mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 05:05:52 +08:00
chore(server): improve migration compatibility (#15014)
#### PR Dependency Tree * **PR #15014** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Remove orphaned legacy subscription and entitlement records during backfill. * Repair workspaces missing active owners by promoting eligible members and cleaning up empty workspaces. * Skip cloud subscription backfill when target user/workspace no longer exists to avoid dangling data. * **Tests** * Added tests verifying legacy data cleanup during backfill. * Added tests verifying workspace ownership repair and migration behavior. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/15014?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -250,6 +250,52 @@ test('backfill marks selfhost team subscriptions as needing license revalidation
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('backfill removes dangling legacy subscriptions and entitlements', async t => {
|
||||||
|
await t.context.db.subscription.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
targetId: randomUUID(),
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
start: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetId: randomUUID(),
|
||||||
|
plan: SubscriptionPlan.Team,
|
||||||
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
start: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await t.context.db.entitlement.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: randomUUID(),
|
||||||
|
source: 'cloud_subscription',
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
subjectId: randomUUID(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetType: 'workspace',
|
||||||
|
targetId: randomUUID(),
|
||||||
|
source: 'cloud_subscription',
|
||||||
|
plan: 'team',
|
||||||
|
status: 'active',
|
||||||
|
subjectId: randomUUID(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.context.projection.backfillEntitlementsAndQuotaStates();
|
||||||
|
|
||||||
|
t.is(await t.context.db.subscription.count(), 0);
|
||||||
|
t.is(await t.context.db.entitlement.count(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('key based selfhost entitlements without raw payload need reupload', async t => {
|
test('key based selfhost entitlements without raw payload need reupload', async t => {
|
||||||
const owner = await t.context.models.user.create({
|
const owner = await t.context.models.user.create({
|
||||||
email: `${randomUUID()}@affine.pro`,
|
email: `${randomUUID()}@affine.pro`,
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ export class LegacyEntitlementProjectionService {
|
|||||||
targetId,
|
targetId,
|
||||||
}: Events['entitlement.changed']) {
|
}: Events['entitlement.changed']) {
|
||||||
if (targetType === 'user') {
|
if (targetType === 'user') {
|
||||||
await this.projectCloudSubscriptions('user', targetId);
|
await this.#projectCloudSubscriptions('user', targetId);
|
||||||
await this.projectUserFeatures(targetId);
|
await this.#projectUserFeatures(targetId);
|
||||||
} else if (targetType === 'workspace') {
|
} else if (targetType === 'workspace') {
|
||||||
await this.projectCloudSubscriptions('workspace', targetId);
|
await this.#projectCloudSubscriptions('workspace', targetId);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.projectWorkspaceFeatures(targetId),
|
this.#projectWorkspaceFeatures(targetId),
|
||||||
this.projectInstalledLicense(targetId),
|
this.#projectInstalledLicense(targetId),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
async onWorkspaceQuotaStateChanged({
|
async onWorkspaceQuotaStateChanged({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}: Events['workspace.quota_state.changed']) {
|
}: Events['workspace.quota_state.changed']) {
|
||||||
await this.projectReadonlyFeature(workspaceId);
|
await this.#projectReadonlyFeature(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanInstalledLicenses() {
|
async scanInstalledLicenses() {
|
||||||
@@ -88,6 +88,8 @@ export class LegacyEntitlementProjectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async backfillEntitlementsAndQuotaStates() {
|
async backfillEntitlementsAndQuotaStates() {
|
||||||
|
await this.#cleanupDanglingLegacyEntitlements();
|
||||||
|
|
||||||
const [subscriptions, users, workspaces] = await Promise.all([
|
const [subscriptions, users, workspaces] = await Promise.all([
|
||||||
this.db.subscription.findMany(),
|
this.db.subscription.findMany(),
|
||||||
this.db.user.findMany({ select: { id: true } }),
|
this.db.user.findMany({ select: { id: true } }),
|
||||||
@@ -95,6 +97,9 @@ export class LegacyEntitlementProjectionService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
|
if (!(await this.#subscriptionTargetExists(subscription))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
|
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
|
||||||
await this.entitlement.markSelfhostLicenseNeedsReupload({
|
await this.entitlement.markSelfhostLicenseNeedsReupload({
|
||||||
licenseKey: subscription.targetId,
|
licenseKey: subscription.targetId,
|
||||||
@@ -148,8 +153,74 @@ export class LegacyEntitlementProjectionService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async projectUserFeatures(userId: string) {
|
async #cleanupDanglingLegacyEntitlements() {
|
||||||
const entitlements = await this.activeEntitlements('user', userId);
|
await this.db.$executeRaw`
|
||||||
|
DELETE FROM entitlements entitlement
|
||||||
|
WHERE (
|
||||||
|
entitlement.target_type = 'user'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
WHERE users.id = entitlement.target_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
entitlement.target_type = 'workspace'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM workspaces
|
||||||
|
WHERE workspaces.id = entitlement.target_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.db.$executeRaw`
|
||||||
|
DELETE FROM subscriptions subscription
|
||||||
|
WHERE (
|
||||||
|
subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI})
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
WHERE users.id = subscription.target_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
subscription.plan = ${SubscriptionPlan.Team}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM workspaces
|
||||||
|
WHERE workspaces.id = subscription.target_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #subscriptionTargetExists(subscription: {
|
||||||
|
targetId: string;
|
||||||
|
plan: string;
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
subscription.plan === SubscriptionPlan.Pro ||
|
||||||
|
subscription.plan === SubscriptionPlan.AI
|
||||||
|
) {
|
||||||
|
return !!(await this.db.user.findUnique({
|
||||||
|
where: { id: subscription.targetId },
|
||||||
|
select: { id: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.plan === SubscriptionPlan.Team) {
|
||||||
|
return !!(await this.db.workspace.findUnique({
|
||||||
|
where: { id: subscription.targetId },
|
||||||
|
select: { id: true },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #projectUserFeatures(userId: string) {
|
||||||
|
const entitlements = await this.#activeEntitlements('user', userId);
|
||||||
const quotaEntitlement = entitlements.find(entitlement =>
|
const quotaEntitlement = entitlements.find(entitlement =>
|
||||||
['lifetime_pro', 'pro'].includes(entitlement.plan)
|
['lifetime_pro', 'pro'].includes(entitlement.plan)
|
||||||
);
|
);
|
||||||
@@ -190,7 +261,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async projectWorkspaceFeatures(workspaceId: string) {
|
async #projectWorkspaceFeatures(workspaceId: string) {
|
||||||
const [entitlement, resolved] = await Promise.all([
|
const [entitlement, resolved] = await Promise.all([
|
||||||
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
this.entitlement.getBestEntitlement('workspace', workspaceId),
|
||||||
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
|
||||||
@@ -215,7 +286,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async projectCloudSubscriptions(
|
async #projectCloudSubscriptions(
|
||||||
targetType: 'user' | 'workspace',
|
targetType: 'user' | 'workspace',
|
||||||
targetId: string
|
targetId: string
|
||||||
) {
|
) {
|
||||||
@@ -229,13 +300,15 @@ export class LegacyEntitlementProjectionService {
|
|||||||
orderBy: { updatedAt: 'asc' },
|
orderBy: { updatedAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const entitlement of this.projectableCloudEntitlements(entitlements)) {
|
for (const entitlement of this.#projectableCloudEntitlements(
|
||||||
|
entitlements
|
||||||
|
)) {
|
||||||
const metadata = entitlement.metadata as Metadata;
|
const metadata = entitlement.metadata as Metadata;
|
||||||
await this.db.subscription.upsert({
|
await this.db.subscription.upsert({
|
||||||
where: {
|
where: {
|
||||||
targetId_plan: {
|
targetId_plan: {
|
||||||
targetId,
|
targetId,
|
||||||
plan: this.subscriptionPlan(entitlement.plan),
|
plan: this.#subscriptionPlan(entitlement.plan),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -243,21 +316,21 @@ export class LegacyEntitlementProjectionService {
|
|||||||
variant: metadata.variant ?? null,
|
variant: metadata.variant ?? null,
|
||||||
quantity: entitlement.quantity ?? 1,
|
quantity: entitlement.quantity ?? 1,
|
||||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||||
provider: this.provider(metadata.provider),
|
provider: this.#provider(metadata.provider),
|
||||||
status: this.subscriptionStatus(entitlement.status),
|
status: this.#subscriptionStatus(entitlement.status),
|
||||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||||
end: entitlement.expiresAt,
|
end: entitlement.expiresAt,
|
||||||
trialEnd: entitlement.graceUntil,
|
trialEnd: entitlement.graceUntil,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
targetId,
|
targetId,
|
||||||
plan: this.subscriptionPlan(entitlement.plan),
|
plan: this.#subscriptionPlan(entitlement.plan),
|
||||||
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
|
||||||
variant: metadata.variant ?? null,
|
variant: metadata.variant ?? null,
|
||||||
quantity: entitlement.quantity ?? 1,
|
quantity: entitlement.quantity ?? 1,
|
||||||
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
|
||||||
provider: this.provider(metadata.provider),
|
provider: this.#provider(metadata.provider),
|
||||||
status: this.subscriptionStatus(entitlement.status),
|
status: this.#subscriptionStatus(entitlement.status),
|
||||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
start: entitlement.startsAt ?? entitlement.createdAt,
|
||||||
end: entitlement.expiresAt,
|
end: entitlement.expiresAt,
|
||||||
trialEnd: entitlement.graceUntil,
|
trialEnd: entitlement.graceUntil,
|
||||||
@@ -277,17 +350,17 @@ export class LegacyEntitlementProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private *projectableCloudEntitlements(entitlements: Entitlement[]) {
|
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
|
||||||
const byPlan = new Map<string, Entitlement>();
|
const byPlan = new Map<string, Entitlement>();
|
||||||
|
|
||||||
for (const entitlement of entitlements) {
|
for (const entitlement of entitlements) {
|
||||||
const plan = this.subscriptionPlan(entitlement.plan);
|
const plan = this.#subscriptionPlan(entitlement.plan);
|
||||||
const current = byPlan.get(plan);
|
const current = byPlan.get(plan);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!current ||
|
!current ||
|
||||||
this.subscriptionProjectionPriority(entitlement) >
|
this.#subscriptionProjectionPriority(entitlement) >
|
||||||
this.subscriptionProjectionPriority(current)
|
this.#subscriptionProjectionPriority(current)
|
||||||
) {
|
) {
|
||||||
byPlan.set(plan, entitlement);
|
byPlan.set(plan, entitlement);
|
||||||
}
|
}
|
||||||
@@ -296,7 +369,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
yield* byPlan.values();
|
yield* byPlan.values();
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscriptionProjectionPriority(entitlement: {
|
#subscriptionProjectionPriority(entitlement: {
|
||||||
status: string;
|
status: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}) {
|
}) {
|
||||||
@@ -312,7 +385,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async projectInstalledLicense(workspaceId: string) {
|
async #projectInstalledLicense(workspaceId: string) {
|
||||||
const [entitlements, resolved] = await Promise.all([
|
const [entitlements, resolved] = await Promise.all([
|
||||||
this.db.entitlement.findMany({
|
this.db.entitlement.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -326,8 +399,8 @@ export class LegacyEntitlementProjectionService {
|
|||||||
]);
|
]);
|
||||||
const entitlement = entitlements.sort(
|
const entitlement = entitlements.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
this.installedLicenseStatusPriority(right.status) -
|
this.#installedLicenseStatusPriority(right.status) -
|
||||||
this.installedLicenseStatusPriority(left.status) ||
|
this.#installedLicenseStatusPriority(left.status) ||
|
||||||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
|
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
|
||||||
right.updatedAt.getTime() - left.updatedAt.getTime()
|
right.updatedAt.getTime() - left.updatedAt.getTime()
|
||||||
)[0];
|
)[0];
|
||||||
@@ -386,7 +459,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private installedLicenseStatusPriority(status: string) {
|
#installedLicenseStatusPriority(status: string) {
|
||||||
if (status === 'active' || status === 'grace') {
|
if (status === 'active' || status === 'grace') {
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
@@ -399,7 +472,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async projectReadonlyFeature(workspaceId: string) {
|
async #projectReadonlyFeature(workspaceId: string) {
|
||||||
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
|
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -420,7 +493,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async activeEntitlements(
|
async #activeEntitlements(
|
||||||
targetType: 'user' | 'workspace',
|
targetType: 'user' | 'workspace',
|
||||||
targetId: string
|
targetId: string
|
||||||
) {
|
) {
|
||||||
@@ -439,7 +512,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscriptionPlan(plan: string) {
|
#subscriptionPlan(plan: string) {
|
||||||
if (plan === 'lifetime_pro') {
|
if (plan === 'lifetime_pro') {
|
||||||
return SubscriptionPlan.Pro;
|
return SubscriptionPlan.Pro;
|
||||||
}
|
}
|
||||||
@@ -449,7 +522,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscriptionStatus(status: string) {
|
#subscriptionStatus(status: string) {
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
return SubscriptionStatus.Active;
|
return SubscriptionStatus.Active;
|
||||||
}
|
}
|
||||||
@@ -459,7 +532,7 @@ export class LegacyEntitlementProjectionService {
|
|||||||
return SubscriptionStatus.Canceled;
|
return SubscriptionStatus.Canceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private provider(provider: string | null | undefined) {
|
#provider(provider: string | null | undefined) {
|
||||||
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
|
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import ava, { TestFn } from 'ava';
|
||||||
|
|
||||||
|
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
import { BackfillPermissionProjection1765500000000 } from '../migrations/1765500000000-backfill-permission-projection';
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
module: TestingModule;
|
||||||
|
db: PrismaClient;
|
||||||
|
models: Models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = ava as TestFn<Context>;
|
||||||
|
|
||||||
|
test.before(async t => {
|
||||||
|
t.context.module = await createTestingModule();
|
||||||
|
t.context.db = t.context.module.get(PrismaClient);
|
||||||
|
t.context.models = t.context.module.get(Models);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async t => {
|
||||||
|
await t.context.module.initTestingDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after.always(async t => {
|
||||||
|
await t.context.module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permission backfill repairs ownerless workspaces before runtime state projection', async t => {
|
||||||
|
const emptyWorkspace = await t.context.db.workspace.create({
|
||||||
|
data: { public: false },
|
||||||
|
});
|
||||||
|
const member = await t.context.models.user.create({
|
||||||
|
email: 'member@affine.pro',
|
||||||
|
});
|
||||||
|
const memberWorkspace = await t.context.db.workspace.create({
|
||||||
|
data: { public: false },
|
||||||
|
});
|
||||||
|
await t.context.db.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: memberWorkspace.id,
|
||||||
|
userId: member.id,
|
||||||
|
role: 'member',
|
||||||
|
state: 'active',
|
||||||
|
source: 'legacy',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ref = {
|
||||||
|
get(token: unknown) {
|
||||||
|
if (token === Models) {
|
||||||
|
return t.context.models;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async getWorkspaceState() {
|
||||||
|
return {
|
||||||
|
isReadonly: false,
|
||||||
|
readonlyReasons: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as ModuleRef;
|
||||||
|
|
||||||
|
await BackfillPermissionProjection1765500000000.up(t.context.db, ref);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
await t.context.db.workspace.count({ where: { id: emptyWorkspace.id } }),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
t.like(
|
||||||
|
await t.context.db.workspaceMember.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
workspaceId: memberWorkspace.id,
|
||||||
|
userId: member.id,
|
||||||
|
state: 'active',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ role: 'owner' }
|
||||||
|
);
|
||||||
|
t.like(
|
||||||
|
await t.context.db.workspaceUserRole.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
workspaceId: memberWorkspace.id,
|
||||||
|
userId: member.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ type: 99 }
|
||||||
|
);
|
||||||
|
});
|
||||||
+82
-2
@@ -5,12 +5,14 @@ import { WorkspacePolicyService } from '../../core/permission/policy';
|
|||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
|
|
||||||
export class BackfillPermissionProjection1765500000000 {
|
export class BackfillPermissionProjection1765500000000 {
|
||||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
static async up(db: PrismaClient, ref: ModuleRef) {
|
||||||
const models = ref.get(Models, { strict: false });
|
const models = ref.get(Models, { strict: false });
|
||||||
await models.permissionProjection.backfillLegacyProjection();
|
await models.permissionProjection.backfillLegacyProjection();
|
||||||
|
await ensureWorkspaceAdminStatsDirtyTriggerGuard(db);
|
||||||
|
await repairOwnerlessWorkspaces(db);
|
||||||
|
|
||||||
const policy = ref.get(WorkspacePolicyService, { strict: false });
|
const policy = ref.get(WorkspacePolicyService, { strict: false });
|
||||||
const workspaces = await _db.workspace.findMany({
|
const workspaces = await db.workspace.findMany({
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
@@ -26,3 +28,81 @@ export class BackfillPermissionProjection1765500000000 {
|
|||||||
|
|
||||||
static async down(_db: PrismaClient) {}
|
static async down(_db: PrismaClient) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWorkspaceAdminStatsDirtyTriggerGuard(db: PrismaClient) {
|
||||||
|
await db.$executeRaw`
|
||||||
|
CREATE OR REPLACE FUNCTION workspace_admin_stats_mark_dirty() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
wid VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
wid := COALESCE(NEW."workspace_id", OLD."workspace_id");
|
||||||
|
IF wid IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "workspaces" WHERE "id" = wid) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "workspace_admin_stats_dirty" ("workspace_id", "updated_at")
|
||||||
|
VALUES (wid, NOW())
|
||||||
|
ON CONFLICT ("workspace_id")
|
||||||
|
DO UPDATE SET "updated_at" = EXCLUDED."updated_at";
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairOwnerlessWorkspaces(db: PrismaClient) {
|
||||||
|
await db.$executeRaw`
|
||||||
|
WITH ownerless AS (
|
||||||
|
SELECT w.id
|
||||||
|
FROM workspaces w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM workspace_members owner
|
||||||
|
WHERE owner.workspace_id = w.id
|
||||||
|
AND owner.role = 'owner'
|
||||||
|
AND owner.state = 'active'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
accepted_members AS (
|
||||||
|
SELECT id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
wm.id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY wm.workspace_id
|
||||||
|
ORDER BY wm.created_at ASC, wm.id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM workspace_members wm
|
||||||
|
JOIN ownerless o ON o.id = wm.workspace_id
|
||||||
|
WHERE wm.state = 'active'
|
||||||
|
) ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
UPDATE workspace_members wm
|
||||||
|
SET role = 'owner', updated_at = now()
|
||||||
|
FROM accepted_members am
|
||||||
|
WHERE wm.id = am.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
DELETE FROM workspaces w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM workspace_members owner
|
||||||
|
WHERE owner.workspace_id = w.id
|
||||||
|
AND owner.role = 'owner'
|
||||||
|
AND owner.state = 'active'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM workspace_members member
|
||||||
|
WHERE member.workspace_id = w.id
|
||||||
|
AND member.state = 'active'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user