feat(server): cleanup legacy compatibility (#15239)

This commit is contained in:
DarkSky
2026-07-15 03:01:51 +08:00
committed by GitHub
parent 00d4ab10a1
commit e145d87d56
104 changed files with 3184 additions and 8883 deletions
-16
View File
@@ -384,22 +384,6 @@
}
}
},
"permission": {
"type": "object",
"description": "Configuration for permission module",
"properties": {
"readModel": {
"type": "string",
"description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`",
"default": "projection"
},
"fallbackLegacyLoader": {
"type": "boolean",
"description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`",
"default": false
}
}
},
"storages": {
"type": "object",
"description": "Configuration for storages module",
@@ -0,0 +1,494 @@
SET affine.permission_projection.enabled = 'off';
-- Final copy. Existing rows are authoritative and are never overwritten.
INSERT INTO "workspace_access_policies" (
"workspace_id", "visibility", "sharing_enabled", "url_preview_enabled"
)
SELECT
w."id",
CASE WHEN w."public" THEN 'public' ELSE 'private' END,
w."enable_sharing",
w."enable_url_preview"
FROM "workspaces" w
ON CONFLICT ("workspace_id") DO NOTHING;
INSERT INTO "workspace_members" (
"workspace_id", "user_id", "role", "state", "source", "created_at", "updated_at"
)
SELECT
p."workspace_id",
p."user_id",
affine_permission_legacy_workspace_role(p."type"),
'active',
CASE p."source"::text WHEN 'Link' THEN 'link' ELSE 'email' END,
p."created_at",
p."updated_at"
FROM "workspace_user_permissions" p
WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO "workspace_invitations" (
"workspace_id", "invitee_user_id", "inviter_user_id", "requested_role",
"status", "kind", "created_at", "updated_at"
)
SELECT
p."workspace_id",
p."user_id",
p."inviter_id",
CASE affine_permission_legacy_workspace_role(p."type")
WHEN 'admin' THEN 'admin'
ELSE 'member'
END,
affine_permission_workspace_invitation_state(p."status"),
CASE p."source"::text WHEN 'Link' THEN 'link' ELSE 'email' END,
p."created_at",
p."updated_at"
FROM "workspace_user_permissions" p
WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL
AND affine_permission_workspace_invitation_state(p."status") IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO "doc_access_policies" (
"workspace_id", "doc_id", "visibility", "public_role",
"member_default_role", "published_at"
)
SELECT
p."workspace_id",
p."page_id",
CASE WHEN p."public" THEN 'public' ELSE 'private' END,
CASE WHEN p."public" THEN 'external' ELSE NULL END,
affine_permission_legacy_default_doc_role(p."defaultRole"),
p."published_at"
FROM "workspace_pages" p
ON CONFLICT ("workspace_id", "doc_id") DO NOTHING;
INSERT INTO "doc_grants" (
"workspace_id", "doc_id", "principal_type", "principal_id", "role", "created_at"
)
SELECT
p."workspace_id",
p."page_id",
'user',
p."user_id",
affine_permission_legacy_doc_role(p."type"),
p."created_at"
FROM "workspace_page_user_permissions" p
WHERE affine_permission_legacy_doc_role(p."type") IS NOT NULL
ON CONFLICT DO NOTHING;
INSERT INTO "effective_workspace_quota_states" (
"workspace_id", "plan", "seat_limit", "member_count",
"overcapacity_member_count", "blob_limit", "storage_quota",
"history_period_seconds", "readonly", "readonly_reasons", "known",
"stale", "last_reconciled_at", "stale_after", "created_at", "updated_at"
)
SELECT
s."workspace_id",
'free',
3,
0,
0,
10485760,
10737418240,
604800,
s."readonly",
s."readonly_reasons",
s."known",
NOT s."known",
s."last_reconciled_at",
s."stale_after",
s."created_at",
s."updated_at"
FROM "workspace_runtime_states" s
ON CONFLICT ("workspace_id") DO NOTHING;
INSERT INTO "provider_subscriptions" (
"id", "provider", "target_type", "target_id", "plan", "recurring",
"status", "external_customer_id", "external_subscription_id",
"external_product_id", "iap_store", "external_ref", "quantity",
"period_start", "period_end", "trial_start", "trial_end", "canceled_at",
"metadata", "created_at", "updated_at"
)
SELECT
gen_random_uuid()::text,
s."provider",
CASE
WHEN s."plan" = 'team' THEN 'workspace'
WHEN s."plan" IN ('selfhosted', 'selfhostedteam') THEN 'instance'
ELSE 'user'
END,
s."target_id",
s."plan",
s."recurring",
s."status",
CASE WHEN s."provider" = 'revenuecat'::"Provider" THEN s."target_id" END,
CASE
WHEN s."provider" = 'stripe'::"Provider"
THEN COALESCE(s."stripe_subscription_id", 'legacy_subscription:' || s."id"::text)
END,
CASE
WHEN s."provider" = 'revenuecat'::"Provider"
THEN COALESCE(s."rc_product_id", 'legacy_product:' || s."id"::text)
END,
CASE
WHEN s."provider" = 'revenuecat'::"Provider"
THEN COALESCE(s."iap_store", 'app_store'::"IapStore")
END,
CASE
WHEN s."provider" = 'revenuecat'::"Provider"
THEN COALESCE(s."rc_external_ref", 'legacy_subscription:' || s."id"::text)
END,
s."quantity",
s."start",
s."end",
s."trial_start",
s."trial_end",
s."canceled_at",
jsonb_strip_nulls(jsonb_build_object(
'variant', s."variant",
'stripeScheduleId', s."stripe_schedule_id",
'nextBillAt', s."next_bill_at",
'entitlement', s."rc_entitlement",
'legacySubscriptionId', s."id",
'legacyRevenueCatIdentityIncomplete',
s."provider" = 'revenuecat'::"Provider"
)),
s."created_at",
s."updated_at"
FROM "subscriptions" s
ON CONFLICT DO NOTHING;
INSERT INTO "entitlements" (
"id", "target_type", "target_id", "source", "plan", "status",
"subject_id", "quantity", "metadata", "starts_at", "expires_at",
"validated_at", "created_at", "updated_at"
)
SELECT
gen_random_uuid()::text,
ps."target_type",
ps."target_id",
'cloud_subscription',
CASE
WHEN ps."plan" = 'pro' AND ps."recurring" = 'lifetime' THEN 'lifetime_pro'
WHEN ps."plan" = 'selfhostedteam' THEN 'selfhost_team'
ELSE ps."plan"
END,
CASE
WHEN ps."status" IN ('active', 'trialing') THEN 'active'
WHEN ps."status" = 'past_due' THEN 'grace'
WHEN ps."status" = 'canceled' THEN 'revoked'
ELSE 'expired'
END,
CASE
WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id"
ELSE ps."id"
END,
CASE WHEN ps."target_type" = 'workspace' THEN GREATEST(ps."quantity", 1) END,
jsonb_build_object('providerSubscriptionId', ps."id", 'recurring', ps."recurring"),
ps."period_start",
ps."period_end",
CURRENT_TIMESTAMP,
ps."created_at",
ps."updated_at"
FROM "provider_subscriptions" ps
WHERE ps."target_type" <> 'instance'
AND NOT EXISTS (
SELECT 1
FROM "entitlements" e
WHERE e."source" = 'cloud_subscription'
AND e."subject_id" = CASE
WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id"
ELSE ps."id"
END
)
ON CONFLICT DO NOTHING;
INSERT INTO "entitlements" (
"id", "target_type", "target_id", "source", "plan", "status",
"subject_id", "metadata", "validated_at", "created_at", "updated_at"
)
SELECT
gen_random_uuid()::text,
'user',
f."user_id",
'admin_grant',
CASE f."name"
WHEN 'pro_plan_v1' THEN 'pro'
WHEN 'lifetime_pro_plan_v1' THEN 'lifetime_pro'
WHEN 'unlimited_copilot' THEN 'ai'
ELSE 'free'
END,
'active',
'legacy_user_feature:' || f."id"::text,
jsonb_build_object('legacyFeature', f."name"),
CURRENT_TIMESTAMP,
f."created_at",
CURRENT_TIMESTAMP
FROM "user_features" f
WHERE f."activated"
AND (f."expired_at" IS NULL OR f."expired_at" > CURRENT_TIMESTAMP)
AND f."name" IN ('free_plan_v1', 'pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot')
AND NOT EXISTS (
SELECT 1 FROM "entitlements" e
WHERE e."target_type" = 'user'
AND e."target_id" = f."user_id"
AND e."plan" = CASE f."name"
WHEN 'pro_plan_v1' THEN 'pro'
WHEN 'lifetime_pro_plan_v1' THEN 'lifetime_pro'
WHEN 'unlimited_copilot' THEN 'ai'
ELSE 'free'
END
AND e."status" IN ('active', 'grace')
)
ON CONFLICT DO NOTHING;
INSERT INTO "entitlements" (
"id", "target_type", "target_id", "source", "plan", "status",
"subject_id", "quantity", "metadata", "validated_at", "created_at", "updated_at"
)
SELECT
gen_random_uuid()::text,
'workspace',
f."workspace_id",
'admin_grant',
'team',
'active',
'legacy_workspace_feature:' || f."id"::text,
GREATEST(COALESCE((f."configs" ->> 'memberLimit')::integer, 1), 1),
jsonb_build_object('legacyFeature', f."name"),
CURRENT_TIMESTAMP,
f."created_at",
CURRENT_TIMESTAMP
FROM "workspace_features" f
WHERE f."activated"
AND (f."expired_at" IS NULL OR f."expired_at" > CURRENT_TIMESTAMP)
AND f."name" = 'team_plan_v1'
AND NOT EXISTS (
SELECT 1 FROM "entitlements" e
WHERE e."target_type" = 'workspace'
AND e."target_id" = f."workspace_id"
AND e."plan" = 'team'
AND e."status" IN ('active', 'grace')
)
ON CONFLICT DO NOTHING;
DO $$
DECLARE
member_conflicts BIGINT;
invitation_conflicts BIGINT;
workspace_policy_conflicts BIGINT;
doc_policy_conflicts BIGINT;
doc_grant_conflicts BIGINT;
runtime_conflicts BIGINT;
subscription_conflicts BIGINT;
missing_members BIGINT;
missing_invitations BIGINT;
missing_workspace_policies BIGINT;
missing_doc_policies BIGINT;
missing_doc_grants BIGINT;
missing_runtime_states BIGINT;
missing_provider_subscriptions BIGINT;
missing_cloud_entitlements BIGINT;
BEGIN
SELECT COUNT(*) INTO member_conflicts
FROM "workspace_user_permissions" p
JOIN "workspace_members" m
ON m."workspace_id" = p."workspace_id"
AND m."user_id" = p."user_id"
AND m."state" = 'active'
WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(p."type") IS DISTINCT FROM m."role";
SELECT COUNT(*) INTO invitation_conflicts
FROM "workspace_user_permissions" p
JOIN "workspace_invitations" i
ON i."workspace_id" = p."workspace_id"
AND i."invitee_user_id" = p."user_id"
WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus"
AND (
affine_permission_workspace_invitation_state(p."status") IS DISTINCT FROM i."status"
OR CASE affine_permission_legacy_workspace_role(p."type")
WHEN 'admin' THEN 'admin'
ELSE 'member'
END IS DISTINCT FROM i."requested_role"
);
SELECT COUNT(*) INTO workspace_policy_conflicts
FROM "workspaces" w
JOIN "workspace_access_policies" p ON p."workspace_id" = w."id"
WHERE p."visibility" IS DISTINCT FROM CASE WHEN w."public" THEN 'public' ELSE 'private' END
OR p."sharing_enabled" IS DISTINCT FROM w."enable_sharing"
OR p."url_preview_enabled" IS DISTINCT FROM w."enable_url_preview";
SELECT COUNT(*) INTO doc_policy_conflicts
FROM "workspace_pages" d
JOIN "doc_access_policies" p
ON p."workspace_id" = d."workspace_id" AND p."doc_id" = d."page_id"
WHERE p."visibility" IS DISTINCT FROM CASE WHEN d."public" THEN 'public' ELSE 'private' END
OR p."member_default_role" IS DISTINCT FROM affine_permission_legacy_default_doc_role(d."defaultRole");
SELECT COUNT(*) INTO doc_grant_conflicts
FROM "workspace_page_user_permissions" d
JOIN "doc_grants" g
ON g."workspace_id" = d."workspace_id"
AND g."doc_id" = d."page_id"
AND g."principal_type" = 'user'
AND g."principal_id" = d."user_id"
WHERE affine_permission_legacy_doc_role(d."type") IS DISTINCT FROM g."role";
SELECT COUNT(*) INTO runtime_conflicts
FROM "workspace_runtime_states" r
JOIN "effective_workspace_quota_states" q ON q."workspace_id" = r."workspace_id"
WHERE q."readonly" IS DISTINCT FROM r."readonly"
OR q."readonly_reasons" IS DISTINCT FROM r."readonly_reasons";
SELECT COUNT(*) INTO subscription_conflicts
FROM "subscriptions" s
JOIN "provider_subscriptions" ps
ON ps."provider" = s."provider"
AND (
(
s."provider" = 'stripe'::"Provider"
AND ps."external_subscription_id" = COALESCE(
s."stripe_subscription_id",
'legacy_subscription:' || s."id"::text
)
)
OR (
s."provider" = 'revenuecat'::"Provider"
AND ps."iap_store" = COALESCE(s."iap_store", 'app_store'::"IapStore")
AND ps."external_ref" = COALESCE(
s."rc_external_ref",
'legacy_subscription:' || s."id"::text
)
AND ps."external_product_id" = COALESCE(
s."rc_product_id",
'legacy_product:' || s."id"::text
)
AND ps."external_customer_id" = s."target_id"
)
)
WHERE ps."status" IS DISTINCT FROM s."status";
SELECT COUNT(*) INTO missing_members
FROM "workspace_user_permissions" p
WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "workspace_members" m
WHERE m."workspace_id" = p."workspace_id"
AND m."user_id" = p."user_id"
AND m."state" = 'active'
);
SELECT COUNT(*) INTO missing_invitations
FROM "workspace_user_permissions" p
WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL
AND affine_permission_workspace_invitation_state(p."status") IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "workspace_invitations" i
WHERE i."workspace_id" = p."workspace_id"
AND i."invitee_user_id" = p."user_id"
);
SELECT COUNT(*) INTO missing_workspace_policies
FROM "workspaces" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_access_policies" p
WHERE p."workspace_id" = w."id"
);
SELECT COUNT(*) INTO missing_doc_policies
FROM "workspace_pages" d
WHERE NOT EXISTS (
SELECT 1 FROM "doc_access_policies" p
WHERE p."workspace_id" = d."workspace_id"
AND p."doc_id" = d."page_id"
);
SELECT COUNT(*) INTO missing_doc_grants
FROM "workspace_page_user_permissions" d
WHERE affine_permission_legacy_doc_role(d."type") IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM "doc_grants" g
WHERE g."workspace_id" = d."workspace_id"
AND g."doc_id" = d."page_id"
AND g."principal_type" = 'user'
AND g."principal_id" = d."user_id"
);
SELECT COUNT(*) INTO missing_runtime_states
FROM "workspace_runtime_states" r
WHERE NOT EXISTS (
SELECT 1 FROM "effective_workspace_quota_states" q
WHERE q."workspace_id" = r."workspace_id"
);
SELECT COUNT(*) INTO missing_provider_subscriptions
FROM "subscriptions" s
WHERE NOT EXISTS (
SELECT 1 FROM "provider_subscriptions" ps
WHERE ps."provider" = s."provider"
AND (
(
s."provider" = 'stripe'::"Provider"
AND ps."external_subscription_id" = COALESCE(
s."stripe_subscription_id",
'legacy_subscription:' || s."id"::text
)
)
OR (
s."provider" = 'revenuecat'::"Provider"
AND ps."iap_store" = COALESCE(s."iap_store", 'app_store'::"IapStore")
AND ps."external_ref" = COALESCE(
s."rc_external_ref",
'legacy_subscription:' || s."id"::text
)
AND ps."external_product_id" = COALESCE(
s."rc_product_id",
'legacy_product:' || s."id"::text
)
AND ps."external_customer_id" = s."target_id"
)
)
);
SELECT COUNT(*) INTO missing_cloud_entitlements
FROM "provider_subscriptions" ps
WHERE ps."target_type" <> 'instance'
AND NOT EXISTS (
SELECT 1 FROM "entitlements" e
WHERE e."source" = 'cloud_subscription'
AND e."subject_id" = CASE
WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id"
ELSE ps."id"
END
);
RAISE NOTICE 'final legacy backfill retained new-table values for member=% invitation=% workspace_policy=% doc_policy=% doc_grant=% runtime=% subscription=% conflicts',
member_conflicts,
invitation_conflicts,
workspace_policy_conflicts,
doc_policy_conflicts,
doc_grant_conflicts,
runtime_conflicts,
subscription_conflicts;
IF missing_members + missing_invitations + missing_workspace_policies
+ missing_doc_policies + missing_doc_grants + missing_runtime_states
+ missing_provider_subscriptions + missing_cloud_entitlements > 0 THEN
RAISE EXCEPTION 'legacy backfill incomplete: member=% invitation=% workspace_policy=% doc_policy=% doc_grant=% runtime=% provider_subscription=% cloud_entitlement=% missing facts',
missing_members,
missing_invitations,
missing_workspace_policies,
missing_doc_policies,
missing_doc_grants,
missing_runtime_states,
missing_provider_subscriptions,
missing_cloud_entitlements;
END IF;
END
$$;
@@ -0,0 +1,73 @@
SET affine.permission_projection.enabled = 'off';
DROP TRIGGER IF EXISTS "affine_permission_project_workspace_user_permission" ON "workspace_user_permissions";
DROP TRIGGER IF EXISTS "affine_permission_project_workspace_page" ON "workspace_pages";
DROP TRIGGER IF EXISTS "affine_permission_project_workspace_page_user_permission" ON "workspace_page_user_permissions";
DROP TRIGGER IF EXISTS "affine_permission_project_workspace_policy" ON "workspaces";
DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_member" ON "workspace_members";
DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_invitation" ON "workspace_invitations";
DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_access_policy" ON "workspace_access_policies";
DROP TRIGGER IF EXISTS "affine_permission_project_new_doc_access_policy" ON "doc_access_policies";
DROP TRIGGER IF EXISTS "affine_permission_project_new_doc_grant" ON "doc_grants";
DROP TRIGGER IF EXISTS "project_legacy_workspace_readonly_feature_trigger" ON "effective_workspace_quota_states";
DROP FUNCTION IF EXISTS affine_permission_project_workspace_user_permission() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_workspace_page() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_workspace_page_user_permission() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_workspace_policy() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_member() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_invitation() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_access_policy() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_new_doc_access_policy() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_project_new_doc_grant() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_projection_error_category(TEXT, TEXT) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_lock_workspace(VARCHAR) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_lock_doc(VARCHAR, VARCHAR) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_should_project_from_legacy() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_should_project_from_new() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_projection_enabled() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_sync_origin() CASCADE;
DROP FUNCTION IF EXISTS affine_permission_new_workspace_role(TEXT) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_new_workspace_source(TEXT) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_new_invitation_status(TEXT) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_new_doc_role(TEXT) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_legacy_workspace_role(INTEGER) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_legacy_doc_role(INTEGER) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_legacy_default_doc_role(INTEGER) CASCADE;
DROP FUNCTION IF EXISTS affine_permission_workspace_invitation_state("WorkspaceMemberStatus") CASCADE;
DROP FUNCTION IF EXISTS project_legacy_workspace_readonly_feature() CASCADE;
DELETE FROM "user_features"
WHERE "name" IN ('free_plan_v1', 'pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot');
DELETE FROM "workspace_features"
WHERE "name" IN ('team_plan_v1', 'quota_exceeded_readonly_workspace_v1', 'unlimited_workspace');
DROP TABLE "workspace_user_permissions";
DROP TABLE "workspace_page_user_permissions";
DROP TABLE "workspace_runtime_states";
DROP TABLE "subscriptions";
DROP INDEX IF EXISTS "workspace_pages_workspace_id_public_idx";
DROP INDEX IF EXISTS "workspace_pages_public_published_at_idx";
DROP INDEX IF EXISTS "workspace_members_legacy_permission_id_key";
DROP INDEX IF EXISTS "workspace_invitations_legacy_permission_id_key";
DROP INDEX IF EXISTS "doc_grants_legacy_key";
ALTER TABLE "workspaces"
DROP COLUMN "public",
DROP COLUMN "enable_sharing",
DROP COLUMN "enable_url_preview";
ALTER TABLE "workspace_pages"
DROP COLUMN "public",
DROP COLUMN "defaultRole";
ALTER TABLE "workspace_members" DROP COLUMN "legacy_permission_id";
ALTER TABLE "workspace_invitations" DROP COLUMN "legacy_permission_id";
ALTER TABLE "doc_grants"
DROP COLUMN "legacy_workspace_id",
DROP COLUMN "legacy_doc_id",
DROP COLUMN "legacy_user_id";
ALTER TABLE "workspace_admin_stats" DROP COLUMN "features";
+31 -150
View File
@@ -27,13 +27,10 @@ model User {
features UserFeature[]
userStripeCustomer UserStripeCustomer?
workspaces WorkspaceUserRole[]
workspaceMembers WorkspaceMember[]
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
// Invite others to join the workspace
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
connectedAccounts ConnectedAccount[]
calendarAccounts CalendarAccount[]
mcpCredentials McpCredential[]
@@ -181,12 +178,9 @@ model Workspace {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement())
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// workspace level feature flags
enableAi Boolean @default(true) @map("enable_ai")
enableSharing Boolean @default(true) @map("enable_sharing")
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding")
name String? @db.VarChar
avatarKey String? @map("avatar_key") @db.VarChar
@@ -195,8 +189,6 @@ model Workspace {
features WorkspaceFeature[]
docs WorkspaceDoc[]
permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserRole[]
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
@@ -210,11 +202,10 @@ model Workspace {
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
workspaceDocViewDaily WorkspaceDocViewDaily[]
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
runtimeState WorkspaceRuntimeState?
quotaState EffectiveWorkspaceQuotaState?
accessPolicy WorkspaceAccessPolicy?
projectedMembers WorkspaceMember[]
projectedInvitations WorkspaceInvitation[]
members WorkspaceMember[]
invitations WorkspaceInvitation[]
docAccessPolicies DocAccessPolicy[]
docGrants DocGrant[]
mcpCredentials McpCredential[]
@@ -224,21 +215,6 @@ model Workspace {
@@map("workspaces")
}
model WorkspaceRuntimeState {
workspaceId String @id @map("workspace_id") @db.VarChar
known Boolean @default(false)
readonly Boolean @default(false)
readonlyReasons String[] @default([]) @map("readonly_reasons")
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@map("workspace_runtime_states")
}
model Entitlement {
id String @id @default(uuid()) @db.VarChar
targetType String @map("target_type") @db.Text
@@ -328,15 +304,14 @@ model EffectiveWorkspaceQuotaState {
}
model WorkspaceMember {
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
role String @db.Text
state String @default("active") @db.Text
source String @default("legacy") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
role String @db.Text
state String @default("active") @db.Text
source String @default("legacy") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -348,21 +323,20 @@ model WorkspaceMember {
}
model WorkspaceInvitation {
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
inviteeUserId String? @map("invitee_user_id") @db.VarChar
normalizedEmail String? @map("normalized_email") @db.VarChar
inviterUserId String? @map("inviter_user_id") @db.VarChar
requestedRole String @default("member") @map("requested_role") @db.Text
status String @db.Text
kind String @default("email") @db.Text
id String @id @default(dbgenerated()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
inviteeUserId String? @map("invitee_user_id") @db.VarChar
normalizedEmail String? @map("normalized_email") @db.VarChar
inviterUserId String? @map("inviter_user_id") @db.VarChar
requestedRole String @default("member") @map("requested_role") @db.Text
status String @db.Text
kind String @default("email") @db.Text
// Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL.
tokenHash String? @map("token_hash") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
tokenHash String? @map("token_hash") @db.Text
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
@@ -462,22 +436,18 @@ model MailDelivery {
}
model DocGrant {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
principalType String @map("principal_type") @db.Text
principalId String @map("principal_id") @db.VarChar
role String @db.Text
grantedBy String? @map("granted_by") @db.VarChar
legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar
legacyDocId String? @map("legacy_doc_id") @db.VarChar
legacyUserId String? @map("legacy_user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
principalType String @map("principal_type") @db.Text
principalId String @map("principal_id") @db.VarChar
role String @db.Text
grantedBy String? @map("granted_by") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId, principalType, principalId])
// Partial unique index exists in migration for non-null legacy ids.
@@index([principalType, principalId, role])
@@index([workspaceId, docId, role])
@@map("doc_grants")
@@ -491,9 +461,6 @@ model DocGrant {
model WorkspaceDoc {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("page_id") @db.VarChar
public Boolean @default(false)
// Workspace user's default role in this page, default is `Manager`
defaultRole Int @default(30) @db.SmallInt
// Page/Edgeless
mode Int @default(0) @db.SmallInt
// Whether the doc is blocked
@@ -505,8 +472,6 @@ model WorkspaceDoc {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId])
@@index([workspaceId, public])
@@index([public, publishedAt])
@@map("workspace_pages")
}
@@ -532,45 +497,6 @@ enum WorkspaceMemberSource {
Link
}
model WorkspaceUserRole {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
// Workspace Role, Owner/Admin/Collaborator/External
type Int @db.SmallInt
/// the invite status of the workspace member
status WorkspaceMemberStatus @default(Pending)
source WorkspaceMemberSource @default(Email)
inviterId String? @map("inviter_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
inviter User? @relation(name: "inviter", fields: [inviterId], references: [id], onDelete: SetNull)
@@unique([workspaceId, userId])
// optimize for querying user's workspace permissions
@@index([workspaceId, type, status])
@@index(userId)
@@map("workspace_user_permissions")
}
model WorkspaceDocUserRole {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("page_id") @db.VarChar
userId String @map("user_id") @db.VarChar
// External/Reader/Editor/Manager/Owner
type Int @db.SmallInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId, userId])
@@map("workspace_page_user_permissions")
}
model UserFeature {
id Int @id @default(autoincrement())
userId String @map("user_id") @db.VarChar
@@ -623,7 +549,6 @@ model WorkspaceAdminStats {
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
memberCount BigInt @default(0) @map("member_count") @db.BigInt
publicPageCount BigInt @default(0) @map("public_page_count") @db.BigInt
features String[] @default([]) @db.Text
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -1174,50 +1099,6 @@ model UserStripeCustomer {
@@map("user_stripe_customers")
}
model Subscription {
id Int @id @default(autoincrement()) @db.Integer
targetId String @map("target_id") @db.VarChar
plan String @db.VarChar(20)
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
quantity Int @default(1) @db.Integer
// subscription.id, null for lifetime payment or one time payment subscription
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// stripe schedule id
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
// subscription provider: stripe or revenuecat
provider Provider @default(stripe)
// iap store for revenuecat subscriptions
iapStore IapStore? @map("iap_store")
// revenuecat entitlement name like "Pro" / "AI"
rcEntitlement String? @map("rc_entitlement") @db.VarChar
// revenuecat product id like "app.affine.pro.Annual"
rcProductId String? @map("rc_product_id") @db.VarChar
// external reference, appstore originalTransactionId or play purchaseToken
rcExternalRef String? @map("rc_external_ref") @db.VarChar
// subscription.status, active/past_due/canceled/unpaid...
status String @db.VarChar(20)
// subscription.current_period_start
start DateTime @map("start") @db.Timestamptz(3)
// subscription.current_period_end, null for lifetime payment
end DateTime? @map("end") @db.Timestamptz(3)
// subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
// subscription.canceled_at
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
// subscription.trial_start
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
// subscription.trial_end
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([targetId, plan])
@@map("subscriptions")
}
model ProviderSubscription {
id String @id @default(uuid()) @db.VarChar
provider Provider
@@ -4,6 +4,7 @@ import Sinon from 'sinon';
import { AuthSessionService, CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EntitlementModule } from '../../core/entitlement';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
@@ -21,7 +22,7 @@ const test = ava as TestFn<{
test.before(async t => {
const m = await createTestingModule({
imports: [QuotaModule, FeatureModule, UserModule],
imports: [EntitlementModule, QuotaModule, FeatureModule, UserModule],
providers: [AuthService],
});
@@ -4,7 +4,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import ava, { type ExecutionContext, type TestFn } from 'ava';
import Sinon from 'sinon';
import { Cache, CryptoHelper } from '../../base';
import { Cache, ConfigFactory, CryptoHelper } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { Models, WorkspaceRole } from '../../models';
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
@@ -553,11 +553,16 @@ test('local leases are short lived and do not persist keys to server configs', a
});
test('local leases persist normalized custom endpoints', async t => {
const customEndpointSupported = Sinon.stub(
t.context.byok,
'customEndpointSupported'
).get(() => true);
t.teardown(() => customEndpointSupported.restore());
const config = t.context.module.get(ConfigFactory);
const deploymentType = globalThis.env.DEPLOYMENT_TYPE;
Object.assign(globalThis.env, { DEPLOYMENT_TYPE: 'selfhosted' });
t.false(t.context.byok.customEndpointSupported);
config.override({ copilot: { byok: { allowCustomEndpoint: true } } });
t.true(t.context.byok.customEndpointSupported);
t.teardown(() => {
config.override({ copilot: { byok: { allowCustomEndpoint: false } } });
Object.assign(globalThis.env, { DEPLOYMENT_TYPE: deploymentType });
});
const { user, workspace } = await createUserWorkspace(t);
await grantUserPlan(t, user.id);
@@ -140,7 +140,7 @@ test('should be able to merge updates as snapshot', async t => {
await db.workspace.create({
data: {
id: '1',
public: false,
accessPolicy: { create: {} },
},
});
@@ -2,8 +2,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { Workspace, WorkspaceRole } from '../../../models';
import { LicenseService } from '../../../plugins/license/service';
import {
SubscriptionRecurring,
SubscriptionVariant as PaymentSubscriptionVariant,
} from '../../../plugins/payment/types';
import {
createApp,
e2e,
@@ -70,6 +76,59 @@ e2e('should install file license', async t => {
});
t.is(res.installLicense.variant, SubscriptionVariant.Onetime);
const db = app.get(PrismaClient);
const installed = await db.installedLicense.findUniqueOrThrow({
where: { workspaceId: workspace.id },
});
const staleAt = new Date(0);
await db.installedLicense.update({
where: { key: installed.key },
data: {
quantity: installed.quantity + 10,
recurring:
installed.recurring === SubscriptionRecurring.Monthly
? SubscriptionRecurring.Yearly
: SubscriptionRecurring.Monthly,
validatedAt: staleAt,
expiredAt: staleAt,
},
});
await db.entitlement.updateMany({
where: {
source: 'selfhost_license',
targetType: 'workspace',
targetId: workspace.id,
},
data: {
quantity: installed.quantity + 20,
validatedAt: staleAt,
expiresAt: staleAt,
},
});
await app.get(LicenseService).licensesHealthCheck();
const revalidated = await db.installedLicense.findUniqueOrThrow({
where: { key: installed.key },
});
t.is(revalidated.variant, PaymentSubscriptionVariant.Onetime);
t.is(revalidated.quantity, installed.quantity);
t.is(revalidated.recurring, installed.recurring);
t.is(revalidated.expiredAt?.getTime(), installed.expiredAt?.getTime());
t.true(revalidated.validatedAt.getTime() > staleAt.getTime());
const entitlement = await db.entitlement.findFirstOrThrow({
where: {
source: 'selfhost_license',
targetType: 'workspace',
targetId: workspace.id,
},
});
t.is(entitlement.quantity, installed.quantity);
t.is(entitlement.expiresAt?.getTime(), installed.expiredAt?.getTime());
t.true((entitlement.validatedAt?.getTime() ?? 0) > staleAt.getTime());
});
e2e('should not allow to install license if not owner', async t => {
@@ -298,9 +298,9 @@ e2e(
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
@@ -309,7 +309,6 @@ e2e(
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
@@ -479,9 +478,9 @@ e2e(
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, ARRAY[]::text[], NOW())
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
@@ -490,7 +489,6 @@ e2e(
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
@@ -6,8 +6,9 @@ import {
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, WorkspaceMemberSource } from '@prisma/client';
import { WorkspacePolicyService } from '../../../core/permission';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
import {
@@ -109,8 +110,9 @@ const cancelTeamWorkspace = async (workspaceId: string) => {
},
data: { status: 'revoked' },
});
await db.subscription.updateMany({
await db.providerSubscription.updateMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: SubscriptionPlan.Team,
},
@@ -176,16 +178,16 @@ e2e('should allocate seats', async t => {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
const linkInvitation = await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
kind: 'link',
});
t.is(linkInvitation.source, WorkspaceMemberSource.Link);
const invitationCount = app.queue.count('notification.sendInvitation');
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
@@ -219,7 +221,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
@@ -227,7 +228,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u3 = await app.createUser();
@@ -235,7 +235,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u3.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
@@ -275,7 +274,6 @@ e2e(
userId: allocating.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const underReview = await app.create(Mockers.User);
@@ -313,10 +311,8 @@ e2e(
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
@@ -350,10 +346,8 @@ e2e(
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
@@ -371,10 +365,8 @@ e2e(
}
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
}
);
@@ -1,16 +1,49 @@
import type { WorkspaceDoc } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput;
export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput & {
public?: boolean;
defaultRole?: DocRole;
};
export type MockedDocMeta = WorkspaceDoc;
export type MockedDocMeta = WorkspaceDoc & {
public: boolean;
defaultRole: DocRole;
};
export class MockDocMeta extends Mocker<MockDocMetaInput, MockedDocMeta> {
override async create(input: MockDocMetaInput) {
return await this.db.workspaceDoc.create({
data: input,
const {
public: isPublic = false,
defaultRole = DocRole.Manager,
...meta
} = input;
const doc = await this.db.workspaceDoc.create({
data: meta,
});
await this.db.docAccessPolicy.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
visibility: isPublic ? 'public' : 'private',
publicRole: isPublic ? 'external' : null,
memberDefaultRole:
defaultRole === DocRole.None
? 'none'
: defaultRole === DocRole.Reader
? 'reader'
: defaultRole === DocRole.Commenter
? 'commenter'
: defaultRole === DocRole.Editor
? 'editor'
: defaultRole === DocRole.Owner
? 'owner'
: 'manager',
},
});
return { ...doc, public: isPublic, defaultRole };
}
}
@@ -1,16 +1,35 @@
import type { WorkspaceDocUserRole } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocUserInput = Prisma.WorkspaceDocUserRoleUncheckedCreateInput;
export type MockDocUserInput = {
workspaceId: string;
docId: string;
userId: string;
type: DocRole;
};
export type MockedDocUser = WorkspaceDocUserRole;
export type MockedDocUser = MockDocUserInput & { createdAt: Date };
export class MockDocUser extends Mocker<MockDocUserInput, MockedDocUser> {
override async create(input: MockDocUserInput) {
return await this.db.workspaceDocUserRole.create({
data: input,
const grant = await this.db.docGrant.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
principalType: 'user',
principalId: input.userId,
role:
input.type === DocRole.Owner
? 'owner'
: input.type === DocRole.Manager
? 'manager'
: input.type === DocRole.Editor
? 'editor'
: input.type === DocRole.Commenter
? 'commenter'
: 'reader',
},
});
return { ...input, createdAt: grant.createdAt };
}
}
@@ -1,6 +1,5 @@
import { faker } from '@faker-js/faker';
import { Feature, FeatureType } from '../../models';
import { Mocker } from './factory';
interface MockTeamWorkspaceInput {
@@ -16,17 +15,6 @@ export class MockTeamWorkspace extends Mocker<
const id = input?.id ?? faker.string.uuid();
const quantity = input?.quantity ?? 10;
await this.db.subscription.create({
data: {
targetId: id,
plan: 'team',
recurring: 'monthly',
status: 'active',
start: faker.date.past(),
nextBillAt: faker.date.future(),
quantity,
},
});
await this.db.entitlement.create({
data: {
targetType: 'workspace',
@@ -38,19 +26,6 @@ export class MockTeamWorkspace extends Mocker<
},
});
await this.db.workspaceFeature.create({
data: {
workspaceId: id,
reason: 'test',
activated: true,
name: Feature.TeamPlan,
type: FeatureType.Quota,
configs: {
memberLimit: quantity,
},
},
});
return { id };
}
}
@@ -1,26 +1,68 @@
import type { Prisma, WorkspaceUserRole } from '@prisma/client';
import { WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import {
WorkspaceMemberStatus,
WorkspaceRole,
type WorkspaceUserCompat,
} from '../../models';
import { workspaceInvitationToCompat } from '../../models/workspace-user-compat';
import { Mocker } from './factory';
export type MockWorkspaceUserInput = Omit<
Prisma.WorkspaceUserRoleUncheckedCreateInput,
'type'
> & {
export type MockWorkspaceUserInput = {
workspaceId: string;
userId: string;
type?: WorkspaceRole;
status?: WorkspaceMemberStatus;
inviterId?: string | null;
kind?: 'email' | 'link';
};
export class MockWorkspaceUser extends Mocker<
MockWorkspaceUserInput,
WorkspaceUserRole
WorkspaceUserCompat
> {
override async create(input: MockWorkspaceUserInput) {
return await this.db.workspaceUserRole.create({
const type = input.type ?? WorkspaceRole.Collaborator;
const status = input.status ?? WorkspaceMemberStatus.Accepted;
if (status === WorkspaceMemberStatus.Accepted) {
const member = await this.db.workspaceMember.create({
data: {
workspaceId: input.workspaceId,
userId: input.userId,
role:
type === WorkspaceRole.Owner
? 'owner'
: type === WorkspaceRole.Admin
? 'admin'
: 'member',
state: 'active',
source: 'legacy',
},
});
return {
...member,
type,
status,
source: 'Email' as const,
inviterId: null,
};
}
const invitation = await this.db.workspaceInvitation.create({
data: {
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Accepted,
...input,
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
inviterUserId: input.inviterId,
requestedRole: type === WorkspaceRole.Admin ? 'admin' : 'member',
status:
status === WorkspaceMemberStatus.UnderReview
? 'waiting_review'
: status === WorkspaceMemberStatus.NeedMoreSeat ||
status === WorkspaceMemberStatus.AllocatingSeat ||
status === WorkspaceMemberStatus.NeedMoreSeatAndReview
? 'waiting_seat'
: 'pending',
kind: input.kind ?? 'email',
},
});
return workspaceInvitationToCompat(invitation);
}
}
@@ -2,15 +2,18 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { faker } from '@faker-js/faker';
import type { Prisma, Workspace } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { omit } from 'lodash-es';
import { WorkspaceRole } from '../../models';
import type { Workspace } from '../../models';
import { Mocker } from './factory';
export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & {
owner?: { id: string };
snapshot?: Uint8Array | true;
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
};
export type MockedWorkspace = Workspace;
@@ -28,72 +31,51 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
input.snapshot = snapshot;
}
const snapshot = input?.snapshot;
input = omit(input, 'owner', 'snapshot');
const isPublic = input?.public ?? false;
const enableSharing = input?.enableSharing ?? true;
const enableUrlPreview = input?.enableUrlPreview ?? false;
input = omit(
input,
'owner',
'snapshot',
'public',
'enableSharing',
'enableUrlPreview'
);
const workspace = await this.db.workspace.create({
data: {
name: faker.animal.cat(),
public: false,
...input,
permissions: owner
accessPolicy: {
create: {
visibility: isPublic ? 'public' : 'private',
sharingEnabled: enableSharing,
urlPreviewEnabled: enableUrlPreview,
},
},
members: owner
? {
create: {
userId: owner.id,
type: WorkspaceRole.Owner,
status: 'Accepted',
role: 'owner',
state: 'active',
source: 'legacy',
},
}
: undefined,
},
});
const runtimeStateColumns = await this.db.$queryRaw<
Array<{ exists: boolean }>
>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`;
if (runtimeStateColumns[0]?.exists) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
known = true,
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
last_reconciled_at = now(),
stale_after = NULL,
updated_at = now()
`;
} else {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
stale_at = NULL,
updated_at = now()
`;
}
await this.db.effectiveWorkspaceQuotaState.create({
data: {
workspaceId: workspace.id,
plan: 'free',
seatLimit: 0,
blobLimit: 0,
storageQuota: 0,
historyPeriodSeconds: 0,
known: true,
},
});
// create a rootDoc snapshot
if (snapshot) {
@@ -109,6 +91,11 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
},
});
}
return workspace;
return {
...workspace,
public: isPublic,
enableSharing,
enableUrlPreview,
};
}
}
@@ -31,7 +31,7 @@ test.after(async () => {
async function create() {
return db.workspace.create({
data: { public: false },
data: { accessPolicy: { create: {} } },
});
}
@@ -142,12 +142,13 @@ test('should paginate doc user roles', async t => {
})),
});
await db.workspaceDocUserRole.createMany({
await db.docGrant.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
workspaceId: workspace.id,
docId,
userId: String(i),
type: DocRole.Editor,
principalType: 'user',
principalId: String(i),
role: 'editor',
createdAt: new Date(Date.now() + i * 1000),
})),
});
@@ -3,8 +3,12 @@ import ava, { TestFn } from 'ava';
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
import { AvailableUserFeatureConfig } from '../../core/features/types';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { Feature } from '../../models/common/feature';
import {
Feature,
FeatureType,
UserFeatureModel,
UserModel,
} from '../../models';
import { createTestingModule, TestingModule } from '../utils';
interface Context {
@@ -18,7 +22,6 @@ const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(UserFeatureModel);
t.context.resolver = module.get(AdminFeatureManagementResolver);
t.context.module = module;
@@ -36,140 +39,39 @@ test.after(async t => {
await t.context.module.close();
});
test('configurable user features exclude commercial projection features', t => {
test('only administrator is a configurable user feature', t => {
const config = new AvailableUserFeatureConfig();
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
});
test('admin feature resolver rejects commercial projection features', async t => {
await t.throwsAsync(
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
{ message: /not configurable/ }
);
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
t.deepEqual([...config.availableUserFeatures()], [Feature.Admin]);
t.deepEqual([...config.configurableUserFeatures()], [Feature.Admin]);
});
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'administrator');
t.is(userFeature, null);
t.is(await t.context.model.get(t.context.u1.id, Feature.Admin), null);
});
test('should get user feature', async t => {
test('should add and get user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
});
test('should list user features', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.like(await model.list(u1.id), ['free_plan_v1']);
});
test('should list user features by type', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'test');
await model.add(u1.id, 'unlimited_copilot', 'test');
t.like(await model.list(u1.id, FeatureType.Quota), ['free_plan_v1']);
t.like(await model.list(u1.id, FeatureType.Feature), ['unlimited_copilot']);
});
test('should directly test user feature existence', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'administrator'));
});
test('should add user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'unlimited_copilot', 'test');
t.true(await model.has(u1.id, 'unlimited_copilot'));
t.true((await model.list(u1.id)).includes('unlimited_copilot'));
await model.add(u1.id, Feature.Admin, 'test');
t.is((await model.get(u1.id, Feature.Admin))?.name, Feature.Admin);
t.true(await model.has(u1.id, Feature.Admin));
t.deepEqual(await model.list(u1.id, FeatureType.Feature), [Feature.Admin]);
});
test('should not add existing user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'test');
await model.add(u1.id, 'free_plan_v1', 'test');
t.like(await model.list(u1.id), ['free_plan_v1']);
await model.add(u1.id, Feature.Admin, 'test');
await model.add(u1.id, Feature.Admin, 'test');
t.deepEqual(await model.list(u1.id), [Feature.Admin]);
});
test('should remove user feature', async t => {
const { model, u1 } = t.context;
await model.remove(u1.id, 'free_plan_v1');
t.false(await model.has(u1.id, 'free_plan_v1'));
t.false((await model.list(u1.id)).includes('free_plan_v1'));
await model.add(u1.id, Feature.Admin, 'test');
await model.remove(u1.id, Feature.Admin);
t.false(await model.has(u1.id, Feature.Admin));
});
test('should switch user quota', async t => {
const { model, u1 } = t.context;
await model.switchQuota(u1.id, 'pro_plan_v1', 'test');
const quota = await model.getQuota(u1.id);
t.snapshot(quota?.configs, 'switch to pro plan');
await model.switchQuota(u1.id, 'free_plan_v1', 'test');
const quota2 = await model.getQuota(u1.id);
t.snapshot(quota2?.configs, 'switch to free plan');
});
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
const quota = await model.db.userFeature.findFirst({
where: {
userId: u1.id,
},
});
t.not(quota?.reason, 'test not switch');
});
test('should use pro plan as free for selfhost instance', async t => {
const previousDeploymentType = env.DEPLOYMENT_TYPE;
// @ts-expect-error DEPLOYMENT_TYPE is readonly
env.DEPLOYMENT_TYPE = 'selfhosted';
try {
await using module = await createTestingModule();
const models = module.get(Models);
const u1 = await models.user.create({
email: 'u1@affine.pro',
registered: true,
});
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
'use pro plan as free plan for selfhosted instance'
);
} finally {
// @ts-expect-error DEPLOYMENT_TYPE is readonly
env.DEPLOYMENT_TYPE = previousDeploymentType;
}
test('admin resolver updates administrator feature', async t => {
await t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.Admin]);
t.true(await t.context.model.has(t.context.u1.id, Feature.Admin));
});
@@ -1,157 +0,0 @@
import { Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
import {
FeatureType,
UserModel,
WorkspaceFeatureModel,
WorkspaceModel,
} from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: WorkspaceFeatureModel;
resolver: AdminWorkspaceResolver;
ws: Workspace;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(WorkspaceFeatureModel);
t.context.resolver = module.get(AdminWorkspaceResolver);
t.context.module = module;
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
const u1 = await t.context.module.get(UserModel).create({
email: 'u1@affine.pro',
registered: true,
});
t.context.ws = await t.context.module.get(WorkspaceModel).create(u1.id);
});
test.after(async t => {
await t.context.module.close();
});
test('should get null if workspace feature not found', async t => {
const { model, ws } = t.context;
const userFeature = await model.get(ws.id, 'unlimited_workspace');
t.is(userFeature, null);
});
test('admin workspace update changes workspace flags', async t => {
await t.context.resolver.adminUpdateWorkspace({
id: t.context.ws.id,
name: 'updated',
});
t.is(
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
'updated'
);
});
test('should directly test workspace feature existence', async t => {
const { model, ws } = t.context;
t.false(await model.has(ws.id, 'unlimited_workspace'));
});
test('should get workspace quota', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
const quota = await model.getQuota(ws.id);
t.snapshot(quota?.configs, 'team plan');
});
test('should return null if quota removed', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
await model.remove(ws.id, 'team_plan_v1');
const quota = await model.getQuota(ws.id);
t.is(quota, null);
});
test('should list empty workspace features', async t => {
const { model, ws } = t.context;
t.deepEqual(await model.list(ws.id), []);
});
test('should list workspace features by type', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
await model.add(ws.id, 'team_plan_v1', 'test');
t.like(await model.list(ws.id, FeatureType.Quota), ['team_plan_v1']);
t.like(await model.list(ws.id, FeatureType.Feature), ['unlimited_workspace']);
});
test('should add workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
t.is(
(await model.get(ws.id, 'unlimited_workspace'))?.name,
'unlimited_workspace'
);
t.true(await model.has(ws.id, 'unlimited_workspace'));
t.true((await model.list(ws.id)).includes('unlimited_workspace'));
});
test('should add workspace feature with overrides', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
const f1 = await model.get(ws.id, 'team_plan_v1');
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 });
const f2 = await model.get(ws.id, 'team_plan_v1');
t.not(f1!.configs.memberLimit, f2!.configs.memberLimit);
t.is(f2!.configs.memberLimit, 100);
});
test('should not add existing workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
await model.add(ws.id, 'team_plan_v1', 'test');
t.like(await model.list(ws.id), ['team_plan_v1']);
});
test('should replace existing workspace if overrides updated', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 10 });
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 });
const f2 = await model.get(ws.id, 'team_plan_v1');
t.is(f2!.configs.memberLimit, 100);
});
test('should remove workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
await model.remove(ws.id, 'team_plan_v1');
t.false(await model.has(ws.id, 'team_plan_v1'));
t.false((await model.list(ws.id)).includes('team_plan_v1'));
});
@@ -27,14 +27,7 @@ test.after(async t => {
test('should get feature', async t => {
const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1');
const adminFeature = await feature.get('administrator');
t.snapshot(freePlanFeature.configs);
});
test('should throw if feature not found', async t => {
const { feature } = t.context;
await t.throwsAsync(feature.get('not_found_feature' as any), {
message: 'Feature not_found_feature not found',
});
t.deepEqual(adminFeature.configs, {});
});
@@ -1,621 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
import {
DocRole,
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
PermissionProjectionModel,
permissionProjectionTriggerErrorCategory,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule } from '../create-module';
import { Mockers } from '../mocks';
const module = await createModule({});
const db = module.get(PrismaClient);
test.after.always(async () => {
await module.close();
});
test('payment provider facts migration makes nullable provider identities explicit', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260604000000_payment_provider_facts/migration.sql'
),
'utf8'
);
t.regex(
migration,
/provider_subscriptions_stripe_identity_check[\s\S]*"provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL/
);
t.regex(
migration,
/provider_subscriptions_revenuecat_identity_check[\s\S]*"provider" <> 'revenuecat' OR \("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"\("provider", "external_subscription_id"\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"\("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id"\)/
);
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
}
protected override get db() {
return this.fakeDb as never;
}
}
let appliedPermissionProjectionTriggerFunctionUpdates = false;
async function applyPermissionProjectionTriggerFunctionUpdates() {
if (appliedPermissionProjectionTriggerFunctionUpdates) {
return;
}
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
for (const name of [
'affine_permission_project_new_workspace_member',
'affine_permission_project_new_workspace_invitation',
'affine_permission_project_new_doc_access_policy',
'affine_permission_project_new_doc_grant',
]) {
const sql = migration.match(
new RegExp(
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
)
)?.[0];
if (!sql) {
throw new Error(`Missing migration function ${name}`);
}
await db.$executeRawUnsafe(sql);
}
appliedPermissionProjectionTriggerFunctionUpdates = true;
}
async function hasCurrentWorkspaceInvitationColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name IN ('requested_role', 'status', 'kind')
`;
return rows.length === 3;
}
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
const queryResults = [
[{ count: 1n }],
[{ count: 2n }],
[{ count: 3n }],
[{ count: 4n }],
[{ count: 5n }],
[{ count: 6n }],
[{ count: 7n }],
[{ count: 8n }],
[{ count: 9n }],
[{ count: 10n }],
[
{ category: 'legacy_doc_external_row', count: 11n },
{ category: 'doc_default_owner', count: 12n },
],
];
const model = new TestPermissionProjectionModel({
$queryRaw: async () => queryResults.shift(),
});
t.deepEqual(await model.checkLegacyProjection(), {
oldWorkspacePolicyMismatch: 1,
oldAcceptedMemberMismatch: 2,
extraProjectedMember: 3,
oldInvitationMismatch: 4,
extraProjectedInvitation: 5,
oldDocGrantMismatch: 6,
extraProjectedDocGrant: 7,
oldDocPolicyMismatch: 8,
extraProjectedDocPolicy: 9,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict: 10,
oldNewDecisionMismatch: 0,
invalidLegacyRows: {
legacy_doc_external_row: 11,
doc_default_owner: 12,
},
});
});
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
const executed: unknown[] = [];
let transactionOptions: unknown;
const model = new TestPermissionProjectionModel({
$transaction: async (
callback: (tx: unknown) => Promise<void>,
options: unknown
) => {
transactionOptions = options;
await callback({
$executeRaw: async (query: unknown) => {
executed.push(query);
},
});
},
});
await model.backfillLegacyProjection();
t.is(executed.length, 11);
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
});
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
]);
});
test('permission projection migration uses non-recursive origin guard', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
const guardBody = migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
)?.[0];
t.truthy(guardBody);
t.true(
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
);
t.false(
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
);
t.truthy(
migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
)
);
});
test('permission projection trigger maps legacy workspace permission rows', async t => {
const workspace = await module.create(Mockers.Workspace);
const [admin, pending] = await module.create(Mockers.User, 2);
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: workspace.id,
userId: admin.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
],
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: admin.id,
state: 'active',
},
});
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
where: {
workspaceId_inviteeUserId: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
},
},
});
t.is(member.role, 'admin');
t.is(invitation.requestedRole, 'member');
t.is(invitation.status, 'pending');
});
test('permission projection trigger maps legacy doc policy rows', async t => {
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'public-doc',
public: true,
defaultRole: DocRole.Reader,
},
});
const policy = await db.docAccessPolicy.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'public-doc',
},
},
});
t.is(policy.visibility, 'public');
t.is(policy.publicRole, 'external');
t.is(policy.memberDefaultRole, 'reader');
});
async function hasDocGrantLegacyProjectionColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'doc_grants'
AND column_name IN (
'legacy_workspace_id',
'legacy_doc_id',
'legacy_user_id'
)
`;
return rows.length === 3;
}
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
if (!(await hasDocGrantLegacyProjectionColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates doc_grants legacy columns'
);
return;
}
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceDocUserRole.createMany({
data: [
{
workspaceId: workspace.id,
docId: 'valid-grant',
userId: user.id,
type: DocRole.Reader,
},
{
workspaceId: workspace.id,
docId: 'dirty-external',
userId: user.id,
type: DocRole.External,
},
{
workspaceId: workspace.id,
docId: 'dirty-none',
userId: user.id,
type: DocRole.None,
},
],
});
const grants = await db.docGrant.findMany({
where: {
workspaceId: workspace.id,
principalId: user.id,
},
orderBy: {
docId: 'asc',
},
});
t.deepEqual(
grants.map(grant => [grant.docId, grant.role]),
[['valid-grant', 'reader']]
);
});
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const member = await db.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId: user.id,
role: 'member',
state: 'active',
},
});
t.truthy(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
);
await db.workspaceMember.update({
where: { id: member.id },
data: { state: 'suspended' },
});
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
if (!(await hasCurrentWorkspaceInvitationColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates workspace invitation projection columns'
);
return;
}
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const [invitation] = await db.$queryRaw<{ id: string }[]>`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
requested_role,
status,
kind
)
VALUES (
${workspace.id},
${user.id},
'member',
'pending',
'email'
)
RETURNING id
`;
t.is(
(
await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
).status,
'Pending'
);
await db.$executeRaw`
UPDATE workspace_invitations
SET status = 'declined'
WHERE id = ${invitation.id}
`;
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'metadata-doc',
public: true,
defaultRole: DocRole.Reader,
mode: 1,
blocked: true,
title: 'Title',
summary: 'Summary',
publishedAt: new Date('2026-01-01T00:00:00Z'),
},
});
await db.docAccessPolicy.delete({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
const doc = await db.workspaceDoc.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
t.is(doc.public, false);
t.is(doc.defaultRole, DocRole.Manager);
t.is(doc.publishedAt, null);
t.is(doc.mode, 1);
t.is(doc.blocked, true);
t.is(doc.title, 'Title');
t.is(doc.summary, 'Summary');
});
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'user',
principalId: user.id,
role: 'reader',
},
});
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
role: 'manager',
},
});
await db.docGrant.delete({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
},
},
});
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
where: {
workspaceId_docId_userId: {
workspaceId: workspace.id,
docId: 'group-doc',
userId: user.id,
},
},
});
t.is(legacyGrant.type, DocRole.Reader);
});
test('PermissionProjectionModel parses trigger error metric category', t => {
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:owner_conflict:duplicate owner')
),
'owner_conflict'
);
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:unexpected:nope')
),
'unknown'
);
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
});
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
const checker = new PermissionProjectionChecker(
{
workspace: {
findMany: async () => [],
},
$queryRaw: async () => [
{
category: 'active_member_doc',
workspaceId: 'w1',
docId: 'doc1',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'explicit_doc_grant',
workspaceId: 'w1',
docId: 'doc2',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'workspace_invitation',
workspaceId: 'w1',
docId: null,
userId: 'u2',
workspaceActions: ['Workspace.Read'],
docActions: null,
},
],
} as never,
{
permissionProjection: {
checkLegacyProjection: async () => ({}),
},
} as never,
{
load: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: 'legacy' },
docs: input.docs
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
: [],
}),
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: input.docs ? 'legacy' : 'projection' },
docs: input.docs
? [
{
docId: input.docs[0].docId,
marker:
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
},
]
: [],
}),
} as never,
{
evaluate: (input: unknown) => input,
} as never
);
t.deepEqual(await checker.checkLegacyProjection(), {
oldNewDecisionMismatch: 2,
});
});
@@ -5,7 +5,12 @@ import test from 'ava';
import Sinon from 'sinon';
import { EventBus, NewOwnerIsNotActiveMember } from '../../base';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import {
DocRole,
Models,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule, TestingModule } from '../create-module';
import { Mockers } from '../mocks';
@@ -48,10 +53,12 @@ test('should transfer workespace owner', async t => {
owner: { id: user.id },
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -78,10 +85,12 @@ test('should keep old owner as admin when transferring a team workspace', async
id: workspace.id,
quantity: 10,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -99,11 +108,12 @@ test('should throw if transfer owner to non-active member', async t => {
instanceOf: NewOwnerIsNotActiveMember,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.AllocatingSeat }
);
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
@@ -231,22 +241,7 @@ test('should delete workspace user role', async t => {
t.is(role, null);
});
test('should delete legacy-only external workspace user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
await models.workspaceUser.delete(workspace.id, u1.id);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
});
test('should convert existing workspace user role to legacy-only external role', async t => {
test('should remove workspace permission when changing a member to external', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
@@ -258,85 +253,35 @@ test('should convert existing workspace user role to legacy-only external role',
status: WorkspaceMemberStatus.Accepted,
}
);
const docId = 'external-member-doc';
await models.docUser.set(workspace.id, docId, u1.id, DocRole.Editor);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role?.type, WorkspaceRole.External);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
t.is(
await db.workspaceMember.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
}),
0
);
});
test('should backfill legacy permission id for new workspace member writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
t.is(
await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
inviteeUserId: u1.id,
},
},
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
});
t.is(member.legacyPermissionId, legacyRole.id);
});
test('should backfill legacy permission id for new workspace invitation writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
}
}),
0
);
t.is(
(await models.docUser.get(workspace.id, docId, u1.id))?.type,
DocRole.Editor
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const invitation = await db.workspaceInvitation.findFirstOrThrow({
where: {
workspaceId: workspace.id,
inviteeUserId: u1.id,
},
});
t.is(invitation.legacyPermissionId, legacyRole.id);
});
test('should get user workspace roles with filter', async t => {
@@ -344,19 +289,19 @@ test('should get user workspace roles with filter', async t => {
const ws2 = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceUserRole.createMany({
await db.workspaceMember.createMany({
data: [
{
workspaceId: ws1.id,
userId: user.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
role: 'admin',
state: 'active',
},
{
workspaceId: ws2.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Accepted,
role: 'member',
state: 'active',
},
],
});
@@ -381,14 +326,12 @@ test('should paginate workspace user roles', async t => {
})),
});
await db.workspaceUserRole.createMany({
await db.workspaceMember.createMany({
data: users.map((user, i) => ({
workspaceId: workspace.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
status: Object.values(WorkspaceMemberStatus)[
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
],
role: 'member',
state: 'active',
createdAt: new Date(Date.now() + i * 1000),
})),
});
@@ -421,19 +364,20 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async
const workspace = await module.create(Mockers.Workspace);
for (const user of users) {
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.AllocatingSeat }
);
}
await models.workspaceUser.allocateSeats(workspace.id, 1);
let count = await db.workspaceUserRole.count({
let count = await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
status: 'pending',
},
});
@@ -441,10 +385,10 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async
await models.workspaceUser.allocateSeats(workspace.id, 3);
count = await db.workspaceUserRole.count({
count = await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
status: 'pending',
},
});
@@ -40,11 +40,15 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
canceledCount: 0,
dbObservability: {
externalProductId: 'app.affine.pro.Annual',
externalRef: 'rc:app.affine.pro.Annual',
iapStore: 'app_store',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-tx-1',
rcProductId: 'app.affine.pro.Annual',
},
lastActivated: {
plan: 'pro',
@@ -53,14 +57,14 @@ Generated by [AVA](https://avajs.dev).
subscriberCount: 1,
}
## should process expiration/refund by deleting subscription and emitting canceled
## should process expiration/refund by canceling subscription and emitting canceled
> should process expiration/refund and emit canceled
{
activatedEventCount: 0,
canceledEventCount: 2,
finalDBCount: 0,
canceledEventCount: 1,
finalDBCount: 1,
lastCanceled: {
plan: 'pro',
recurring: 'yearly',
@@ -116,12 +120,16 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
name: 'Pro monthly on iOS',
rec: {
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
iapStore: 'app_store',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
plan: 'pro',
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-ios-1',
rcProductId: 'app.affine.pro.Monthly',
recurring: 'monthly',
status: 'active',
},
@@ -130,12 +138,16 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
name: 'AI annual on Android',
rec: {
externalProductId: 'app.affine.pro.ai.Annual',
externalRef: 'rc:app.affine.pro.ai.Annual',
iapStore: 'play_store',
metadata: {
entitlement: 'AI',
isTrial: false,
willRenew: true,
},
plan: 'ai',
provider: 'revenuecat',
rcEntitlement: 'AI',
rcExternalRef: 'token-android-1',
rcProductId: 'app.affine.pro.ai.Annual',
recurring: 'yearly',
status: 'active',
},
@@ -148,7 +160,7 @@ Generated by [AVA](https://avajs.dev).
> should keep active after trial renewal
{
activatedCount: 2,
activatedCount: 1,
canceledCount: 0,
status: 'active',
}
@@ -159,7 +171,7 @@ Generated by [AVA](https://avajs.dev).
{
canceledCount: 1,
finalDBCount: 0,
finalDBCount: 1,
}
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
@@ -188,7 +200,7 @@ Generated by [AVA](https://avajs.dev).
{
activatedCount: 0,
hasRCRecord: false,
hasRCRecord: true,
}
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
@@ -196,18 +208,30 @@ Generated by [AVA](https://avajs.dev).
> should reconcile and fix missing or out-of-order states for revenuecat records
{
activatedCount: 1,
activatedCount: 0,
canceledCount: 0,
subscriberCount: 1,
hasActiveEntitlement: true,
records: [
{
externalRef: 'rc:app.affine.pro.Annual',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
},
],
reusedPlaceholder: true,
subscriberCount: 2,
}
## should treat refund as early expiration and revoke immediately
> should delete record and emit canceled on refund
> should cancel record and emit canceled on refund
{
canceledEventCount: 2,
finalDBCount: 0,
canceledEventCount: 1,
finalDBCount: 1,
}
## should ignore non-whitelisted productId and not write to DB
@@ -236,11 +260,11 @@ Generated by [AVA](https://avajs.dev).
c: 0,
},
afterSecond: {
a: 3,
a: 2,
c: 0,
},
afterThird: {
a: 4,
a: 2,
c: 1,
},
},
@@ -1,5 +1,7 @@
import { TransactionHost } from '@nestjs-cls/transactional';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { CryptoHelper, EventBus, JobQueue } from '../../base';
import { EntitlementService } from '../../core/entitlement';
@@ -127,16 +129,23 @@ test('onetime selfhost license seat allocation ignores projected license quantit
});
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const transactionHost = Sinon.stub(TransactionHost, 'getInstance').returns({
withTransaction: (...args: unknown[]) =>
(args.at(-1) as () => Promise<unknown>)(),
} as TransactionHost);
t.teardown(() => transactionHost.restore());
const events: Array<{ name: string; payload: unknown }> = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const operations: string[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => null,
upsert: async (input: unknown) => {
create: async (input: unknown) => {
upserts.push(input);
operations.push('source');
return {
workspaceId: 'ws',
key: 'license-key',
@@ -156,6 +165,7 @@ test('recurring selfhost license activation returns activation projection withou
{
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
entitlements.push(input);
operations.push('entitlement');
},
} as unknown as EntitlementService,
{} as unknown as QuotaStateService
@@ -186,6 +196,7 @@ test('recurring selfhost license activation returns activation projection withou
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.is(activatedLicenseKey, 'license-key');
t.deepEqual(operations, ['source', 'entitlement']);
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
@@ -9,6 +9,7 @@ import {
SubscriptionAlreadyExists,
} from '../../base';
import { ConfigModule } from '../../base/config';
import { EntitlementService } from '../../core/entitlement';
import { FeatureService } from '../../core/features';
import { Models } from '../../models';
import { PaymentModule } from '../../plugins/payment';
@@ -107,12 +108,20 @@ test.beforeEach(async t => {
Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]);
t.context.mockSub = subs =>
Sinon.stub(rc, 'getSubscriptions').resolves(
subs.map(s => ({ ...s, customerId: customerId }))
subs.map(s => ({
...s,
customerId,
externalRef: s.externalRef ?? `rc:${s.productId}`,
}))
);
t.context.mockSubSeq = sequences => {
const stub = Sinon.stub(rc, 'getSubscriptions');
sequences.forEach((seq, idx) => {
const subs = seq.map(s => ({ ...s, customerId: customerId }));
const subs = seq.map(s => ({
...s,
customerId,
externalRef: s.externalRef ?? `rc:${s.productId}`,
}));
if (idx === 0) stub.onFirstCall().resolves(subs);
else if (idx === 1) stub.onSecondCall().resolves(subs);
else stub.onCall(idx).resolves(subs);
@@ -189,7 +198,7 @@ test('should resolve product mapping consistently (whitelist, override, unknown)
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
mockAlias(user.id);
const alias = mockAlias(user.id);
const subscriber = mockSub([
{
identifier: 'Pro',
@@ -217,14 +226,14 @@ test('should standardize RC subscriber response and upsert subscription with obs
});
const { activatedCount, canceledCount, events } = collectEvents();
const record = await t.context.db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const record = await t.context.db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: {
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
metadata: true,
externalProductId: true,
externalRef: true,
},
});
@@ -241,20 +250,65 @@ test('should standardize RC subscriber response and upsert subscription with obs
},
'should standardize payload and have events'
);
const transferred = await t.context.models.user.create({
email: 'revenuecat-transfer@affine.pro',
});
alias.resolves([transferred.id]);
await webhook.onWebhook({
appUserId: transferred.id,
event: {
id: 'evt_1_transfer',
environment: 'PRODUCTION',
app_id: 'app.affine.pro',
type: 'TRANSFER',
store: 'app_store',
original_transaction_id: 'orig-tx-1',
},
});
t.is(
(
await t.context.db.providerSubscription.findFirstOrThrow({
where: {
provider: 'revenuecat',
externalRef: 'rc:app.affine.pro.Annual',
},
})
).targetId,
transferred.id
);
t.true(
t.context.event.emit.calledWith('entitlement.changed', {
targetType: 'user',
targetId: user.id,
})
);
t.true(
t.context.event.emit.calledWith('user.subscription.canceled', {
userId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
})
);
});
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
test('should process expiration/refund by canceling subscription and emitting canceled', async t => {
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Annual',
externalRef: 'rc:app.affine.pro.Annual',
},
});
@@ -279,7 +333,7 @@ test('should process expiration/refund by deleting subscription and emitting can
original_transaction_id: 'orig-tx-2',
});
const finalDBCount = await db.subscription.count({
const finalDBCount = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
@@ -304,33 +358,47 @@ test('should enqueue per-user reconciliation jobs for existing RC active/trialin
const cron = module.get(SubscriptionCronJobs);
const common = { provider: 'revenuecat', start: new Date() } as const;
await db.subscription.createMany({
const common = { provider: 'revenuecat', periodStart: new Date() } as const;
await db.providerSubscription.createMany({
data: [
{
targetType: 'user',
targetId: 'u1',
plan: 'pro',
status: 'active',
recurring: 'monthly',
iapStore: 'app_store',
externalCustomerId: 'c1',
externalProductId: 'pro-monthly',
externalRef: 'r1',
...common,
},
{
targetType: 'user',
targetId: 'u2',
plan: 'ai',
status: 'trialing',
recurring: 'yearly',
iapStore: 'app_store',
externalCustomerId: 'c2',
externalProductId: 'ai-yearly',
externalRef: 'r2',
...common,
},
{
targetType: 'user',
targetId: 'u1',
plan: 'ai',
status: 'past_due',
recurring: 'monthly',
iapStore: 'play_store',
externalCustomerId: 'c1',
externalProductId: 'ai-monthly',
externalRef: 'r3',
...common,
},
],
});
await cron.reconcileRevenueCatSubscriptions();
const calls = module.queue.add.getCalls().map(c => ({
@@ -411,17 +479,21 @@ test('should activate subscriptions via webhook for whitelisted products across
// reset event history between scenarios for clean counts
event.emit.resetHistory?.();
await triggerWebhook(user.id, s.event);
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
const rec = await db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: user.id,
plan: s.expectedPlan,
},
select: {
plan: true,
recurring: true,
status: true,
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
metadata: true,
externalProductId: true,
externalRef: true,
},
});
const { activatedCount } = collectEvents();
@@ -479,9 +551,9 @@ test('should keep active and advance period dates when a trialing subscription r
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { status: true, start: true, end: true },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true, periodStart: true, periodEnd: true },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
@@ -535,7 +607,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing
store: 'app_store',
});
const finalDBCount = await db.subscription.count({
const finalDBCount = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
@@ -564,8 +636,8 @@ test('should set canceledAt and keep active until expiration when will_renew is
type: 'CANCELLATION',
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true, canceledAt: true },
});
const { activatedCount, canceledCount } = collectEvents();
@@ -602,8 +674,8 @@ test('should retain record as past_due (inactive but not expired) and NOT emit c
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true },
});
const { canceledCount } = collectEvents();
@@ -617,18 +689,25 @@ test('should block checkout when an existing subscription of the same plan is ac
const { module, db } = t.context;
const manager = module.get(UserSubscriptionManager);
let subscriptionId: string;
{
await db.subscription.create({
const subscription = await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
},
});
subscriptionId = subscription.id;
await t.throwsAsync(
manager.checkout(
@@ -649,9 +728,16 @@ test('should block checkout when an existing subscription of the same plan is ac
}
{
await db.subscription.update({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
data: { provider: 'stripe' },
await db.providerSubscription.update({
where: { id: subscriptionId },
data: {
provider: 'stripe',
externalSubscriptionId: 'sub_existing',
iapStore: null,
externalCustomerId: null,
externalProductId: null,
externalRef: null,
},
});
await t.throwsAsync(
@@ -677,17 +763,18 @@ test('should block checkout when an existing subscription of the same plan is ac
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'stripe',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_conflict',
},
});
mockSub([
{
identifier: 'Pro',
@@ -708,7 +795,7 @@ test('should skip RC upsert when Stripe active already exists for same plan', as
store: 'app_store',
});
const rcRec = await db.subscription.findFirst({
const rcRec = await db.providerSubscription.findFirst({
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
});
const { activatedCount } = collectEvents();
@@ -720,19 +807,23 @@ test('should skip RC upsert when Stripe active already exists for same plan', as
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
const { db, service } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date(),
periodStart: new Date(),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:managed',
},
});
// local helper used multiple times within this test
const expectManaged = async (fn: () => Promise<any>) =>
const expectManaged = async (fn: () => Promise<unknown>) =>
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
await expectManaged(() =>
@@ -752,29 +843,83 @@ test('should block read-write ops on revenuecat-managed record (cancel/resume/up
});
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
const { webhook, db, collectEvents, mockAlias, mockSubSeq } = t.context;
mockAlias(user.id);
const subscriber = mockSub([
{
identifier: 'Pro',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'play_store',
willRenew: true,
duration: null,
const placeholder = await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2999-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: user.id,
externalProductId: 'legacy_product:1',
externalRef: 'legacy_subscription:1',
metadata: { legacyRevenueCatIdentityIncomplete: true },
},
]);
});
const subscription = {
identifier: 'Pro',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'play_store',
willRenew: true,
duration: null,
} as const;
const subscriber = mockSubSeq([[subscription], [subscription]]);
await webhook.syncAppUser(user.id);
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
periodEnd: new Date('2999-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: user.id,
externalProductId: 'legacy_product:2',
externalRef: 'legacy_subscription:2',
metadata: { legacyRevenueCatIdentityIncomplete: true },
},
});
await webhook.syncAppUser(user.id);
const { activatedCount, canceledCount } = collectEvents();
const subscriberCount = subscriber.getCalls()?.length || 0;
const records = await db.providerSubscription.findMany({
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
select: { id: true, externalRef: true, metadata: true },
});
const normalizedRecords = records.map(({ id: _, ...record }) => record);
const activeEntitlement = await db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
status: 'active',
subjectId: placeholder.id,
},
});
t.snapshot(
{ subscriberCount, activatedCount, canceledCount },
{
subscriberCount,
activatedCount,
canceledCount,
records: normalizedRecords,
reusedPlaceholder: records[0]?.id === placeholder.id,
hasActiveEntitlement: !!activeEntitlement,
},
'should reconcile and fix missing or out-of-order states for revenuecat records'
);
});
@@ -783,14 +928,19 @@ test('should treat refund as early expiration and revoke immediately', async t =
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
},
});
@@ -814,13 +964,13 @@ test('should treat refund as early expiration and revoke immediately', async t =
store: 'app_store',
});
const count = await db.subscription.count({
const count = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
t.snapshot(
{ finalDBCount: count, canceledEventCount: canceledCount },
'should delete record and emit canceled on refund'
'should cancel record and emit canceled on refund'
);
});
@@ -846,7 +996,9 @@ test('should ignore non-whitelisted productId and not write to DB', async t => {
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const dbCount = await db.subscription.count({ where: { targetId: user.id } });
const dbCount = await db.providerSubscription.count({
where: { targetId: user.id },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
{ dbCount, activatedCount, canceledCount },
@@ -901,8 +1053,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const r1 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const r1 = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { plan: true, recurring: true, provider: true },
});
const s1 = collectEvents();
@@ -913,8 +1065,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'play_store',
});
const r2 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
const r2 = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'ai' },
select: { plan: true, recurring: true, provider: true },
});
const s2 = collectEvents();
@@ -925,7 +1077,9 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const count = await db.subscription.count({ where: { targetId: user.id } });
const count = await db.providerSubscription.count({
where: { targetId: user.id },
});
const s3 = collectEvents();
t.snapshot(
@@ -1025,18 +1179,19 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy
// case3: only stripe subscription -> should NOT sync (call count remains 2)
{
await db.subscription.deleteMany({
await db.providerSubscription.deleteMany({
where: { targetId: user.id, provider: 'revenuecat' },
});
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
provider: 'stripe',
status: 'active',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_123',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_123',
},
});
const subs = await subResolver.refreshUserSubscriptions(currentUser);
@@ -1048,30 +1203,49 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy
test('user subscriptions ignore active rows after their current period ended', async t => {
const { db, subResolver } = t.context;
await db.subscription.createMany({
await db.providerSubscription.createMany({
data: [
{
targetType: 'user',
targetId: user.id,
plan: 'ai',
provider: 'stripe',
status: 'active',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
end: new Date('2025-01-08T00:00:00.000Z'),
stripeSubscriptionId: 'sub_expired_ai',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2025-01-08T00:00:00.000Z'),
externalSubscriptionId: 'sub_expired_ai',
},
{
targetType: 'user',
targetId: user.id,
plan: 'pro',
provider: 'stripe',
status: 'active',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
end: new Date('2099-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_current_pro',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_current_pro',
},
],
});
const entitlement = t.context.module.get(EntitlementService);
await entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_expired_ai',
end: new Date('2025-01-08T00:00:00.000Z'),
});
await entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_current_pro',
end: new Date('2099-01-01T00:00:00.000Z'),
});
const subscriptions = await subResolver.subscriptions(user, user);
t.deepEqual(subscriptions.map(subscription => subscription.plan).sort(), [
@@ -1092,18 +1266,6 @@ test('user subscriptions preserve provider trialing status', async t => {
email: `${Date.now()}-trial-status@affine.pro`,
});
await db.subscription.create({
data: {
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
provider: 'stripe',
status: SubscriptionStatus.Trialing,
recurring: SubscriptionRecurring.Yearly,
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2099-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_trialing_status',
},
});
await db.providerSubscription.create({
data: {
provider: 'stripe',
@@ -1117,6 +1279,14 @@ test('user subscriptions preserve provider trialing status', async t => {
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
},
});
await t.context.module.get(EntitlementService).upsertFromCloudSubscription({
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Trialing,
subscriptionId: 'sub_trialing_status',
end: new Date('2099-01-01T00:00:00.000Z'),
});
const subscriptions = await subResolver.subscriptions(trialUser, trialUser);
@@ -10,7 +10,9 @@ import { EventBus } from '../../base';
import { ConfigFactory, ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EntitlementService } from '../../core/entitlement';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { SelfhostTeamSubscriptionManager } from '../../plugins/payment/manager';
import { RevenueCatService } from '../../plugins/payment/revenuecat';
import { SubscriptionService } from '../../plugins/payment/service';
import { StripeFactory } from '../../plugins/payment/stripe';
@@ -226,7 +228,7 @@ test.beforeEach(async t => {
await db.workspace.create({
data: {
id: 'ws_1',
public: false,
accessPolicy: { create: {} },
},
});
await db.userStripeCustomer.create({
@@ -365,15 +367,17 @@ test('should list normal prices for user with old ai subscriptions', async t =>
test('should throw if user has subscription already', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
@@ -394,15 +398,17 @@ test('should throw if user has subscription already', async t => {
test('should allow checkout after local subscription period ended', async t => {
const { service, u1, db, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_expired_ai',
externalSubscriptionId: 'sub_expired_ai',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-05-04T13:11:45.000Z'),
end: new Date('2026-05-11T13:11:45.000Z'),
periodStart: new Date('2026-05-04T13:11:45.000Z'),
periodEnd: new Date('2026-05-11T13:11:45.000Z'),
},
});
@@ -710,10 +716,6 @@ test('should be able to create subscription', async t => {
await service.saveStripeSubscription(sub);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.calledOnceWith('user.subscription.activated', {
userId: u1.id,
@@ -721,8 +723,6 @@ test('should be able to create subscription', async t => {
recurring: SubscriptionRecurring.Monthly,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
const providerFact = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
@@ -760,26 +760,33 @@ test('should be able to update subscription', async t => {
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: sub.id,
},
},
});
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.canceledAt?.getTime(), canceledAt * 1000);
});
test('should replace old subscription row when stripe creates a new subscription for the same plan', async t => {
test('should preserve old provider fact when stripe creates a new subscription for the same plan', async t => {
const { service, db, u1 } = t.context;
const old = await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_old',
externalSubscriptionId: 'sub_old',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Canceled,
start: new Date('2026-03-26T08:23:57.000Z'),
end: new Date('2027-03-26T08:23:57.000Z'),
periodStart: new Date('2026-03-26T08:23:57.000Z'),
periodEnd: new Date('2027-03-26T08:23:57.000Z'),
},
});
@@ -801,14 +808,21 @@ test('should replace old subscription row when stripe creates a new subscription
},
});
const subscriptions = await db.subscription.findMany({
where: { targetId: u1.id, plan: SubscriptionPlan.Pro },
const subscriptions = await db.providerSubscription.findMany({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
plan: SubscriptionPlan.Pro,
},
orderBy: { externalSubscriptionId: 'asc' },
});
t.is(subscriptions.length, 1);
t.is(subscriptions[0].id, old.id);
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new');
t.is(subscriptions.length, 2);
t.is(subscriptions[0].externalSubscriptionId, 'sub_new');
t.is(subscriptions[0].status, SubscriptionStatus.Active);
t.is(subscriptions[1].externalSubscriptionId, 'sub_old');
t.is(subscriptions[1].status, SubscriptionStatus.Canceled);
});
test('should be able to delete subscription', async t => {
@@ -828,11 +842,6 @@ test('should be able to delete subscription', async t => {
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.is(subInDB, null);
t.like(
await db.providerSubscription.findUnique({
where: {
@@ -851,15 +860,18 @@ test('should be able to delete subscription', async t => {
test('should be able to cancel subscription', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
metadata: { preserved: true },
},
});
@@ -881,6 +893,15 @@ test('should be able to cancel subscription', async t => {
);
t.is(subInDB.status, SubscriptionStatus.Active);
t.truthy(subInDB.canceledAt);
const saved = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.like(saved.metadata, { preserved: true, nextBillAt: null });
});
test('should reconcile canceled stripe subscriptions and revoke local entitlement', async t => {
@@ -897,11 +918,16 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen
await cron.reconcileStripeSubscriptions();
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id, stripeSubscriptionId: sub.id },
const subInDB = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: sub.id,
},
},
});
t.is(subInDB, null);
t.is(subInDB?.status, SubscriptionStatus.Canceled);
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: u1.id,
@@ -914,15 +940,17 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen
test('should be able to resume subscription', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
canceledAt: new Date(),
},
});
@@ -976,15 +1004,17 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
test('should be able to update recurring', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
@@ -1034,16 +1064,18 @@ test('should be able to update recurring', async t => {
test('should release the schedule if the new recurring is the same as the current phase', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
stripeScheduleId: 'sub_sched_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
metadata: { stripeScheduleId: 'sub_sched_1' },
},
});
@@ -1253,14 +1285,16 @@ test('should be able to checkout for lifetime recurring', async t => {
test('should not be able to checkout for lifetime recurring if already subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: null,
externalSubscriptionId: 'stripe_invoice:existing_lifetime',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
periodStart: new Date(),
},
});
@@ -1287,8 +1321,13 @@ test('should be able to subscribe to lifetime recurring', async t => {
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findFirst({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
});
t.true(
@@ -1301,7 +1340,7 @@ test('should be able to subscribe to lifetime recurring', async t => {
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`);
const paymentFact = await db.paymentEvent.findUnique({
where: {
@@ -1319,28 +1358,88 @@ test('should be able to subscribe to lifetime recurring', async t => {
currency: lifetimeInvoice.currency,
processingStatus: 'processed',
});
t.context.stripe.invoices.retrieve.resolves(
lifetimeInvoice as Stripe.Response<Stripe.Invoice>
);
await service.handleRefundedInvoice(lifetimeInvoice.id, 'refund');
const entitlement = await db.entitlement.findFirstOrThrow({
where: {
source: 'cloud_subscription',
subjectId: `stripe_invoice:${lifetimeInvoice.id}`,
},
});
t.is(entitlement.status, 'revoked');
});
test('should be able to subscribe to lifetime recurring with old subscription', async t => {
const { service, stripe, db, u1, event } = t.context;
const { app, service, stripe, db, u1, event } = t.context;
await db.subscription.create({
const previous = await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
subscriptionId: previous.id,
stripeSubscriptionId: previous.externalSubscriptionId,
end: previous.periodEnd,
});
event.emit.resetHistory();
stripe.subscriptions.cancel.rejects(new Error('remote cancellation failed'));
await t.throwsAsync(() => service.saveStripeInvoice(lifetimeInvoice), {
message: 'remote cancellation failed',
});
const current = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.is(current.status, SubscriptionStatus.Active);
t.is(
(
await db.entitlement.findFirstOrThrow({
where: { source: 'cloud_subscription', subjectId: 'sub_1' },
})
).status,
'active'
);
t.is(
await db.providerSubscription.count({
where: {
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
}),
0
);
stripe.subscriptions.cancel.resolves(sub as any);
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findFirst({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
});
t.true(
@@ -1353,20 +1452,36 @@ test('should be able to subscribe to lifetime recurring with old subscription',
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`);
t.is(
(
await db.entitlement.findFirstOrThrow({
where: { source: 'cloud_subscription', subjectId: 'sub_1' },
})
).status,
'revoked'
);
t.is(stripe.subscriptions.cancel.callCount, 2);
t.deepEqual(
stripe.subscriptions.cancel.firstCall.lastArg,
stripe.subscriptions.cancel.secondCall.lastArg
);
});
test('should not be able to cancel lifetime subscription', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
externalSubscriptionId: 'stripe_invoice:lifetime_cancel',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: null,
periodStart: new Date(),
periodEnd: null,
},
});
@@ -1383,14 +1498,17 @@ test('should not be able to cancel lifetime subscription', async t => {
test('should not be able to update lifetime recurring', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
externalSubscriptionId: 'stripe_invoice:lifetime_update',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: null,
periodStart: new Date(),
periodEnd: null,
},
});
@@ -1444,15 +1562,17 @@ test('should be able to checkout for team', async t => {
test('should not be able to checkout for workspace if subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
quantity: 1,
},
});
@@ -1478,15 +1598,17 @@ test('should not be able to checkout for workspace if subscribed', async t => {
test('should be able to checkout for workspace after canceled subscription', async t => {
const { service, u1, db, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Canceled,
start: new Date(Date.now() - 100000),
end: new Date(Date.now() - 1000),
periodStart: new Date(Date.now() - 100000),
periodEnd: new Date(Date.now() - 1000),
quantity: 1,
},
});
@@ -1537,8 +1659,8 @@ test('should be able to create team subscription', async t => {
await service.saveStripeSubscription(teamSub);
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
const subInDB = await db.providerSubscription.findFirst({
where: { targetType: 'workspace', targetId: 'ws_1' },
});
t.true(
@@ -1549,21 +1671,23 @@ test('should be able to create team subscription', async t => {
quantity: 1,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
t.is(subInDB?.externalSubscriptionId, sub.id);
});
test('should replace old team subscription row when stripe creates a new subscription', async t => {
test('should preserve old team provider fact when stripe creates a new subscription', async t => {
const { service, db } = t.context;
const old = await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_old_team',
externalSubscriptionId: 'sub_old_team',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Canceled,
start: new Date('2026-03-26T08:23:57.000Z'),
end: new Date('2027-03-26T08:23:57.000Z'),
periodStart: new Date('2026-03-26T08:23:57.000Z'),
periodEnd: new Date('2027-03-26T08:23:57.000Z'),
quantity: 24,
},
});
@@ -1583,15 +1707,22 @@ test('should replace old team subscription row when stripe creates a new subscri
},
});
const subscriptions = await db.subscription.findMany({
where: { targetId: 'ws_1', plan: SubscriptionPlan.Team },
const subscriptions = await db.providerSubscription.findMany({
where: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
plan: SubscriptionPlan.Team,
},
orderBy: { externalSubscriptionId: 'asc' },
});
t.is(subscriptions.length, 1);
t.is(subscriptions[0].id, old.id);
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team');
t.is(subscriptions.length, 2);
t.is(subscriptions[0].externalSubscriptionId, 'sub_new_team');
t.is(subscriptions[0].status, SubscriptionStatus.Active);
t.is(subscriptions[0].quantity, 11);
t.is(subscriptions[1].externalSubscriptionId, 'sub_old_team');
t.is(subscriptions[1].status, SubscriptionStatus.Canceled);
});
test('should be able to update team subscription', async t => {
@@ -1612,8 +1743,8 @@ test('should be able to update team subscription', async t => {
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
const subInDB = await db.providerSubscription.findFirst({
where: { targetType: 'workspace', targetId: 'ws_1' },
});
t.is(subInDB?.quantity, 2);
@@ -1654,9 +1785,6 @@ test('should persist mutable team subscription fields on same stripe subscriptio
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
});
const entitlement = await db.entitlement.findFirst({
where: {
source: 'cloud_subscription',
@@ -1672,11 +1800,11 @@ test('should persist mutable team subscription fields on same stripe subscriptio
},
});
t.like(subInDB, {
t.like(providerFact, {
recurring: SubscriptionRecurring.Yearly,
quantity: 9,
start: new Date(1780000000 * 1000),
end: new Date(1811536000 * 1000),
periodStart: new Date(1780000000 * 1000),
periodEnd: new Date(1811536000 * 1000),
trialStart: new Date(1780000000 * 1000),
trialEnd: new Date(1780604800 * 1000),
});
@@ -1748,11 +1876,16 @@ test('should suspend on dispute and restore when dispute won', async t => {
await service.handleRefundedInvoice(invoice.id, 'dispute_open');
const removed = await db.subscription.findFirst({
where: { stripeSubscriptionId: 'sub_1' },
const suspended = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.is(removed, null);
t.is(suspended?.status, SubscriptionStatus.Canceled);
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: t.context.u1.id,
@@ -1766,8 +1899,13 @@ test('should suspend on dispute and restore when dispute won', async t => {
await service.handleRefundedInvoice(invoice.id, 'dispute_won');
const restored = await db.subscription.findFirst({
where: { stripeSubscriptionId: 'sub_1' },
const restored = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.truthy(restored);
@@ -1781,6 +1919,99 @@ test('should suspend on dispute and restore when dispute won', async t => {
);
});
test('should keep selfhost provider identity and license source on replay and cancellation', async t => {
const { app, db, service } = t.context;
const selfhostSubscription = {
...sub,
id: 'sub_selfhost',
schedule: 'schedule_selfhost',
items: {
...sub.items,
data: [
{
...sub.items.data[0],
quantity: 5,
subscription: 'sub_selfhost',
price: {
id: 'price_selfhost',
lookup_key: `${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Yearly}`,
product: 'product_selfhost',
currency: 'usd',
unit_amount: 12000,
},
},
],
},
} as Stripe.Subscription;
await service.saveStripeSubscription(selfhostSubscription);
const first = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: selfhostSubscription.id,
},
},
});
await db.providerSubscription.create({
data: {
provider: 'revenuecat',
targetType: 'instance',
targetId: first.targetId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
iapStore: 'app_store',
externalCustomerId: 'selfhost-customer',
externalProductId: 'selfhost-product',
externalRef: 'selfhost-ref',
},
});
const selected = await app
.get(SelfhostTeamSubscriptionManager)
.getSubscription({
key: first.targetId,
plan: SubscriptionPlan.SelfHostedTeam,
});
t.is(selected?.provider, 'stripe');
await service.saveStripeSubscription({
...selfhostSubscription,
items: {
...selfhostSubscription.items,
data: [{ ...selfhostSubscription.items.data[0], quantity: 7 }],
},
});
const replayed = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: selfhostSubscription.id,
},
},
});
t.is(replayed.targetId, first.targetId);
t.is(replayed.quantity, 7);
t.is(await db.license.count({ where: { key: first.targetId } }), 1);
t.is(app.mails.count('TeamLicense'), 1);
await service.deleteStripeSubscription({
...selfhostSubscription,
status: SubscriptionStatus.Canceled,
});
t.is(
(
await db.providerSubscription.findUniqueOrThrow({
where: { id: first.id },
})
).status,
SubscriptionStatus.Canceled
);
t.is(await db.license.count({ where: { key: first.targetId } }), 1);
});
// NOTE(@forehalo): cancel and resume a team subscription share the same logic with user subscription
test.skip('should be able to cancel team subscription', async () => {});
test.skip('should be able to resume team subscription', async () => {});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { WorkspaceRole } from '../../core/permission';
import { UserType } from '../../core/user/types';
@Injectable()
@@ -11,12 +10,12 @@ export class WorkspaceResolverMock {
async createWorkspace(user: UserType, _init: null) {
const workspace = await this.prisma.workspace.create({
data: {
public: false,
permissions: {
accessPolicy: { create: {} },
members: {
create: {
type: WorkspaceRole.Owner,
role: 'owner',
state: 'active',
userId: user.id,
status: WorkspaceMemberStatus.Accepted,
},
},
},
@@ -8,7 +8,7 @@ import { ConfigFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import { BlobModel } from '../../models';
import { getMime } from '../../native';
import {
collectAllBlobSizes,
@@ -34,7 +34,6 @@ const RESTRICTED_QUOTA = {
};
let app: TestingApp;
let model: WorkspaceFeatureModel;
type CompleteResult =
| {
ok: true;
@@ -146,7 +145,6 @@ test.before(async () => {
builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime);
},
});
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
blob: {
@@ -447,18 +445,6 @@ test('should reject blob exceeded storage quota', async t => {
});
});
test('should accept blob even storage out of quota if workspace has unlimited feature', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
await model.add(workspace.id, 'unlimited_workspace', 'test');
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
await t.notThrowsAsync(setBlob(app, workspace.id, buffer));
await t.notThrowsAsync(setBlob(app, workspace.id, buffer));
});
test('should throw error when blob size large than max file size', async t => {
await app.signup();
@@ -7,9 +7,7 @@ import Sinon from 'sinon';
import supertest from 'supertest';
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
import { ConfigFactory } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { PermissionReadModel } from '../../core/permission/config';
import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
import {
@@ -52,11 +50,10 @@ test.before(async t => {
workspace: {
create: {
id: 'public',
public: true,
accessPolicy: { create: { visibility: 'public' } },
},
},
docId: 'private',
public: false,
},
});
@@ -65,11 +62,10 @@ test.before(async t => {
workspace: {
create: {
id: 'private',
public: false,
accessPolicy: { create: {} },
},
},
docId: 'public',
public: true,
},
});
@@ -78,13 +74,28 @@ test.before(async t => {
workspace: {
create: {
id: 'totally-private',
public: false,
accessPolicy: { create: {} },
},
},
docId: 'private',
public: false,
},
});
await db.docAccessPolicy.createMany({
data: [
{ workspaceId: 'public', docId: 'private', visibility: 'private' },
{
workspaceId: 'private',
docId: 'public',
visibility: 'public',
publicRole: 'external',
},
{
workspaceId: 'totally-private',
docId: 'private',
visibility: 'private',
},
],
});
});
test.after.always(async t => {
@@ -154,31 +165,6 @@ test('should be able to get private workspace with public pages', async t => {
t.is(res.text, 'blob');
});
test('should be able to get private workspace with public pages using new permission model', async t => {
const { app, storage } = t.context;
const config = app.get(ConfigFactory);
config.override({
permission: {
readModel: PermissionReadModel.Projection,
},
});
try {
storage.get.resolves(blob());
const res = await app.GET('/api/workspaces/private/blobs/test');
t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
} finally {
config.override({
permission: {
readModel: PermissionReadModel.Legacy,
},
});
}
});
test('should not be able to get private workspace with no public pages', async t => {
const { app } = t.context;
+57 -48
View File
@@ -1,31 +1,39 @@
import { Models, UserFeatureName, WorkspaceFeatureName } from '../../models';
import { Models } from '../../models';
import {
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { EntitlementService } from '../entitlement';
export async function createDevUsers(models: Models) {
export async function createDevUsers(
models: Models,
entitlement: EntitlementService
) {
const devUsers: {
email: string;
name: string;
password: string;
features: UserFeatureName[];
workspaceFeatures?: WorkspaceFeatureName[];
plans: Array<'pro' | 'ai'>;
teamWorkspace?: boolean;
}[] = [
{
email: 'dev@affine.pro',
name: 'Dev User',
password: 'dev',
features: ['free_plan_v1', 'unlimited_copilot', 'administrator'],
plans: ['ai'],
},
{
email: 'pro@affine.pro',
name: 'Pro User',
password: 'pro',
features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'],
plans: ['pro', 'ai'],
},
{
email: 'team@affine.pro',
name: 'Team User',
password: 'team',
features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'],
workspaceFeatures: ['team_plan_v1'],
plans: ['pro', 'ai'],
teamWorkspace: true,
},
];
const devWorkspaceBlob = Buffer.from(
@@ -33,13 +41,7 @@ export async function createDevUsers(models: Models) {
'base64'
);
for (const {
email,
name,
password,
features,
workspaceFeatures,
} of devUsers) {
for (const { email, name, password, plans, teamWorkspace } of devUsers) {
try {
let devUser = await models.user.getUserByEmail(email);
if (!devUser) {
@@ -49,40 +51,47 @@ export async function createDevUsers(models: Models) {
password,
});
}
for (const feature of features) {
if (feature.includes('plan')) {
await models.userFeature.switchQuota(devUser.id, feature, name);
} else {
await models.userFeature.add(devUser.id, feature, name);
}
await models.userFeature.add(devUser.id, 'administrator', name);
for (const plan of plans) {
await entitlement.upsertFromCloudSubscription({
targetId: devUser.id,
plan,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
provider: 'dev',
subscriptionId: `dev:${devUser.id}:${plan}`,
});
}
if (workspaceFeatures) {
for (const feature of workspaceFeatures) {
const workspaceIds = (
await models.workspaceUser.getUserActiveRoles(devUser.id)
).map(row => row.workspaceId);
const workspaces = await models.workspace.findMany(workspaceIds);
let hasFeatureWorkspace = false;
for (const workspace of workspaces) {
if (await models.workspaceFeature.has(workspace.id, feature)) {
hasFeatureWorkspace = true;
break;
}
}
if (!hasFeatureWorkspace) {
// create a new workspace with the feature
const workspace = await models.workspace.create(devUser.id);
await models.doc.upsert({
spaceId: workspace.id,
docId: workspace.id,
blob: devWorkspaceBlob,
timestamp: Date.now(),
editorId: devUser.id,
});
await models.workspaceFeature.add(workspace.id, feature, name, {
memberLimit: 10,
});
}
if (teamWorkspace) {
const workspaceIds = (
await models.workspaceUser.getUserActiveRoles(devUser.id)
).map(row => row.workspaceId);
const workspaces = await models.workspace.findMany(workspaceIds);
const hasTeamWorkspace = (
await Promise.all(
workspaces.map(workspace =>
entitlement.resolveWorkspaceEntitlement(workspace.id)
)
)
).some(resolved => resolved.plan === 'team');
if (!hasTeamWorkspace) {
const workspace = await models.workspace.create(devUser.id);
await models.doc.upsert({
spaceId: workspace.id,
docId: workspace.id,
blob: devWorkspaceBlob,
timestamp: Date.now(),
editorId: devUser.id,
});
await entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: 'team',
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
quantity: 10,
provider: 'dev',
subscriptionId: `dev:${workspace.id}:team`,
});
}
}
} catch {
@@ -3,6 +3,7 @@ import './config';
import { Module } from '@nestjs/common';
import { BackendRuntimeModule } from '../backend-runtime';
import { EntitlementModule } from '../entitlement';
import { FeatureModule } from '../features';
import { MailModule } from '../mail';
import { QuotaModule } from '../quota';
@@ -27,6 +28,7 @@ import { AuthSigningKeyResolver } from './signing-key-resolver';
imports: [
BackendRuntimeModule,
FeatureModule,
EntitlementModule,
UserModule,
QuotaModule,
MailModule,
@@ -7,6 +7,7 @@ import { assign, pick } from 'lodash-es';
import { Config, OnEvent, SignUpForbidden } from '../../base';
import { Models, type User, type UserSession } from '../../models';
import { EntitlementService } from '../entitlement';
import { Mailer } from '../mail/mailer';
import type { MailDeliveryMetadata } from '../mail/types';
import { AuthSessionService } from './auth-session';
@@ -44,7 +45,8 @@ export class AuthService implements OnApplicationBootstrap {
private readonly config: Config,
private readonly models: Models,
private readonly mailer: Mailer,
private readonly authSessions: AuthSessionService
private readonly authSessions: AuthSessionService,
private readonly entitlement: EntitlementService
) {
this.cookieOptions = {
sameSite: 'lax',
@@ -63,7 +65,7 @@ export class AuthService implements OnApplicationBootstrap {
async onApplicationBootstrap() {
if (env.dev) {
await createDevUsers(this.models);
await createDevUsers(this.models, this.entitlement);
}
}
@@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, hasNewerVersion, URLHelper } from '../../base';
import { Namespace } from '../../env';
import { Feature, type WorkspaceFeatureName } from '../../models';
import { Feature } from '../../models';
import { CurrentUser, Public } from '../auth';
import { Admin } from '../common';
import { AvailableUserFeatureConfig } from '../features';
@@ -168,13 +168,6 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
override availableUserFeatures() {
return super.availableUserFeatures();
}
@ResolveField(() => [Feature], {
description: 'Workspace features available for admin configuration',
})
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
return [];
}
}
@InputType()
@@ -1,175 +0,0 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import {
EntitlementModule,
EntitlementProjectionChecker,
EntitlementService,
} from '../index';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
checker: EntitlementProjectionChecker;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.checker = module.get(EntitlementProjectionChecker);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('checker distinguishes valid projection from dirty legacy features', async t => {
const cleanUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: cleanUser.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
});
const dirtyUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.userFeature.add(
dirtyUser.id,
'pro_plan_v1',
'dirty legacy feature'
);
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.dirtyLegacyUserFeatures, 1);
t.is(report.missingUserFeatureProjection, 0);
});
test('checker reports missing legacy projection and stale state', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id,
plan: 'pro',
recurring: SubscriptionRecurring.Monthly,
status: 'active',
},
{ emit: false }
);
await t.context.db.effectiveUserQuotaState.update({
where: { userId: user.id },
data: {
staleAfter: new Date('2020-01-01T00:00:00Z'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionProjectionMissing, 1);
t.is(report.staleEffectiveUserState, 1);
});
test('checker reports legal legacy facts missing entitlements', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.installedLicense.create({
data: {
key: 'legacy-verifiable-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('raw-license'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.cloudSubscriptionEntitlementMissing, 1);
t.is(report.selfhostLicenseEntitlementMissing, 1);
});
test('checker reports provider facts missing entitlements', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
externalSubscriptionId: 'sub_provider_without_entitlement',
periodStart: new Date(),
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
},
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.providerActiveEntitlementMissing, 1);
});
test('checker reports entitlements missing active provider facts', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
stripeSubscriptionId: 'sub_entitlement_without_active_provider',
});
const report = await t.context.checker.checkEntitlementProjection();
t.is(report.entitlementProviderMissing, 1);
});
@@ -1,623 +0,0 @@
import { randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { EntitlementModule, EntitlementService } from '../index';
import { LegacyEntitlementProjectionService } from '../projection';
interface Context {
module: TestingModule;
db: PrismaClient;
models: Models;
entitlement: EntitlementService;
projection: LegacyEntitlementProjectionService;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({ imports: [EntitlementModule] });
t.context.module = module;
t.context.db = module.get(PrismaClient);
t.context.models = module.get(Models);
t.context.entitlement = module.get(EntitlementService);
t.context.projection = module.get(LegacyEntitlementProjectionService);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.after.always(async t => {
await t.context.module.close();
});
test('projects user entitlement to legacy user features and subscriptions', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
},
{ emit: false }
);
await t.context.entitlement.upsertFromCloudSubscription(
{
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Monthly,
status: 'active',
},
{ emit: false }
);
await t.context.projection.onEntitlementChanged({
targetType: 'user',
targetId: user.id,
});
t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1'));
t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
t.like(
await t.context.db.subscription.findUnique({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
}),
{
recurring: SubscriptionRecurring.Yearly,
status: 'active',
}
);
await t.context.entitlement.revokeCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
});
t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot'));
});
test('projects workspace entitlement and readonly state to legacy workspace features', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.entitlement.upsertFromCloudSubscription({
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: 'active',
quantity: 8,
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
const teamFeature = await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
);
t.is(teamFeature?.configs.memberLimit, 8);
await t.context.db.effectiveWorkspaceQuotaState.upsert({
where: {
workspaceId: workspace.id,
},
create: {
workspaceId: workspace.id,
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
update: {
plan: 'free',
ownerUserId: owner.id,
usesOwnerQuota: true,
seatLimit: 3,
memberCount: 4,
overcapacityMemberCount: 1,
blobLimit: BigInt(10),
storageQuota: BigInt(10),
usedStorageQuota: BigInt(1),
historyPeriodSeconds: 7,
readonly: true,
readonlyReasons: ['member_overflow'],
known: true,
stale: false,
},
});
await t.context.projection.onWorkspaceQuotaStateChanged({
workspaceId: workspace.id,
});
t.true(
await t.context.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
);
});
test('installed license scanner never trusts quantity without raw license', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.installedLicense.create({
data: {
key: 'legacy-key',
workspaceId: workspace.id,
quantity: 100,
recurring: SubscriptionRecurring.Yearly,
validateKey: '',
validatedAt: new Date(),
},
});
await t.context.projection.scanInstalledLicenses();
const entitlement = await t.context.db.entitlement.findFirst({
where: {
source: 'selfhost_license',
subjectId: 'legacy-key',
},
});
t.is(entitlement?.status, 'needs_reupload');
t.is(entitlement?.quantity, null);
});
test.serial(
'selfhosted legacy projection ignores unknown entitlements',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'ai',
status: 'active',
subjectId: `forged-ai:${user.id}`,
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'user',
targetId: user.id,
});
t.false(
await t.context.models.userFeature.has(user.id, 'unlimited_copilot')
);
t.is(
await t.context.db.subscription.count({ where: { targetId: user.id } }),
0
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
test('backfill marks selfhost team subscriptions as needing license revalidation', async t => {
await t.context.db.subscription.create({
data: {
targetId: 'license-key-target',
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
},
});
await t.context.projection.backfillEntitlementsAndQuotaStates();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: {
source: 'selfhost_license',
subjectId: 'license-key-target',
},
}),
{
targetType: 'instance',
targetId: 'license-key-target',
plan: 'selfhost_team',
status: 'needs_reupload',
}
);
});
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('shadow backfill preserves legacy rows and records provider facts', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const paidAiUser = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
const danglingTargetId = randomUUID();
await t.context.db.subscription.createMany({
data: [
{
targetId: user.id,
stripeSubscriptionId: 'sub_ai_trial',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
trialStart: new Date('2026-01-01T00:00:00.000Z'),
trialEnd: new Date('2026-01-08T00:00:00.000Z'),
},
{
targetId: paidAiUser.id,
stripeSubscriptionId: 'sub_ai_paid',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
},
{
targetId: danglingTargetId,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-01-01T00:00:00.000Z'),
},
],
});
await t.context.db.invoice.create({
data: {
stripeInvoiceId: 'in_backfill_lifetime',
targetId: user.id,
currency: 'usd',
amount: 9999,
status: 'paid',
reason: 'subscription_create',
},
});
await t.context.db.installedLicense.create({
data: {
key: 'shadow-license-key',
workspaceId: workspace.id,
quantity: 3,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'shadow-validate-key',
validatedAt: new Date(),
},
});
await t.context.projection.shadowBackfillEntitlementsAndQuotaStates();
t.truthy(
await t.context.db.subscription.findFirst({
where: { targetId: danglingTargetId },
})
);
t.like(
await t.context.db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_ai_trial',
},
},
}),
{
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.AI,
status: SubscriptionStatus.Active,
}
);
t.truthy(
await t.context.db.subscriptionTrialUsage.findUnique({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: user.id,
plan: SubscriptionPlan.AI,
},
},
})
);
t.falsy(
await t.context.db.subscriptionTrialUsage.findUnique({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: paidAiUser.id,
plan: SubscriptionPlan.AI,
},
},
})
);
t.like(
await t.context.db.paymentEvent.findUnique({
where: {
provider_externalEventId: {
provider: 'stripe',
externalEventId: 'stripe_invoice:in_backfill_lifetime',
},
},
}),
{
targetId: user.id,
externalInvoiceId: 'in_backfill_lifetime',
amount: 9999,
processingStatus: 'processed',
}
);
t.false(
await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1')
);
});
test('key based selfhost entitlements without raw payload need reupload', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.entitlement.upsertFromSelfhostLicense({
workspaceId: workspace.id,
licenseKey: 'remote-key',
recurring: SubscriptionRecurring.Yearly,
quantity: 5,
validateKey: 'validate-key',
expiresAt: new Date(Date.now() + 3600_000),
});
await t.context.projection.scanInstalledLicenses();
t.like(
await t.context.db.entitlement.findFirstOrThrow({
where: { source: 'selfhost_license', subjectId: 'remote-key' },
}),
{ status: 'needs_reupload', quantity: null }
);
});
test('revoked selfhost entitlement removes installed license projection', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'revoked-key',
quantity: 5,
signedPayload: Buffer.from('signed-license-payload'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.db.installedLicense.create({
data: {
key: 'revoked-key',
workspaceId: workspace.id,
quantity: 5,
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
validatedAt: new Date(),
license: Buffer.from('signed-license-payload'),
},
});
await t.context.entitlement.revokeBySubject(
'selfhost_license',
'revoked-key'
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
});
test('installed license projection uses explicit entitlement status priority', async t => {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.createMany({
data: [
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'expired',
subjectId: 'expired-key',
quantity: 5,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'expired-validate-key',
},
expiresAt: new Date(Date.now() - 3600_000),
validatedAt: new Date(),
},
{
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'grace',
subjectId: 'grace-key',
quantity: 6,
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'grace-validate-key',
},
expiresAt: new Date(Date.now() - 1800_000),
graceUntil: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
],
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
const installedLicense =
await t.context.db.installedLicense.findUniqueOrThrow({
where: { workspaceId: workspace.id },
});
t.is(installedLicense.key, 'grace-key');
t.is(installedLicense.quantity, 6);
t.is(installedLicense.validateKey, 'grace-validate-key');
});
test.serial(
'selfhosted projection does not trust non-null signed payload',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error test mutates env singleton for deployment-specific projection semantics
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
const owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: workspace.id,
source: 'selfhost_license',
plan: 'selfhost_team',
status: 'active',
subjectId: 'forged-key',
quantity: 100,
signedPayload: Buffer.from('not-a-valid-license'),
metadata: {
recurring: SubscriptionRecurring.Yearly,
validateKey: 'validate-key',
},
expiresAt: new Date(Date.now() + 3600_000),
validatedAt: new Date(),
},
});
await t.context.projection.onEntitlementChanged({
targetType: 'workspace',
targetId: workspace.id,
});
t.falsy(
await t.context.models.workspaceFeature.get(
workspace.id,
'team_plan_v1'
)
);
t.falsy(
await t.context.db.installedLicense.findUnique({
where: { workspaceId: workspace.id },
})
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);
@@ -9,7 +9,6 @@ import { Models } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { EntitlementModule } from '../index';
import { EntitlementService } from '../service';
@@ -375,134 +374,3 @@ test('selfhosted resolution ignores unsigned DB entitlements', async t => {
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
});
test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => {
const user = await t.context.models.user.create({
email: 'legacy-subscription-user@affine.pro',
});
await t.context.db.subscription.create({
data: {
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 1,
start: new Date(),
},
});
const userResolved = await t.context.service.resolveUserEntitlement(user.id);
const userEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(userResolved.plan, 'pro');
t.is(userEntitlement?.status, 'active');
const owner = await t.context.models.user.create({
email: 'legacy-subscription-owner@affine.pro',
});
const workspace = await t.context.models.workspace.create(owner.id);
await t.context.db.subscription.create({
data: {
targetId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
quantity: 7,
start: new Date(),
},
});
const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement(
workspace.id
);
t.is(workspaceResolved.plan, 'team');
t.is(workspaceResolved.quantity, 7);
t.is(workspaceResolved.quota.seatLimit, 7);
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const revokedResolved = await t.context.service.resolveUserEntitlement(
user.id
);
const revokedEntitlement = await t.context.db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
plan: 'pro',
},
});
t.is(revokedResolved.plan, 'free');
t.is(revokedEntitlement?.status, 'revoked');
});
test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => {
const user = await t.context.models.user.create({
email: 'legacy-delete-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
await t.context.db.subscription.delete({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'free');
t.is(updated?.status, 'revoked');
});
test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => {
const user = await t.context.models.user.create({
email: 'string-subscription-user@affine.pro',
});
const entitlement = await t.context.service.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_legacy_string',
});
await t.context.db.subscription.findUniqueOrThrow({
where: {
targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro },
},
});
const resolved = await t.context.service.resolveUserEntitlement(user.id);
const updated = await t.context.db.entitlement.findUnique({
where: { id: entitlement.id },
});
t.is(resolved.plan, 'pro');
t.is(updated?.status, 'active');
});
@@ -1,23 +1,11 @@
import { Module } from '@nestjs/common';
import { LegacyEntitlementProjectionService } from './projection';
import { EntitlementProjectionChecker } from './projection-checker';
import { EntitlementService } from './service';
@Module({
providers: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
exports: [
EntitlementService,
LegacyEntitlementProjectionService,
EntitlementProjectionChecker,
],
providers: [EntitlementService],
exports: [EntitlementService],
})
export class EntitlementModule {}
export { EntitlementService };
export { EntitlementProjectionChecker };
export { LegacyEntitlementProjectionService };
@@ -1,347 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class EntitlementProjectionChecker {
constructor(private readonly db: PrismaClient) {}
async checkEntitlementProjection() {
const now = new Date();
const [
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
providerActiveEntitlementMissing,
entitlementProviderMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
] = await Promise.all([
this.db.user.count({
where: { quotaState: null },
}),
this.db.workspace.count({
where: { quotaState: null },
}),
this.db.effectiveUserQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.db.effectiveWorkspaceQuotaState.count({
where: {
OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }],
},
}),
this.cloudSubscriptionProjectionMissing(),
this.selfhostLicenseProjectionMissing(),
this.cloudSubscriptionEntitlementMissing(),
this.selfhostLicenseEntitlementMissing(),
this.providerActiveEntitlementMissing(),
this.entitlementProviderMissing(),
this.dirtyLegacyUserFeatures(),
this.dirtyLegacyWorkspaceFeatures(),
this.missingUserFeatureProjection(),
this.missingWorkspaceFeatureProjection(),
]);
return {
missingEffectiveUserState,
missingEffectiveWorkspaceState,
staleEffectiveUserState,
staleEffectiveWorkspaceState,
cloudSubscriptionProjectionMissing,
selfhostLicenseProjectionMissing,
cloudSubscriptionEntitlementMissing,
selfhostLicenseEntitlementMissing,
providerActiveEntitlementMissing,
entitlementProviderMissing,
dirtyLegacyUserFeatures,
dirtyLegacyWorkspaceFeatures,
missingUserFeatureProjection,
missingWorkspaceFeatureProjection,
};
}
private async cloudSubscriptionProjectionMissing() {
const legacyKeys = new Set(
(
await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
})
).map(subscription => `${subscription.targetId}:${subscription.plan}`)
);
const entitlements = await this.validEntitlements({
source: 'cloud_subscription',
});
return entitlements.filter(
entitlement =>
entitlement.targetId &&
!legacyKeys.has(
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
).length;
}
private async selfhostLicenseProjectionMissing() {
const licenseKeys = new Set(
(
await this.db.installedLicense.findMany({
select: { key: true },
})
).map(license => license.key)
);
const entitlements = await this.validEntitlements({
source: 'selfhost_license',
});
return entitlements.filter(
entitlement =>
entitlement.subjectId && !licenseKeys.has(entitlement.subjectId)
).length;
}
private async cloudSubscriptionEntitlementMissing() {
const activeSubscriptions = await this.db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
},
select: { targetId: true, plan: true },
});
const valid = new Set(
(
await this.validEntitlements({
source: 'cloud_subscription',
})
).map(
entitlement =>
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
);
return activeSubscriptions.filter(
subscription =>
!valid.has(`${subscription.targetId}:${subscription.plan}`)
).length;
}
private async selfhostLicenseEntitlementMissing() {
const licenses = await this.db.installedLicense.findMany({
where: {
license: { not: null },
},
select: { key: true },
});
const validKeys = new Set(
(
await this.validEntitlements({
source: 'selfhost_license',
})
).flatMap(entitlement => entitlement.subjectId ?? [])
);
return licenses.filter(license => !validKeys.has(license.key)).length;
}
private async providerActiveEntitlementMissing() {
const activeProviderKeys = await this.activeProviderSubscriptionKeys();
const valid = new Set(
(
await this.validEntitlements({
source: 'cloud_subscription',
})
).map(
entitlement =>
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
);
return activeProviderKeys.filter(key => !valid.has(key)).length;
}
private async entitlementProviderMissing() {
const activeProviderKeys = new Set(
await this.activeProviderSubscriptionKeys()
);
const entitlements = await this.validEntitlements({
source: 'cloud_subscription',
});
return entitlements.filter(
entitlement =>
entitlement.targetId &&
!activeProviderKeys.has(
`${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}`
)
).length;
}
private async dirtyLegacyUserFeatures() {
const rows = await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: {
userId: true,
name: true,
},
});
const valid = new Set(
(
await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
})
).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`)
);
return rows.filter(row => {
const plan =
row.name === 'lifetime_pro_plan_v1'
? 'lifetime_pro'
: row.name === 'pro_plan_v1'
? 'pro'
: 'ai';
return !valid.has(`${row.userId}:${plan}`);
}).length;
}
private async dirtyLegacyWorkspaceFeatures() {
const rows = await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
});
const validWorkspaceIds = new Set(
(
await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
})
).flatMap(entitlement => entitlement.targetId ?? [])
);
return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length;
}
private async missingUserFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'user',
plan: { in: ['pro', 'lifetime_pro', 'ai'] },
});
const features = new Set(
(
await this.db.userFeature.findMany({
where: {
activated: true,
name: {
in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'],
},
},
select: { userId: true, name: true },
})
).map(feature => `${feature.userId}:${feature.name}`)
);
return entitlements.filter(entitlement => {
if (!entitlement.targetId) {
return false;
}
const feature =
entitlement.plan === 'lifetime_pro'
? 'lifetime_pro_plan_v1'
: entitlement.plan === 'pro'
? 'pro_plan_v1'
: 'unlimited_copilot';
return !features.has(`${entitlement.targetId}:${feature}`);
}).length;
}
private async missingWorkspaceFeatureProjection() {
const entitlements = await this.validEntitlements({
targetType: 'workspace',
plan: { in: ['team', 'selfhost_team'] },
});
const featureWorkspaceIds = new Set(
(
await this.db.workspaceFeature.findMany({
where: {
activated: true,
name: 'team_plan_v1',
},
select: { workspaceId: true },
})
).map(feature => feature.workspaceId)
);
return entitlements.filter(
entitlement =>
entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId)
).length;
}
private validEntitlements(where: Record<string, unknown>) {
const now = new Date();
return this.db.entitlement.findMany({
where: {
...where,
...(where.source === 'selfhost_license'
? { signedPayload: { not: null } }
: {}),
OR: [
{
status: 'active',
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
{
status: 'grace',
graceUntil: { gt: now },
},
],
},
select: {
targetId: true,
subjectId: true,
plan: true,
},
});
}
private subscriptionPlan(plan: string) {
return plan === 'lifetime_pro' ? 'pro' : plan;
}
private async activeProviderSubscriptionKeys() {
const now = new Date();
const subscriptions = await this.db.providerSubscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
OR: [{ periodEnd: null }, { periodEnd: { gt: now } }],
},
select: {
targetId: true,
plan: true,
},
});
return subscriptions.map(
subscription => `${subscription.targetId}:${subscription.plan}`
);
}
}
@@ -1,835 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Entitlement, IapStore, PrismaClient, Provider } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../plugins/payment/types';
import { EntitlementService } from './service';
type Metadata = {
provider?: string | null;
recurring?: string | null;
variant?: string | null;
subscriptionId?: string | number | null;
stripeSubscriptionId?: string | null;
validateKey?: string | null;
legacyProjected?: boolean;
};
const BACKFILL_BATCH_SIZE = 1000;
@Injectable()
export class LegacyEntitlementProjectionService {
constructor(
private readonly db: PrismaClient,
private readonly models: Models,
private readonly entitlement: EntitlementService
) {}
@OnEvent('entitlement.changed')
async onEntitlementChanged({
targetType,
targetId,
}: Events['entitlement.changed']) {
if (targetType === 'user') {
await this.#projectCloudSubscriptions('user', targetId);
await this.#projectUserFeatures(targetId);
} else if (targetType === 'workspace') {
await this.#projectCloudSubscriptions('workspace', targetId);
await Promise.all([
this.#projectWorkspaceFeatures(targetId),
this.#projectInstalledLicense(targetId),
]);
}
}
@OnEvent('workspace.quota_state.changed')
async onWorkspaceQuotaStateChanged({
workspaceId,
}: Events['workspace.quota_state.changed']) {
await this.#projectReadonlyFeature(workspaceId);
}
async scanInstalledLicenses(options: { emit?: boolean } = {}) {
const licenses = await this.db.installedLicense.findMany();
const emit = options.emit ?? true;
await Promise.all(
licenses.map(async license =>
license.license
? await this.entitlement.upsertFromSelfhostLicense(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
license: Buffer.from(license.license),
},
{ emit }
)
: license.validateKey
? await this.entitlement.upsertFromValidatedSelfhostLicense(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
recurring: license.recurring,
quantity: license.quantity,
expiresAt: license.expiredAt,
validatedAt: license.validatedAt,
validateKey: license.validateKey,
variant: license.variant,
},
{ emit }
)
: await this.entitlement.markSelfhostLicenseNeedsReupload(
{
workspaceId: license.workspaceId,
licenseKey: license.key,
reason: 'Installed license has no raw payload to verify.',
},
{ emit }
)
)
);
}
async backfillEntitlementsAndQuotaStates() {
await this.#cleanupDanglingLegacyEntitlements();
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: true });
}
async shadowBackfillEntitlementsAndQuotaStates() {
await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: false });
}
async #backfillEntitlementsAndQuotaStates({
cleanupLegacy,
}: {
cleanupLegacy: boolean;
}) {
const [subscriptionCount, invoiceCount, installedLicenseCount] =
await Promise.all([
this.db.subscription.count(),
this.db.invoice.count(),
this.db.installedLicense.count(),
]);
if (
subscriptionCount === 0 &&
invoiceCount === 0 &&
installedLicenseCount === 0
) {
await this.#backfillQuotaStateStaleFlags();
return;
}
const subscriptions = await this.db.subscription.findMany();
for (const subscription of subscriptions) {
if (!(await this.#subscriptionTargetExists(subscription))) {
continue;
}
if (subscription.plan === SubscriptionPlan.SelfHostedTeam) {
await this.entitlement.markSelfhostLicenseNeedsReupload(
{
licenseKey: subscription.targetId,
reason:
'Historical self-hosted team subscription needs license activation or revalidation.',
},
{ emit: cleanupLegacy }
);
continue;
}
await this.entitlement.upsertFromCloudSubscription(subscription, {
emit: cleanupLegacy,
legacySync: true,
});
await this.#backfillProviderSubscription(subscription);
if (
subscription.plan === SubscriptionPlan.AI &&
(subscription.trialStart || subscription.trialEnd)
) {
await this.#backfillTrialUsage(subscription);
}
}
await this.#backfillPaymentEvents();
await this.scanInstalledLicenses({ emit: cleanupLegacy });
await this.#backfillQuotaStateStaleFlags();
}
async #backfillQuotaStateStaleFlags() {
await Promise.all([
this.db.effectiveUserQuotaState.updateMany({
data: { stale: true },
}),
this.db.effectiveWorkspaceQuotaState.updateMany({
data: { stale: true },
}),
]);
await Promise.all([
this.#createMissingUserQuotaStates(),
this.#createMissingWorkspaceQuotaStates(),
]);
}
async #createMissingUserQuotaStates() {
let lastId: string | undefined;
while (true) {
const users = await this.db.user.findMany({
select: { id: true },
where: lastId ? { id: { gt: lastId } } : undefined,
orderBy: { id: 'asc' },
take: BACKFILL_BATCH_SIZE,
});
if (!users.length) {
break;
}
await this.db.effectiveUserQuotaState.createMany({
data: users.map(user => ({
userId: user.id,
plan: 'free',
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
})),
skipDuplicates: true,
});
lastId = users.at(-1)?.id;
}
}
async #createMissingWorkspaceQuotaStates() {
let lastId: string | undefined;
while (true) {
const workspaces = await this.db.workspace.findMany({
select: { id: true },
where: lastId ? { id: { gt: lastId } } : undefined,
orderBy: { id: 'asc' },
take: BACKFILL_BATCH_SIZE,
});
if (!workspaces.length) {
break;
}
await this.db.effectiveWorkspaceQuotaState.createMany({
data: workspaces.map(workspace => ({
workspaceId: workspace.id,
plan: 'free',
usesOwnerQuota: true,
seatLimit: 0,
memberCount: 0,
overcapacityMemberCount: 0,
blobLimit: BigInt(0),
storageQuota: BigInt(0),
usedStorageQuota: BigInt(0),
historyPeriodSeconds: 0,
known: false,
stale: true,
})),
skipDuplicates: true,
});
lastId = workspaces.at(-1)?.id;
}
}
async #backfillProviderSubscription(subscription: {
targetId: string;
plan: string;
recurring: string;
status: string;
provider: Provider | string;
iapStore?: IapStore | null;
rcExternalRef?: string | null;
rcProductId?: string | null;
stripeSubscriptionId?: string | null;
quantity: number;
start: Date;
end?: Date | null;
trialStart?: Date | null;
trialEnd?: Date | null;
canceledAt?: Date | null;
}) {
const targetType =
subscription.plan === SubscriptionPlan.Team ? 'workspace' : 'user';
if (
subscription.provider === 'stripe' &&
subscription.stripeSubscriptionId
) {
await this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.stripeSubscriptionId,
},
},
update: {
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
create: {
provider: 'stripe',
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
externalSubscriptionId: subscription.stripeSubscriptionId,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
});
return;
}
if (
subscription.provider === 'revenuecat' &&
subscription.iapStore &&
subscription.rcExternalRef &&
subscription.rcProductId
) {
await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId: {
provider: 'revenuecat',
iapStore: subscription.iapStore,
externalRef: subscription.rcExternalRef,
externalProductId: subscription.rcProductId,
externalCustomerId: subscription.targetId,
},
},
update: {
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
create: {
provider: 'revenuecat',
targetType,
targetId: subscription.targetId,
plan: subscription.plan,
recurring: subscription.recurring,
status: subscription.status,
externalCustomerId: subscription.targetId,
iapStore: subscription.iapStore,
externalRef: subscription.rcExternalRef,
externalProductId: subscription.rcProductId,
quantity: subscription.quantity,
periodStart: subscription.start,
periodEnd: subscription.end,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
canceledAt: subscription.canceledAt,
metadata: { legacySync: true },
},
});
}
}
async #backfillTrialUsage(subscription: {
targetId: string;
plan: string;
provider: Provider | string;
stripeSubscriptionId?: string | null;
rcExternalRef?: string | null;
trialStart?: Date | null;
trialEnd?: Date | null;
start: Date;
}) {
await this.db.subscriptionTrialUsage.upsert({
where: {
targetType_targetId_plan: {
targetType: 'user',
targetId: subscription.targetId,
plan: subscription.plan,
},
},
update: {},
create: {
targetType: 'user',
targetId: subscription.targetId,
plan: subscription.plan,
provider:
subscription.provider === 'revenuecat' ? 'revenuecat' : 'stripe',
externalRef:
subscription.stripeSubscriptionId ??
subscription.rcExternalRef ??
null,
firstUsedAt:
subscription.trialStart ??
subscription.trialEnd ??
subscription.start,
metadata: { legacySync: true },
},
});
}
async #backfillPaymentEvents() {
const invoices = await this.db.invoice.findMany();
for (const invoice of invoices) {
await this.db.paymentEvent.upsert({
where: {
provider_externalEventId: {
provider: 'stripe',
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
},
},
update: {
targetId: invoice.targetId,
externalInvoiceId: invoice.stripeInvoiceId,
amount: invoice.amount,
currency: invoice.currency,
processingStatus: 'processed',
processedAt: invoice.updatedAt,
metadata: {
legacySync: true,
status: invoice.status,
reason: invoice.reason,
},
},
create: {
provider: 'stripe',
eventType: 'invoice.backfill',
externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`,
targetId: invoice.targetId,
externalInvoiceId: invoice.stripeInvoiceId,
amount: invoice.amount,
currency: invoice.currency,
occurredAt: invoice.createdAt,
processingStatus: 'processed',
processedAt: invoice.updatedAt,
metadata: {
legacySync: true,
status: invoice.status,
reason: invoice.reason,
},
},
});
}
}
async #cleanupDanglingLegacyEntitlements() {
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) {
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
const entitlements = await this.#activeEntitlements('user', userId);
const quotaEntitlement = entitlements.find(entitlement =>
['lifetime_pro', 'pro'].includes(entitlement.plan)
);
if (quotaEntitlement?.plan === 'lifetime_pro') {
await this.models.userFeature.switchQuota(
userId,
'lifetime_pro_plan_v1',
'legacy entitlement projection'
);
} else if (quotaEntitlement?.plan === 'pro') {
await this.models.userFeature.switchQuota(
userId,
'pro_plan_v1',
'legacy entitlement projection'
);
} else if (
await this.hasActiveUserFeature(userId, [
'pro_plan_v1',
'lifetime_pro_plan_v1',
])
) {
await this.models.userFeature.switchQuota(
userId,
'free_plan_v1',
'legacy entitlement projection'
);
}
if (entitlements.some(entitlement => entitlement.plan === 'ai')) {
await this.models.userFeature.add(
userId,
'unlimited_copilot',
'legacy entitlement projection'
);
} else {
await this.models.userFeature.remove(userId, 'unlimited_copilot');
}
}
async #projectWorkspaceFeatures(workspaceId: string) {
// TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone.
const [entitlement, resolved] = await Promise.all([
this.entitlement.getBestEntitlement('workspace', workspaceId),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
if (
entitlement &&
['team', 'selfhost_team'].includes(resolved.plan) &&
resolved.valid &&
resolved.quota.seatLimit
) {
await this.models.workspaceFeature.add(
workspaceId,
'team_plan_v1',
'legacy entitlement projection',
{
memberLimit: resolved.quota.seatLimit,
}
);
} else {
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
}
}
async #projectCloudSubscriptions(
targetType: 'user' | 'workspace',
targetId: string
) {
// TODO(stable-upgrade): remove reverse projection after stable no longer depends on old subscriptions.
if (env.selfhosted) return;
const entitlements = await this.db.entitlement.findMany({
where: {
targetType,
targetId,
source: 'cloud_subscription',
},
orderBy: { updatedAt: 'asc' },
});
for (const entitlement of this.#projectableCloudEntitlements(
entitlements
)) {
const metadata = entitlement.metadata as Metadata;
await this.db.subscription.upsert({
where: {
targetId_plan: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
},
},
update: {
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
create: {
targetId,
plan: this.#subscriptionPlan(entitlement.plan),
recurring: metadata.recurring ?? SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
stripeSubscriptionId: metadata.stripeSubscriptionId ?? null,
provider: this.#provider(metadata.provider),
status: this.#subscriptionStatus(entitlement.status),
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
trialEnd: entitlement.graceUntil,
},
});
if (!metadata.legacyProjected) {
await this.db.entitlement.update({
where: { id: entitlement.id },
data: {
metadata: {
...metadata,
legacyProjected: true,
},
},
});
}
}
}
*#projectableCloudEntitlements(entitlements: Entitlement[]) {
const byPlan = new Map<string, Entitlement>();
for (const entitlement of entitlements) {
const plan = this.#subscriptionPlan(entitlement.plan);
const current = byPlan.get(plan);
if (
!current ||
this.#subscriptionProjectionPriority(entitlement) >
this.#subscriptionProjectionPriority(current)
) {
byPlan.set(plan, entitlement);
}
}
yield* byPlan.values();
}
#subscriptionProjectionPriority(entitlement: {
status: string;
updatedAt: Date;
}) {
const statusPriority =
entitlement.status === 'active' || entitlement.status === 'grace'
? 2
: entitlement.status === 'expired'
? 1
: 0;
return (
statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime()
);
}
async #projectInstalledLicense(workspaceId: string) {
const [entitlements, resolved] = await Promise.all([
this.db.entitlement.findMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
source: 'selfhost_license',
},
orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }],
}),
this.entitlement.resolveWorkspaceEntitlement(workspaceId),
]);
const entitlement = entitlements.sort(
(left, right) =>
this.#installedLicenseStatusPriority(right.status) -
this.#installedLicenseStatusPriority(left.status) ||
Number(!!right.signedPayload) - Number(!!left.signedPayload) ||
right.updatedAt.getTime() - left.updatedAt.getTime()
)[0];
if (!entitlement) {
return;
}
if (
resolved.plan !== 'selfhost_team' ||
!['active', 'grace', 'expired'].includes(resolved.status)
) {
await this.db.installedLicense.deleteMany({
where: { workspaceId },
});
return;
}
const metadata = entitlement.metadata as Metadata;
const expiredAt = resolved.expiresAt
? new Date(resolved.expiresAt)
: entitlement.expiresAt;
await this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
create: {
workspaceId,
key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id,
quantity: resolved.quantity ?? 1,
recurring:
resolved.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
validateKey: metadata.validateKey ?? '',
validatedAt: entitlement.validatedAt ?? new Date(),
expiredAt,
license: entitlement.signedPayload
? Buffer.from(entitlement.signedPayload)
: null,
},
});
}
#installedLicenseStatusPriority(status: string) {
if (status === 'active' || status === 'grace') {
return 3;
}
if (status === 'expired') {
return 2;
}
if (status === 'needs_reupload') {
return 1;
}
return 0;
}
async #projectReadonlyFeature(workspaceId: string) {
const state = await this.db.effectiveWorkspaceQuotaState.findUnique({
where: {
workspaceId,
},
});
if (state?.readonly) {
await this.models.workspaceFeature.add(
workspaceId,
'quota_exceeded_readonly_workspace_v1',
`legacy quota state projection: ${state.readonlyReasons.join(',')}`
);
} else {
await this.models.workspaceFeature.remove(
workspaceId,
'quota_exceeded_readonly_workspace_v1'
);
}
}
async #activeEntitlements(
targetType: 'user' | 'workspace',
targetId: string
) {
return this.entitlement.getActiveEntitlements(targetType, targetId);
}
private async hasActiveUserFeature(userId: string, names: string[]) {
const count = await this.db.userFeature.count({
where: {
userId,
name: { in: names },
activated: true,
},
});
return count > 0;
}
#subscriptionPlan(plan: string) {
if (plan === 'lifetime_pro') {
return SubscriptionPlan.Pro;
}
if (plan === 'selfhost_team') {
return SubscriptionPlan.SelfHostedTeam;
}
return plan;
}
#subscriptionStatus(status: string) {
if (status === 'active') {
return SubscriptionStatus.Active;
}
if (status === 'grace') {
return SubscriptionStatus.PastDue;
}
return SubscriptionStatus.Canceled;
}
#provider(provider: string | null | undefined) {
return provider === 'revenuecat' ? 'revenuecat' : 'stripe';
}
}
@@ -60,10 +60,6 @@ declare global {
@Injectable()
export class EntitlementService {
private readonly legacyCloudSubscriptionSyncs = new Map<
string,
Promise<void>
>();
private readonly remoteSelfhostLicenseVerifications = new Map<
string,
Promise<Entitlement | null>
@@ -102,7 +98,6 @@ export class EntitlementService {
}
async getBestEntitlement(targetType: TargetType, targetId: string) {
await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId);
const entitlements = await this.db.entitlement.findMany({
where: {
targetType,
@@ -138,7 +133,6 @@ export class EntitlementService {
}
async getActiveEntitlements(targetType: TargetType, targetId: string) {
await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId);
return this.db.entitlement.findMany({
where: {
targetType,
@@ -154,7 +148,7 @@ export class EntitlementService {
async upsertFromCloudSubscription(
input: CloudSubscriptionEntitlementInput,
options: { emit?: boolean; legacySync?: boolean } = {}
options: { emit?: boolean } = {}
) {
const emit = options.emit ?? true;
const targetType = this.targetTypeForPlan(input.plan);
@@ -182,7 +176,6 @@ export class EntitlementService {
variant: input.variant ?? null,
subscriptionId: input.subscriptionId ?? null,
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
legacySync: options.legacySync ?? false,
},
startsAt: input.start ?? null,
expiresAt: input.end ?? null,
@@ -294,37 +287,6 @@ export class EntitlementService {
);
}
async syncLegacyCloudSubscriptionEntitlements(
targetType: TargetType,
targetId: string
) {
// TODO(stable-upgrade): remove legacy subscription import after stable no longer writes old subscriptions.
if (env.selfhosted || targetType === 'instance') {
return;
}
const key = `${targetType}:${targetId}`;
const existing = this.legacyCloudSubscriptionSyncs.get(key);
if (existing) {
return existing;
}
const task = this.doSyncLegacyCloudSubscriptionEntitlements(
targetType,
targetId
)
.then(changed => {
if (changed) {
this.event.emit('entitlement.changed', { targetType, targetId });
}
})
.finally(() => {
this.legacyCloudSubscriptionSyncs.delete(key);
});
this.legacyCloudSubscriptionSyncs.set(key, task);
return task;
}
async upsertFromSelfhostLicense(
input: SelfhostLicenseEntitlementInput,
options: { emit?: boolean } = {}
@@ -505,16 +467,6 @@ export class EntitlementService {
subscriptionId?: string | number | null;
stripeSubscriptionId?: string | null;
}) {
await this.db.subscription.updateMany({
where: {
targetId: input.targetId,
plan: input.plan,
},
data: {
status: SubscriptionStatus.Canceled,
end: new Date(),
},
});
await this.revokeBySubject(
'cloud_subscription',
this.cloudSubjectId(input)
@@ -610,86 +562,6 @@ export class EntitlementService {
}
}
private async doSyncLegacyCloudSubscriptionEntitlements(
targetType: Exclude<TargetType, 'instance'>,
targetId: string
) {
let changed = false;
const legacyPlans =
targetType === 'user'
? [SubscriptionPlan.Pro, SubscriptionPlan.AI]
: [SubscriptionPlan.Team];
const entitlementPlans =
targetType === 'user' ? ['pro', 'lifetime_pro', 'ai'] : ['team'];
const subscriptions = await this.db.subscription.findMany({
where: {
targetId,
plan: { in: legacyPlans },
},
orderBy: { updatedAt: 'asc' },
});
const legacySubjects = new Set(
subscriptions.map(subscription => this.cloudSubjectId(subscription))
);
const legacySubscriptionPlans = new Set(
subscriptions.map(subscription => subscription.plan)
);
for (const subscription of subscriptions) {
const before = await this.findBySubject(
'cloud_subscription',
this.cloudSubjectId(subscription)
);
const entitlement = await this.upsertFromCloudSubscription(subscription, {
emit: false,
legacySync: true,
});
changed =
changed ||
!before ||
before.targetType !== entitlement.targetType ||
before.targetId !== entitlement.targetId ||
before.plan !== entitlement.plan ||
before.status !== entitlement.status ||
before.quantity !== entitlement.quantity ||
before.expiresAt?.getTime() !== entitlement.expiresAt?.getTime() ||
before.graceUntil?.getTime() !== entitlement.graceUntil?.getTime();
}
const staleEntitlements = await this.db.entitlement.findMany({
where: {
targetType,
targetId,
source: 'cloud_subscription',
plan: { in: entitlementPlans },
status: { in: ['active', 'grace'] },
OR: [
{ metadata: { path: ['legacySync'], equals: true } },
{ metadata: { path: ['legacyProjected'], equals: true } },
],
},
});
const staleIds = staleEntitlements
.filter(
entitlement =>
!legacySubjects.has(entitlement.subjectId ?? '') &&
!legacySubscriptionPlans.has(
this.legacySubscriptionPlan(entitlement.plan)
)
)
.map(entitlement => entitlement.id);
if (staleIds.length) {
await this.db.entitlement.updateMany({
where: { id: { in: staleIds } },
data: { status: 'revoked' },
});
changed = true;
}
return changed;
}
private cloudSubjectId(
input: Pick<
CloudSubscriptionEntitlementInput,
@@ -704,19 +576,6 @@ export class EntitlementService {
);
}
private legacySubscriptionPlan(plan: string) {
if (plan === 'pro' || plan === 'lifetime_pro') {
return SubscriptionPlan.Pro;
}
if (plan === 'ai') {
return SubscriptionPlan.AI;
}
if (plan === 'team') {
return SubscriptionPlan.Team;
}
return plan;
}
private async revokeCloudSubscriptionByLegacyTarget(input: {
targetId: string;
plan: SubscriptionPlan | string;
@@ -887,37 +746,23 @@ export class EntitlementService {
const expiresAt = new Date(license.expiresAt);
const validateKey = license.validateKey || metadata.validateKey;
const [updated] = await Promise.all([
this.db.entitlement.update({
where: { id: entitlement.id },
data: {
status: 'active',
quantity: this.normalizedQuantity(license.quantity),
metadata: {
...metadata,
recurring: license.recurring,
validateKey,
remoteValidated: true,
errorCode: null,
errorMessage: null,
},
expiresAt,
validatedAt: new Date(),
const updated = await this.db.entitlement.update({
where: { id: entitlement.id },
data: {
status: 'active',
quantity: this.normalizedQuantity(license.quantity),
metadata: {
...metadata,
recurring: license.recurring,
validateKey,
remoteValidated: true,
errorCode: null,
errorMessage: null,
},
}),
this.db.installedLicense
.updateMany({
where: { key: entitlement.subjectId },
data: {
quantity: this.normalizedQuantity(license.quantity),
recurring: license.recurring,
validateKey,
validatedAt: new Date(),
expiredAt: expiresAt,
},
})
.catch(() => null),
]);
expiresAt,
validatedAt: new Date(),
},
});
this.event.emit('entitlement.changed', {
targetType: 'workspace',
targetId: entitlement.targetId,
@@ -31,7 +31,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
description: 'Enabled features of a user',
})
async userFeatures(@Parent() user: UserType) {
const features = await this.models.userFeature.list(user.id);
const features = await this.models.userFeature.list(
user.id,
undefined,
Array.from(this.availableUserFeatures())
);
const availableUserFeatures = this.availableUserFeatures();
return features.filter(feature => availableUserFeatures.has(feature));
}
@@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';
import { Prisma, PrismaClient } from '@prisma/client';
import test from 'ava';
@@ -7,7 +5,6 @@ import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { AccessControllerBuilder } from '../builder';
import { PermissionDiagnosticService } from '../diagnostic';
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
import type { DocAction } from '../types';
@@ -19,7 +16,6 @@ const module = await createModule({
const builder = module.get(AccessControllerBuilder);
const models = module.get(Models);
const db = module.get(PrismaClient);
const diagnostic = module.get(PermissionDiagnosticService);
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
test.after.always(async () => {
@@ -35,7 +31,7 @@ async function sqlReadableDocIds(input: {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByNewTablesSql({
const predicate = sqlPredicate.docReadableSql({
workspaceId: input.workspaceId,
userId: input.userId,
action: input.action ?? 'Doc.Read',
@@ -73,9 +69,29 @@ async function resetProjection(workspaceId: string) {
member_default_doc_role = EXCLUDED.member_default_doc_role,
updated_at = now()
`;
await models.workspaceRuntimeState.upsert(workspaceId, {
readonly: false,
readonlyReasons: [],
await setWritableRuntime(workspaceId);
}
async function setWritableRuntime(workspaceId: string) {
await db.effectiveWorkspaceQuotaState.upsert({
where: { workspaceId },
create: {
workspaceId,
plan: 'free',
usesOwnerQuota: false,
seatLimit: 0,
blobLimit: 0,
storageQuota: 0,
historyPeriodSeconds: 0,
readonly: false,
known: true,
},
update: {
readonly: false,
readonlyReasons: [],
known: true,
stale: false,
},
});
}
@@ -143,7 +159,7 @@ test('should filter docs by Doc.Read', async t => {
t.is(docs3.length, 0);
});
test('SQL doc read predicate matches Rust for projection default and public candidates', async t => {
test('SQL doc read predicate handles member default and public candidates', async t => {
const owner = await module.create(Mockers.User);
const member = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
@@ -186,18 +202,10 @@ test('SQL doc read predicate matches Rust for projection default and public cand
userId: member.id,
docIds,
});
const shadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: member.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sqlReadable,
});
t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']);
t.true(shadow.matched);
});
test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => {
test('SQL doc read predicate handles non-member grant and sharing disabled', async t => {
const owner = await module.create(Mockers.User);
const nonMember = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
@@ -258,12 +266,6 @@ test('SQL doc read predicate matches Rust for non-member grant and sharing disab
userId: nonMember.id,
docIds,
});
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingEnabledReadable,
});
const sharingEnabledUpdate = await sqlReadableDocIds({
workspaceId: workspace.id,
userId: nonMember.id,
@@ -281,22 +283,14 @@ test('SQL doc read predicate matches Rust for non-member grant and sharing disab
userId: nonMember.id,
docIds,
});
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
workspaceId: workspace.id,
userId: nonMember.id,
docs: docIds.map(docId => ({ docId })),
sqlReadableDocIds: sharingDisabledReadable,
});
t.deepEqual(sharingEnabledReadable, [
'public-doc',
'explicit-grant',
'explicit-owner-grant',
]);
t.true(sharingEnabledShadow.matched);
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
t.deepEqual(sharingDisabledReadable, []);
t.true(sharingDisabledShadow.matched);
});
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
@@ -365,107 +359,13 @@ test('SQL doc predicate suppresses member default when explicit grant exists', a
t.deepEqual(sqlUpdateAllowed, ['default-manager']);
});
test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => {
const workspaceId = randomUUID();
const memberId = randomUUID();
const externalId = randomUUID();
async function fixtureLegacyDocIds(input: {
userId: string;
action: DocAction;
docIds: string[];
}) {
const values = Prisma.join(
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
);
const predicate = sqlPredicate.docReadableByLegacyTablesSql({
workspaceId,
userId: input.userId,
action: input.action,
docIdColumn: Prisma.raw('c.doc_id'),
});
// Current triggers reject newly inserted legacy External workspace rows;
// CTEs let the same predicate run in Postgres against historical shapes.
const rows = await db.$queryRaw<{ docId: string }[]>`
WITH
workspaces(id, enable_sharing) AS (
VALUES (${workspaceId}, true)
),
workspace_pages(workspace_id, page_id, public, "defaultRole") AS (
VALUES
(${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint),
(${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint)
),
workspace_user_permissions(
id,
workspace_id,
user_id,
status,
type
) AS (
VALUES
(${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint),
(${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint)
),
workspace_page_user_permissions(
workspace_id,
page_id,
user_id,
type
) AS (
VALUES
(${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint),
(${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint),
(${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint)
),
candidates(doc_id, ord) AS (VALUES ${values})
SELECT c.doc_id AS "docId"
FROM candidates c
WHERE ${predicate}
ORDER BY c.ord ASC
`;
return rows.map(row => row.docId);
}
const memberUpdateAllowed = await fixtureLegacyDocIds({
userId: memberId,
action: 'Doc.Update',
docIds: ['default-manager', 'explicit-reader'],
});
const externalUpdateAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Update',
docIds: ['external-owner', 'dirty-external'],
});
const externalManageAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.Users.Manage',
docIds: ['external-owner', 'dirty-external'],
});
const externalTransferAllowed = await fixtureLegacyDocIds({
userId: externalId,
action: 'Doc.TransferOwner',
docIds: ['external-owner', 'dirty-external'],
});
t.deepEqual(memberUpdateAllowed, ['default-manager']);
t.deepEqual(externalUpdateAllowed, ['external-owner']);
t.deepEqual(externalManageAllowed, []);
t.deepEqual(externalTransferAllowed, []);
});
test('should filter docs by Doc.Publish', async t => {
const owner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
await models.workspace.update(workspace.id, { enableSharing: true });
await models.workspaceRuntimeState.upsert(workspace.id, {
readonly: false,
readonlyReasons: [],
});
await setWritableRuntime(workspace.id);
const docs1 = await builder
.user(owner.id)
@@ -524,73 +424,3 @@ test('should filter docs by Doc.Publish', async t => {
t.is(docs3.length, 0);
});
test('legacy duplicate doc owner grants do not block projection', async t => {
const owner = await module.create(Mockers.User);
const secondOwner = await module.create(Mockers.User);
const workspace = await module.create(Mockers.Workspace, {
owner,
});
const docId = randomUUID();
await db.$executeRaw`
INSERT INTO workspace_pages (
workspace_id,
page_id,
public,
"defaultRole"
)
VALUES (${workspace.id}, ${docId}, false, ${DocRole.Manager})
`;
await resetProjection(workspace.id);
await db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.$executeRaw`
INSERT INTO workspace_page_user_permissions (
workspace_id,
page_id,
user_id,
type,
created_at
)
VALUES (
${workspace.id},
${docId},
${owner.id},
${DocRole.Owner},
${new Date('2026-01-02T00:00:00Z')}
)
`;
await tx.$executeRaw`
INSERT INTO workspace_page_user_permissions (
workspace_id,
page_id,
user_id,
type,
created_at
)
VALUES (
${workspace.id},
${docId},
${secondOwner.id},
${DocRole.Owner},
${new Date('2026-01-01T00:00:00Z')}
)
`;
});
await models.permissionProjection.backfillLegacyProjection();
const projectedOwners = await db.$queryRaw<{ principalId: string }[]>`
SELECT principal_id AS "principalId"
FROM doc_grants
WHERE workspace_id = ${workspace.id}
AND doc_id = ${docId}
AND role = 'owner'
`;
t.deepEqual(projectedOwners, [{ principalId: secondOwner.id }]);
});
@@ -209,57 +209,14 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
const admin = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.db.$transaction(async db => {
await db.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
const pendingPermission = await db.workspaceUserRole.create({
data: {
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
});
const [invitationShape] = await db.$queryRaw<Array<{ current: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "current"
`;
if (invitationShape?.current) {
await db.workspaceInvitation.create({
data: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
requestedRole: 'member',
status: 'pending',
kind: 'email',
legacyPermissionId: pendingPermission.id,
},
});
} else {
await db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
role,
state,
source,
updated_at
)
VALUES (
${workspace.id},
${pending.id},
${'member'},
${'pending'},
${'email'},
now()
)
`;
}
await t.context.db.workspaceInvitation.create({
data: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
requestedRole: 'member',
status: 'pending',
kind: 'email',
},
});
await t.context.models.workspaceUser.set(
workspace.id,
@@ -269,17 +226,10 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
status: WorkspaceMemberStatus.Accepted,
}
);
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'test team workspace',
{
memberLimit: 20,
}
);
const failure = new Error('cleanup failed');
Sinon.stub(t.context.models.workspaceFeature, 'remove').rejects(failure);
Sinon.stub(t.context.models.workspaceUser, 'demoteAcceptedAdmins').rejects(
failure
);
const error = await t.throwsAsync(
t.context.policy.handleTeamPlanCanceled(workspace.id),
@@ -294,7 +244,4 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
(await t.context.models.workspaceUser.get(workspace.id, admin.id))?.type,
WorkspaceRole.Admin
);
t.true(
await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1')
);
});
File diff suppressed because it is too large Load Diff
@@ -1,31 +0,0 @@
import { z } from 'zod';
import { defineModuleConfig } from '../../base';
export enum PermissionReadModel {
Legacy = 'legacy',
Projection = 'projection',
}
declare global {
interface AppConfigSchema {
permission: {
readModel: PermissionReadModel;
fallbackLegacyLoader: boolean;
};
}
}
defineModuleConfig('permission', {
readModel: {
desc: 'Permission data source for Rust evaluation',
default: PermissionReadModel.Projection,
shape: z.nativeEnum(PermissionReadModel),
env: ['AFFINE_PERMISSION_READ_MODEL', 'string'],
},
fallbackLegacyLoader: {
desc: 'Fallback from projection loader to legacy loader when projection input loading fails',
default: false,
env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'],
},
});
@@ -2,47 +2,26 @@ import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ClsService } from 'nestjs-cls';
import { DocRole, Models } from '../../models';
import type { PermissionEvaluationInputV1 } from '../../native';
import {
toNativeDocRole,
toNativeExplicitDocGrantRole,
toNativeMemberState,
toNativeWorkspaceRole,
} from './context';
import type { DocAction, WorkspaceAction } from './types';
type PermissionRequestCache = {
workspaceMember: Map<
string,
Awaited<ReturnType<Models['workspaceUser']['get']>>
>;
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
workspaceRuntime: Map<
string,
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
>;
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
docPolicies: Map<
string,
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
>;
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
workspaceQuotaRuntime: Map<string, WorkspaceRuntimeState>;
};
type NewWorkspaceMemberRow = {
type WorkspaceMemberRow = {
role: 'owner' | 'admin' | 'member';
state: 'active' | 'suspended' | 'left';
};
type NewWorkspacePolicyRow = {
type WorkspacePolicyRow = {
visibility: 'private' | 'public';
sharingEnabled: boolean;
urlPreviewEnabled: boolean;
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
};
type NewDocPolicyRow = {
type DocPolicyRow = {
docId: string;
visibility: 'private' | 'public';
publicRole: 'external' | null;
@@ -56,12 +35,12 @@ type NewDocPolicyRow = {
urlPreviewEnabled: boolean;
};
type NewDocGrantRow = {
type DocGrantRow = {
docId: string;
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
};
type NewWorkspaceRuntimeState = {
type WorkspaceRuntimeState = {
known: boolean;
stale: boolean;
readonly: boolean;
@@ -73,26 +52,16 @@ const CACHE_KEY = 'permission.context.cache';
function createPermissionRequestCache(): PermissionRequestCache {
return {
workspaceMember: new Map(),
workspacePolicy: new Map(),
workspaceRuntime: new Map(),
workspaceQuotaRuntime: new Map(),
docPolicies: new Map(),
docGrants: new Map(),
};
}
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
export type PermissionDocAction = DocAction | 'Doc.Preview';
function cacheKey(parts: readonly unknown[]) {
return parts.join('\0');
}
@Injectable()
export class PermissionContextLoader {
constructor(
private readonly models: Models,
private readonly db: PrismaClient,
private readonly cls?: ClsService
) {}
@@ -105,94 +74,17 @@ export class PermissionContextLoader {
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const [member, workspace, runtime, docPolicies, docGrants] =
const docIds = docs.map(doc => doc.docId);
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.workspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.workspacePolicy(input.workspaceId),
this.workspaceRuntime(input.workspaceId),
this.docPolicies(
input.workspaceId,
docs.map(doc => doc.docId)
),
this.docPolicies(input.workspaceId, docIds),
input.userId
? this.docGrants(
input.workspaceId,
docs.map(doc => doc.docId),
input.userId
)
: Promise.resolve([]),
]);
const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant]));
const workspaceSharingEnabled = workspace?.enableSharing ?? true;
return {
version: 1,
legacyCompatMode: true,
subject: {
userId: input.userId,
groupIds: [],
allowLocal: input.allowLocal,
},
runtime: {
known: runtime.known,
stale: runtime.stale,
readonly: runtime.readonly,
readonlyReason: runtime.readonlyReasons[0],
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
},
workspace: {
role: toNativeWorkspaceRole(member?.type),
memberState: toNativeMemberState(member?.status),
public: workspace?.public ?? false,
sharingEnabled: workspaceSharingEnabled,
urlPreviewEnabled: workspace?.enableUrlPreview ?? false,
local: !workspace,
},
workspaceActions: input.workspaceActions,
docs: docs.map((doc, index) => {
const policy = docPolicies[index];
const grant = docGrantMap.get(doc.docId);
return {
docId: doc.docId,
actions: doc.actions,
explicitUserRole: toNativeExplicitDocGrantRole(grant?.type),
groupGrants: [],
groupGrantsEnabled: false,
memberDefaultRole: toNativeDocRole(
policy?.workspace ?? DocRole.Manager
),
publicRole: policy?.external === null ? undefined : 'external',
visibility: policy?.external === null ? 'private' : 'public',
sharingEnabled: workspaceSharingEnabled,
previewEnabled: policy?.external !== null,
};
}),
};
}
async loadFromNewTables(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
workspaceActions?: PermissionWorkspaceAction[];
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
}): Promise<PermissionEvaluationInputV1> {
const docs = input.docs ?? [];
const docIds = docs.map(doc => doc.docId);
const [member, workspacePolicy, runtime, docPolicies, docGrants] =
await Promise.all([
input.userId
? this.newWorkspaceMember(input.workspaceId, input.userId)
: Promise.resolve(null),
this.newWorkspacePolicy(input.workspaceId),
this.newWorkspaceRuntime(input.workspaceId),
this.newDocPolicies(input.workspaceId, docIds),
input.userId
? this.newDocGrants(input.workspaceId, docIds, input.userId)
? this.docGrants(input.workspaceId, docIds, input.userId)
: Promise.resolve([]),
]);
const docPolicyMap = new Map(
@@ -293,56 +185,16 @@ export class PermissionContextLoader {
return promise;
}
private workspaceMember(workspaceId: string, userId: string) {
return this.memo(
this.cache.workspaceMember,
cacheKey([workspaceId, userId]),
() => this.models.workspaceUser.get(workspaceId, userId)
);
}
private workspacePolicy(workspaceId: string) {
return this.memo(this.cache.workspacePolicy, workspaceId, () =>
this.models.workspace.get(workspaceId)
);
}
private async workspaceRuntime(workspaceId: string) {
return this.memo(this.cache.workspaceRuntime, workspaceId, () =>
this.models.workspaceRuntimeState.get(workspaceId).then(async state => {
if (state.known && !state.stale) {
return state;
}
const quotaState = await this.newWorkspaceRuntime(workspaceId);
if (!quotaState.known) {
return state;
}
return {
workspaceId,
known: quotaState.known,
stale: quotaState.stale,
readonly: quotaState.readonly,
readonlyReasons: quotaState.readonlyReasons,
updatedAt: null,
lastReconciledAt: null,
staleAfter: quotaState.staleAfter,
};
})
);
}
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
this.cache.workspaceQuotaRuntime.delete(workspaceId);
}
private newWorkspaceRuntime(workspaceId: string) {
private workspaceRuntime(workspaceId: string) {
return this.memo(
this.cache.workspaceQuotaRuntime,
workspaceId,
async () => {
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
const rows = await this.db.$queryRaw<WorkspaceRuntimeState[]>`
SELECT
known,
stale,
@@ -374,26 +226,8 @@ export class PermissionContextLoader {
);
}
private docPolicies(workspaceId: string, docIds: string[]) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docPolicies,
cacheKey([workspaceId, ...uniqueDocIds]),
() => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds)
);
}
private docGrants(workspaceId: string, docIds: string[], userId: string) {
const uniqueDocIds = [...new Set(docIds)];
return this.memo(
this.cache.docGrants,
cacheKey([workspaceId, userId, ...uniqueDocIds]),
() => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId)
);
}
private async newWorkspaceMember(workspaceId: string, userId: string) {
const rows = await this.db.$queryRaw<NewWorkspaceMemberRow[]>`
private async workspaceMember(workspaceId: string, userId: string) {
const rows = await this.db.$queryRaw<WorkspaceMemberRow[]>`
SELECT role, state
FROM workspace_members
WHERE workspace_id = ${workspaceId}
@@ -404,8 +238,8 @@ export class PermissionContextLoader {
return rows[0] ?? null;
}
private async newWorkspacePolicy(workspaceId: string) {
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
private async workspacePolicy(workspaceId: string) {
const rows = await this.db.$queryRaw<WorkspacePolicyRow[]>`
SELECT
visibility,
sharing_enabled AS "sharingEnabled",
@@ -426,11 +260,11 @@ export class PermissionContextLoader {
return !!workspace;
}
private async newDocPolicies(workspaceId: string, docIds: string[]) {
private async docPolicies(workspaceId: string, docIds: string[]) {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocPolicyRow[]>`
return await this.db.$queryRaw<DocPolicyRow[]>`
SELECT
doc_id AS "docId",
visibility,
@@ -443,7 +277,7 @@ export class PermissionContextLoader {
`;
}
private async newDocGrants(
private async docGrants(
workspaceId: string,
docIds: string[],
userId: string
@@ -451,7 +285,7 @@ export class PermissionContextLoader {
if (docIds.length === 0) {
return [];
}
return await this.db.$queryRaw<NewDocGrantRow[]>`
return await this.db.$queryRaw<DocGrantRow[]>`
SELECT doc_id AS "docId", role
FROM doc_grants
WHERE workspace_id = ${workspaceId}
@@ -1,326 +0,0 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { metrics } from '../../base';
import type { PermissionEvaluationOutputV1 } from '../../native';
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { PermissionService } from './service';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [
'legacy_compat_delta',
'projection',
'rust_rule',
'loader',
'sql_predicate',
'legacy_api_role_mapping',
'preview_read_mapping',
'runtime_state',
'projection_or_loader',
] as const;
type PermissionShadowMismatchCategory =
(typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number];
@Injectable()
export class PermissionDiagnosticService {
constructor(
private readonly loader: PermissionContextLoader,
private readonly permission: PermissionService,
@Optional()
@Inject(PermissionSqlPredicateBuilder)
private readonly sqlPredicate = new PermissionSqlPredicateBuilder()
) {}
async shadowDocPermissions(input: {
userId?: string;
workspaceId: string;
docs: Array<{ docId: string; actions: PermissionDocAction[] }>;
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const [legacyOutput, newOutput] = await Promise.all([
this.loader.load(input).then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(input)
.then(input => this.permission.evaluate(input)),
]);
const legacy = legacyOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const current = newOutput.docs.map(doc => ({
docId: doc.docId,
...docLegacyBoundary(doc),
decisions: doc.decisions,
}));
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyDocShadowMismatch(legacy, current));
this.recordShadowMismatch('doc', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowWorkspacePermissions(input: {
userId?: string;
workspaceId: string;
actions: PermissionWorkspaceAction[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const legacyInput = {
userId: input.userId,
workspaceId: input.workspaceId,
workspaceActions: input.actions,
allowLocal: input.allowLocal,
};
const [legacyOutput, newOutput] = await Promise.all([
this.loader
.load(legacyInput)
.then(input => this.permission.evaluate(input)),
this.loader
.loadFromNewTables(legacyInput)
.then(input => this.permission.evaluate(input)),
]);
const legacy = {
...workspaceLegacyBoundary(legacyOutput.workspace),
decisions: legacyOutput.workspace.decisions,
};
const current = {
...workspaceLegacyBoundary(newOutput.workspace),
decisions: newOutput.workspace.decisions,
};
const matched = JSON.stringify(legacy) === JSON.stringify(current);
const mismatchType = matched
? null
: (input.expectedDeltaCategory ??
this.classifyShadowMismatch(legacyOutput, newOutput));
this.recordShadowMismatch('workspace', mismatchType);
return {
matched,
legacy,
current,
mismatchType,
};
}
async shadowSqlDocRead(input: {
userId: string;
workspaceId: string;
docs: Array<{ docId: string }>;
sqlReadableDocIds: string[];
allowLocal?: boolean;
expectedDeltaCategory?: PermissionShadowMismatchCategory;
}) {
const rustOutput = this.permission.evaluate(
await this.loader.loadFromNewTables({
userId: input.userId,
workspaceId: input.workspaceId,
docs: input.docs.map(doc => ({
docId: doc.docId,
actions: ['Doc.Read'],
})),
allowLocal: input.allowLocal,
})
);
const rustReadable = new Set(
rustOutput.docs
.filter(doc => doc.decisions[0]?.allowed)
.map(doc => doc.docId)
);
const sqlReadable = new Set(input.sqlReadableDocIds);
const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id));
const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id));
const mismatchType =
missingInSql.length || extraInSql.length
? (input.expectedDeltaCategory ?? 'sql_predicate')
: null;
this.recordShadowMismatch('sql_predicate', mismatchType);
return {
matched: mismatchType === null,
predicate: this.sqlPredicate.docReadableByNewTables({
workspaceId: input.workspaceId,
userId: input.userId,
action: 'Doc.Read',
}),
rustReadableDocIds: [...rustReadable],
sqlReadableDocIds: [...sqlReadable],
missingInSql,
extraInSql,
mismatchType,
};
}
async shadowPreviewDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowDocPermissions({
...input,
docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }],
});
const legacy = result.legacy[0];
const current = result.current[0];
const legacyPreviewAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const legacyReadAllowed = legacy?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const previewAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Preview'
)?.allowed;
const readAllowed = current?.decisions.find(
decision => decision.action === 'Doc.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
async shadowPreviewWorkspace(input: {
userId?: string;
workspaceId: string;
allowLocal?: boolean;
}) {
const result = await this.shadowWorkspacePermissions({
...input,
actions: ['Workspace.Preview', 'Workspace.Read'],
});
const legacyPreviewAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const legacyReadAllowed = result.legacy.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const previewAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Preview'
)?.allowed;
const readAllowed = result.current.decisions.find(
decision => decision.action === 'Workspace.Read'
)?.allowed;
const mismatchType =
legacyPreviewAllowed !== previewAllowed ||
(previewAllowed && readAllowed && !legacyReadAllowed)
? 'preview_read_mapping'
: result.mismatchType;
this.recordShadowMismatch('preview', mismatchType);
return {
...result,
matched: result.matched && mismatchType === null,
mismatchType,
};
}
private classifyShadowMismatch(
legacyOutput: PermissionEvaluationOutputV1,
newOutput: PermissionEvaluationOutputV1
) {
if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) {
return null;
}
const legacyRestrictions =
JSON.stringify(legacyOutput).includes('runtime_');
const newRestrictions = JSON.stringify(newOutput).includes('runtime_');
if (legacyRestrictions || newRestrictions) {
return 'runtime_state';
}
if (legacyOutput.docs.length !== newOutput.docs.length) {
return 'loader';
}
if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) {
return 'rust_rule';
}
return 'projection';
}
private classifyDocShadowMismatch(
legacy: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>,
current: Array<
ReturnType<typeof docLegacyBoundary> & { decisions: unknown }
>
) {
if (JSON.stringify(legacy) === JSON.stringify(current)) {
return null;
}
const legacyApi = legacy.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
const currentApi = current.map(doc => ({
effectiveRole: doc.effectiveRole,
legacyApiRole: doc.legacyApiRole,
resourceOwnerRole: doc.resourceOwnerRole,
}));
if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) {
return 'legacy_api_role_mapping';
}
if (
JSON.stringify(legacy).includes('runtime_') ||
JSON.stringify(current).includes('runtime_')
) {
return 'runtime_state';
}
if (legacy.length !== current.length) {
return 'loader';
}
const legacyDecisions = legacy.map(doc => doc.decisions);
const currentDecisions = current.map(doc => doc.decisions);
if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) {
return 'rust_rule';
}
return 'projection';
}
private recordShadowMismatch(
scope: string,
category: PermissionShadowMismatchCategory | null
) {
if (!category) {
return;
}
metrics.permission
.counter('shadow_mismatches', {
description: 'Permission shadow-read mismatch count',
})
.add(1, { scope, category });
}
}
@@ -1,14 +1,10 @@
import './config';
import { Module } from '@nestjs/common';
import { QuotaServiceModule } from '../quota/service.module';
import { AccessControllerBuilder } from './builder';
import { PermissionContextLoader } from './context-loader';
import { PermissionDiagnosticService } from './diagnostic';
import { EventsListener } from './event';
import { WorkspacePolicyService } from './policy';
import { PermissionProjectionChecker } from './projection-checker';
import { PermissionService } from './service';
import { PermissionSqlPredicateBuilder } from './sql-predicate';
@@ -18,18 +14,14 @@ import { PermissionSqlPredicateBuilder } from './sql-predicate';
AccessControllerBuilder,
EventsListener,
WorkspacePolicyService,
PermissionProjectionChecker,
PermissionSqlPredicateBuilder,
PermissionContextLoader,
PermissionDiagnosticService,
PermissionService,
],
exports: [
AccessControllerBuilder,
WorkspacePolicyService,
PermissionProjectionChecker,
PermissionSqlPredicateBuilder,
PermissionDiagnosticService,
PermissionService,
],
})
@@ -37,16 +29,11 @@ export class PermissionModule {}
export { AccessControllerBuilder as PermissionAccess } from './builder';
export { PermissionContextLoader } from './context-loader';
export {
PERMISSION_SHADOW_MISMATCH_CATEGORIES,
PermissionDiagnosticService,
} from './diagnostic';
export {
type DotToUnderline,
mapPermissionsToGraphqlPermissions,
} from './permission-map';
export { WorkspacePolicyService } from './policy';
export { PermissionProjectionChecker } from './projection-checker';
export { PermissionService } from './service';
export { PermissionSqlPredicateBuilder } from './sql-predicate';
export {
@@ -79,7 +79,6 @@ export class WorkspacePolicyService {
private async cleanupTeamPlanCanceled(workspaceId: string) {
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
}
@OnEvent('workspace.members.updated')
@@ -1,166 +0,0 @@
import { Injectable, Optional } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Models } from '../../models';
import {
PermissionContextLoader,
type PermissionDocAction,
type PermissionWorkspaceAction,
} from './context-loader';
import { PermissionService } from './service';
type ProjectionDecisionSample = {
category: string;
workspaceId: string;
docId: string | null;
userId: string | null;
workspaceActions: string[] | null;
docActions: string[] | null;
};
@Injectable()
export class PermissionProjectionChecker {
constructor(
private readonly db: PrismaClient,
private readonly models: Models,
@Optional()
private readonly loader?: PermissionContextLoader,
@Optional()
private readonly permission?: PermissionService
) {}
async checkLegacyProjection() {
const report =
await this.models.permissionProjection.checkLegacyProjection();
return {
...report,
oldNewDecisionMismatch: await this.checkOldNewLoaderDecisionMismatch(),
};
}
private async checkOldNewLoaderDecisionMismatch() {
const { loader, permission } = this;
if (!loader || !permission) {
return 0;
}
const samples = await this.db.$queryRaw<ProjectionDecisionSample[]>`
(
SELECT
'active_member_doc' AS category,
old_member.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
old_member.user_id AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
FROM workspace_user_permissions old_member
INNER JOIN workspace_pages old_doc
ON old_doc.workspace_id = old_member.workspace_id
WHERE old_member.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_member.workspace_id || ':' || old_doc.page_id || ':' || old_member.user_id)
LIMIT 80
)
UNION ALL
(
SELECT
'workspace_invitation' AS category,
old_member.workspace_id AS "workspaceId",
NULL::text AS "docId",
old_member.user_id AS "userId",
ARRAY['Workspace.Read']::text[] AS "workspaceActions",
NULL::text[] AS "docActions"
FROM workspace_user_permissions old_member
WHERE old_member.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old_member.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL
ORDER BY md5(old_member.workspace_id || ':' || old_member.user_id)
LIMIT 40
)
UNION ALL
(
SELECT
'public_doc_anonymous' AS category,
old_doc.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
NULL::text AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions"
FROM workspace_pages old_doc
WHERE old_doc.public
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
LIMIT 40
)
UNION ALL
(
SELECT
'workspace_url_preview_private_doc' AS category,
old_doc.workspace_id AS "workspaceId",
old_doc.page_id AS "docId",
NULL::text AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Preview', 'Doc.Read']::text[] AS "docActions"
FROM workspace_pages old_doc
INNER JOIN workspaces old_workspace
ON old_workspace.id = old_doc.workspace_id
WHERE old_workspace.enable_sharing
AND old_workspace.enable_url_preview
AND NOT old_doc.public
AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL
ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id)
LIMIT 40
)
UNION ALL
(
SELECT
'explicit_doc_grant' AS category,
old_grant.workspace_id AS "workspaceId",
old_grant.page_id AS "docId",
old_grant.user_id AS "userId",
NULL::text[] AS "workspaceActions",
ARRAY['Doc.Read', 'Doc.Update', 'Doc.Users.Manage', 'Doc.TransferOwner']::text[] AS "docActions"
FROM workspace_page_user_permissions old_grant
WHERE affine_permission_legacy_doc_role(old_grant.type) IS NOT NULL
ORDER BY md5(old_grant.workspace_id || ':' || old_grant.page_id || ':' || old_grant.user_id)
LIMIT 80
)
`;
let mismatches = 0;
for (const sample of samples) {
const input = {
userId: sample.userId ?? undefined,
workspaceId: sample.workspaceId,
workspaceActions: sample.workspaceActions as
| PermissionWorkspaceAction[]
| undefined,
docs:
sample.docId && sample.docActions
? [
{
docId: sample.docId,
actions: sample.docActions as PermissionDocAction[],
},
]
: undefined,
};
const [legacy, projection] = await Promise.all([
loader.load(input).then(input => permission.evaluate(input)),
loader
.loadFromNewTables(input)
.then(input => permission.evaluate(input)),
]);
if (
JSON.stringify(legacy.workspace) !==
JSON.stringify(projection.workspace) ||
JSON.stringify(legacy.docs) !== JSON.stringify(projection.docs)
) {
mismatches += 1;
}
}
return mismatches;
}
}
@@ -2,10 +2,8 @@ import { Inject, Injectable, Optional } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
Config,
DocActionDenied,
InternalServerError,
metrics,
SpaceAccessDenied,
} from '../../base';
import {
@@ -13,7 +11,6 @@ import {
type PermissionEvaluationInputV1,
type PermissionEvaluationOutputV1,
} from '../../native';
import { PermissionReadModel } from './config';
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
import {
PermissionContextLoader,
@@ -65,42 +62,16 @@ export class PermissionService {
@Inject(PermissionSqlPredicateBuilder)
private readonly sqlPredicate = new PermissionSqlPredicateBuilder(),
@Optional()
private readonly workspacePolicy?: WorkspacePolicyService,
@Optional()
private readonly config?: Config
private readonly workspacePolicy?: WorkspacePolicyService
) {}
readModel() {
return this.config?.permission.readModel ?? PermissionReadModel.Projection;
}
docReadableSqlPredicate(input: {
userId: string;
workspaceId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}) {
if (this.readModel() === PermissionReadModel.Projection) {
return this.sqlPredicate.docReadableByNewTablesSql(input);
}
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
}
fallbackDocReadableSqlPredicate(input: {
userId: string;
workspaceId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}) {
if (
this.readModel() === PermissionReadModel.Projection &&
(this.config?.permission.fallbackLegacyLoader ?? false)
) {
return this.sqlPredicate.docReadableByLegacyTablesSql(input);
}
return null;
return this.sqlPredicate.docReadableSql(input);
}
evaluate(input: PermissionEvaluationInputV1) {
@@ -264,39 +235,28 @@ export class PermissionService {
private async evaluateLoaded(
input: Parameters<PermissionContextLoader['load']>[0]
) {
if (this.readModel() === PermissionReadModel.Projection) {
try {
if (
this.needsFreshRuntimeState(input) &&
(await this.loader.workspaceExists(input.workspaceId))
) {
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId);
}
return this.evaluate(await this.loader.loadFromNewTables(input));
} catch (error) {
if (
input.allowLocal &&
error instanceof Error &&
error.message === 'Workspace owner not found'
) {
const loaded = await this.loader.loadFromNewTables(input);
if (loaded.workspace?.local) {
return this.evaluate(loaded);
}
}
if (!(this.config?.permission.fallbackLegacyLoader ?? false)) {
throw error;
}
metrics.permission
.counter('projection_loader_fallbacks', {
description: 'Permission projection loader fallback count',
})
.add(1);
try {
if (
this.needsFreshRuntimeState(input) &&
(await this.loader.workspaceExists(input.workspaceId))
) {
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId);
}
return this.evaluate(await this.loader.load(input));
} catch (error) {
if (
input.allowLocal &&
error instanceof Error &&
error.message === 'Workspace owner not found'
) {
const loaded = await this.loader.load(input);
if (loaded.workspace?.local) {
return this.evaluate(loaded);
}
}
throw error;
}
return this.evaluate(await this.loader.load(input));
}
private needsFreshRuntimeState(
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { permissionActionRoleMatrixV1 } from '../../native';
import { type DocAction, DocRole, WorkspaceRole } from './types';
import type { DocAction } from './types';
export type PermissionSqlPredicate = {
sql: string;
@@ -18,21 +18,6 @@ export class PermissionSqlPredicateBuilder {
workspace?: { roles?: Record<string, string[]> };
};
private readonly legacyDocRoleValues = new Map<string, DocRole>([
['external', DocRole.External],
['reader', DocRole.Reader],
['commenter', DocRole.Commenter],
['editor', DocRole.Editor],
['manager', DocRole.Manager],
['owner', DocRole.Owner],
]);
private readonly legacyWorkspaceRoleValues = new Map<string, number>([
['member', WorkspaceRole.Collaborator],
['admin', WorkspaceRole.Admin],
['owner', WorkspaceRole.Owner],
]);
private docRolesForAction(action: DocAction) {
return Object.entries(this.matrix.doc?.roles ?? {})
.filter(([, actions]) => actions.includes(action))
@@ -60,12 +45,6 @@ export class PermissionSqlPredicateBuilder {
return [...roles];
}
private legacyNonMemberDocGrantRolesForAction(action: DocAction) {
return this.nonMemberDocGrantRolesForAction(action)
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
}
private rawDocIdColumn(column: RawDocIdColumn = 'doc_id') {
switch (column) {
case 'doc_id':
@@ -76,144 +55,7 @@ export class PermissionSqlPredicateBuilder {
}
}
docReadableByLegacyTables(input: {
workspaceId: string;
userId: string;
action: DocAction;
docIdColumn?: RawDocIdColumn;
}): PermissionSqlPredicate {
const roles = this.docRolesForAction(input.action)
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
const grantRoles = roles.filter(role => role !== DocRole.External);
const nonMemberGrantRoles = this.legacyNonMemberDocGrantRolesForAction(
input.action
);
const legacyActiveMemberRoles = [
WorkspaceRole.Collaborator,
WorkspaceRole.Admin,
WorkspaceRole.Owner,
];
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
)
.map(role => this.legacyWorkspaceRoleValues.get(role))
.filter(role => role !== undefined);
const docIdColumn = this.rawDocIdColumn(input.docIdColumn);
return {
sql: [
`EXISTS (SELECT 1 FROM workspaces w`,
`LEFT JOIN workspace_pages wp ON wp.workspace_id = w.id`,
`AND wp.page_id = ${docIdColumn}`,
`LEFT JOIN workspace_user_permissions wup ON wup.workspace_id = w.id`,
`AND wup.user_id = ? AND wup.status = 'Accepted'`,
`LEFT JOIN workspace_page_user_permissions p ON p.workspace_id = w.id`,
`AND p.user_id = ? AND p.page_id = ${docIdColumn}`,
`WHERE w.id = ? AND (`,
`(wup.type = ANY(?::smallint[]) AND p.type = ANY(?::smallint[]))`,
`OR ((wup.id IS NULL OR wup.type <> ALL(?::smallint[])) AND w.enable_sharing AND p.type = ANY(?::smallint[]))`,
`OR wup.type = ANY(?::smallint[])`,
`OR (wup.type = ANY(?::smallint[]) AND (p.user_id IS NULL OR p.type IN (?, ?))`,
`AND COALESCE(wp."defaultRole", 30) = ANY(?::smallint[]))`,
`OR (w.enable_sharing AND wp.public AND ? = ANY(?::smallint[]))`,
`))`,
].join(' '),
params: [
input.userId,
input.userId,
input.workspaceId,
legacyActiveMemberRoles,
grantRoles,
legacyActiveMemberRoles,
nonMemberGrantRoles,
inheritedWorkspaceRoles,
legacyActiveMemberRoles,
DocRole.None,
DocRole.External,
grantRoles,
DocRole.External,
roles,
],
};
}
docReadableByLegacyTablesSql(input: {
workspaceId: string;
userId: string;
action: DocAction;
docIdColumn?: Prisma.Sql;
}): Prisma.Sql {
const docRoles = this.docRolesForAction(input.action);
const legacyDocRoles = docRoles
.map(role => this.legacyDocRoleValues.get(role))
.filter(role => role !== undefined);
const legacyGrantRoles = legacyDocRoles.filter(
role => role !== DocRole.External
);
const legacyNonMemberGrantRoles =
this.legacyNonMemberDocGrantRolesForAction(input.action);
const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction(
input.action
)
.map(role => this.legacyWorkspaceRoleValues.get(role))
.filter(role => role !== undefined);
const legacyActiveMemberRoles = [
WorkspaceRole.Collaborator,
WorkspaceRole.Admin,
WorkspaceRole.Owner,
];
const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id');
return Prisma.sql`
EXISTS (
SELECT 1
FROM workspaces w
LEFT JOIN workspace_pages wp
ON wp.workspace_id = w.id
AND wp.page_id = ${docIdColumn}
LEFT JOIN workspace_user_permissions wup
ON wup.workspace_id = w.id
AND wup.user_id = ${input.userId}
AND wup.status = 'Accepted'::"WorkspaceMemberStatus"
LEFT JOIN workspace_page_user_permissions p
ON p.workspace_id = w.id
AND p.page_id = ${docIdColumn}
AND p.user_id = ${input.userId}
WHERE w.id = ${input.workspaceId}
AND (
(
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
AND p.type = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
)
OR (
(
wup.id IS NULL
OR wup.type <> ALL(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
)
AND w.enable_sharing
AND p.type = ANY(${Prisma.sql`${legacyNonMemberGrantRoles}::smallint[]`})
)
OR wup.type = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::smallint[]`})
OR (
wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`})
AND (
p.user_id IS NULL
OR p.type IN (${DocRole.None}, ${DocRole.External})
)
AND COALESCE(wp."defaultRole", 30) = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`})
)
OR (
w.enable_sharing
AND wp.public
AND 0 = ANY(${Prisma.sql`${legacyDocRoles}::smallint[]`})
)
)
)
`;
}
docReadableByNewTables(input: {
docReadable(input: {
workspaceId: string;
userId?: string;
action: DocAction;
@@ -260,7 +102,7 @@ export class PermissionSqlPredicateBuilder {
};
}
docReadableByNewTablesSql(input: {
docReadableSql(input: {
workspaceId: string;
userId?: string;
action: DocAction;
@@ -60,57 +60,7 @@ test.after.always(async t => {
await t.context.module.close();
});
test('quota service ignores dirty legacy commercial features', async t => {
const { owner, workspace } = await createWorkspace(t);
await t.context.state.reconcileUserQuotaState(owner.id);
await t.context.state.reconcileWorkspaceQuotaState(workspace.id);
await t.context.models.userFeature.add(
owner.id,
'pro_plan_v1',
'dirty legacy feature'
);
await t.context.models.userFeature.add(
owner.id,
'unlimited_copilot',
'dirty legacy feature'
);
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'dirty legacy feature',
{
memberLimit: 100,
}
);
const userQuota = await t.context.quota.getUserQuota(owner.id);
const workspaceSeats = await t.context.quota.getWorkspaceSeatQuota(
workspace.id
);
t.is(userQuota.name, 'Free');
t.is(userQuota.copilotActionLimit, 10);
t.is(workspaceSeats.memberLimit, 3);
});
test('workspace quota state ignores dirty legacy readonly feature', async t => {
const { workspace } = await createWorkspace(t);
await t.context.models.workspaceFeature.add(
workspace.id,
'quota_exceeded_readonly_workspace_v1',
'dirty legacy feature'
);
const state = await t.context.state.reconcileWorkspaceQuotaState(
workspace.id
);
t.false(state.readonly);
t.deepEqual(state.readonlyReasons, []);
});
test('workspace quota state ignores dirty legacy permission rows', async t => {
test('workspace quota state uses current member rows', async t => {
const { workspace } = await createWorkspace(t);
const member = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
@@ -123,16 +73,11 @@ test('workspace quota state ignores dirty legacy permission rows', async t => {
status: WorkspaceMemberStatus.Accepted,
}
);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: member.id,
},
});
await t.context.db.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: member.id,
},
});
const state = await t.context.state.reconcileWorkspaceQuotaState(
@@ -194,16 +139,11 @@ test('quota state reconcile does not publish unchanged snapshots', async t => {
test('workspace quota state requires owner from new permission table', async t => {
const { owner, workspace } = await createWorkspace(t);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
await t.context.db.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
await t.throwsAsync(
@@ -217,16 +157,11 @@ test('user quota state aggregates owned storage from new permission table only',
await addBlob(t, workspace, 'blob', ONE_GB);
const first = await t.context.state.reconcileUserQuotaState(owner.id);
await t.context.db.$transaction(async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_projection.enabled', 'off', true)
`;
await tx.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
await t.context.db.workspaceMember.deleteMany({
where: {
workspaceId: workspace.id,
userId: owner.id,
},
});
const second = await t.context.state.reconcileUserQuotaState(owner.id);
@@ -315,19 +250,10 @@ test('ai entitlement is a capability overlay on free quota', async t => {
t.is(quota.copilotActionLimit, undefined);
});
test('workspace team status ignores dirty legacy feature', async t => {
test('workspace team status follows entitlement', async t => {
const { workspace } = await createWorkspace(t);
await t.context.state.reconcileWorkspaceQuotaState(workspace.id);
await t.context.models.workspaceFeature.add(
workspace.id,
'team_plan_v1',
'dirty legacy feature',
{
memberLimit: 100,
}
);
t.false(await t.context.models.workspace.isTeamWorkspace(workspace.id));
await t.context.entitlement.upsertFromCloudSubscription({
@@ -117,7 +117,13 @@ export class UserRealtimeProvider
emailVerified: current.emailVerified,
hasPassword: current.hasPassword,
avatarUrl: current.avatarUrl ?? null,
features: (await this.models.userFeature.list(userId))
features: (
await this.models.userFeature.list(
userId,
undefined,
Array.from(this.availableUserFeatures())
)
)
.filter(feature => this.availableUserFeatures().has(feature))
.map(feature => this.serializeFeature(feature)),
};
@@ -25,12 +25,7 @@ import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
import { PageInfo } from '../../../base/graphql/pagination';
import {
Feature,
Models,
WorkspaceFeatureName,
WorkspaceMemberStatus,
} from '../../../models';
import { Models, WorkspaceMemberStatus } from '../../../models';
import { Admin } from '../../common';
import { WorkspaceUserType } from '../../user';
import { TimeWindow } from './analytics-types';
@@ -118,9 +113,6 @@ class ListWorkspaceInput {
@Field(() => String, { nullable: true })
keyword?: string;
@Field(() => [Feature], { nullable: true })
features?: WorkspaceFeatureName[];
@Field(() => AdminWorkspaceSort, { nullable: true })
orderBy?: AdminWorkspaceSort;
@@ -397,9 +389,6 @@ export class AdminWorkspace {
@Field()
enableDocEmbedding!: boolean;
@Field(() => [Feature])
features!: WorkspaceFeatureName[];
@Field(() => WorkspaceUserType, { nullable: true })
owner?: WorkspaceUserType | null;
@@ -469,7 +458,6 @@ export class AdminWorkspaceResolver {
first: filter.first,
skip: filter.skip,
keyword: filter.keyword,
features: filter.features,
order: this.mapSort(filter.orderBy),
flags: {
public: filter.public ?? undefined,
@@ -491,7 +479,6 @@ export class AdminWorkspaceResolver {
this.assertCloudOnly();
const total = await this.models.workspace.adminCountWorkspaces({
keyword: filter.keyword,
features: filter.features,
flags: {
public: filter.public ?? undefined,
enableAi: filter.enableAi ?? undefined,
@@ -415,24 +415,11 @@ export class WorkspaceDocResolver {
action: 'Doc.Read',
docIdColumn: Prisma.raw('"workspace_pages"."page_id"'),
});
const fallbackPredicate = this.permission.fallbackDocReadableSqlPredicate({
userId: me.id,
workspaceId: workspace.id,
action: 'Doc.Read',
docIdColumn: Prisma.raw('"workspace_pages"."page_id"'),
});
const [count, rows] = await this.models.doc
.paginateDocInfoByUpdatedAt(workspace.id, pagination, predicate)
.catch(error => {
if (!fallbackPredicate) {
throw error;
}
return this.models.doc.paginateDocInfoByUpdatedAt(
workspace.id,
pagination,
fallbackPredicate
);
});
const [count, rows] = await this.models.doc.paginateDocInfoByUpdatedAt(
workspace.id,
pagination,
predicate
);
return paginate(rows, 'updatedAt', pagination, count);
}
@@ -9,11 +9,7 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { WorkspaceMemberSource, WorkspaceMemberStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import {
@@ -41,7 +37,7 @@ import {
UserNotFound,
} from '../../../base';
import type { GraphqlContext } from '../../../base/graphql';
import { Models } from '../../../models';
import { Models, type WorkspaceUserCompat } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { BackendRuntimeProvider } from '../../backend-runtime';
import { containsUrlOrDomain } from '../../content-policy';
@@ -788,7 +784,7 @@ export class WorkspaceMemberResolver {
return true;
}
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
private async acceptInvitationByEmail(role: WorkspaceUserCompat) {
await this.assertWorkspaceAcceptsMemberChange(role.workspaceId);
const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true);
@@ -283,15 +283,10 @@ export class WorkspaceStatsJob {
),
public_page_stats AS (
SELECT workspace_id, COUNT(*) AS public_page_count
FROM workspace_pages
WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets)
GROUP BY workspace_id
),
feature_stats AS (
SELECT workspace_id,
ARRAY_AGG(DISTINCT name ORDER BY name) FILTER (WHERE activated) AS features
FROM workspace_features
WHERE workspace_id IN (SELECT workspace_id FROM targets)
FROM doc_access_policies
WHERE visibility = 'public'
AND public_role = 'external'
AND workspace_id IN (SELECT workspace_id FROM targets)
GROUP BY workspace_id
),
aggregated AS (
@@ -301,14 +296,12 @@ export class WorkspaceStatsJob {
COALESCE(bs.blob_count, 0) AS blob_count,
COALESCE(bs.blob_size, 0) AS blob_size,
COALESCE(ms.member_count, 0) AS member_count,
COALESCE(pp.public_page_count, 0) AS public_page_count,
COALESCE(fs.features, ARRAY[]::text[]) AS features
COALESCE(pp.public_page_count, 0) AS public_page_count
FROM targets t
LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id
LEFT JOIN blob_stats bs ON bs.workspace_id = t.workspace_id
LEFT JOIN member_stats ms ON ms.workspace_id = t.workspace_id
LEFT JOIN public_page_stats pp ON pp.workspace_id = t.workspace_id
LEFT JOIN feature_stats fs ON fs.workspace_id = t.workspace_id
)
INSERT INTO workspace_admin_stats (
workspace_id,
@@ -318,7 +311,6 @@ export class WorkspaceStatsJob {
blob_size,
member_count,
public_page_count,
features,
updated_at
)
SELECT
@@ -329,7 +321,6 @@ export class WorkspaceStatsJob {
blob_size,
member_count,
public_page_count,
features,
NOW()
FROM aggregated
ON CONFLICT (workspace_id) DO UPDATE SET
@@ -339,7 +330,6 @@ export class WorkspaceStatsJob {
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
}
@@ -30,13 +30,13 @@ test.after.always(async t => {
test('permission backfill repairs ownerless workspaces before runtime state projection', async t => {
const emptyWorkspace = await t.context.db.workspace.create({
data: { public: false },
data: { accessPolicy: { create: {} } },
});
const member = await t.context.models.user.create({
email: 'member@affine.pro',
});
const memberWorkspace = await t.context.db.workspace.create({
data: { public: false },
data: { accessPolicy: { create: {} } },
});
await t.context.db.workspaceMember.create({
data: {
@@ -80,13 +80,4 @@ test('permission backfill repairs ownerless workspaces before runtime state proj
}),
{ role: 'owner' }
);
t.like(
await t.context.db.workspaceUserRole.findFirstOrThrow({
where: {
workspaceId: memberWorkspace.id,
userId: member.id,
},
}),
{ type: 99 }
);
});
@@ -1,12 +1,8 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { Models } from '../../models';
export class BackfillPermissionProjection1765500000000 {
static async up(db: PrismaClient, ref: ModuleRef) {
const models = ref.get(Models, { strict: false });
await models.permissionProjection.backfillLegacyProjection();
static async up(db: PrismaClient, _ref: ModuleRef) {
await ensureWorkspaceAdminStatsDirtyTriggerGuard(db);
await repairOwnerlessWorkspaces(db);
await backfillUnknownQuotaRuntimeStates(db);
@@ -134,29 +130,6 @@ async function backfillUnknownQuotaRuntimeStates(db: PrismaClient) {
stale = true,
updated_at = now()
`;
await db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
SELECT
workspace_id,
false,
false,
ARRAY[]::text[],
NULL,
NULL,
now()
FROM effective_workspace_quota_states
ON CONFLICT (workspace_id)
DO NOTHING
`;
}
async function repairOwnerlessWorkspaces(db: PrismaClient) {
@@ -1,15 +1,8 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { LegacyEntitlementProjectionService } from '../../core/entitlement';
export class BackfillEntitlementProjection1765600000000 {
static async up(_db: PrismaClient, ref: ModuleRef) {
const projection = ref.get(LegacyEntitlementProjectionService, {
strict: false,
});
await projection.shadowBackfillEntitlementsAndQuotaStates();
}
static async up(_db: PrismaClient, _ref: ModuleRef) {}
static async down(_db: PrismaClient) {}
}
@@ -19,13 +19,4 @@ export class BaseModel {
// See https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#using-the-injecttransaction-decorator
return this.txHost.tx;
}
protected async withPermissionProjectionMetric<T>(operation: Promise<T>) {
try {
return await operation;
} catch (err) {
this.models.permissionProjection.recordTriggerErrorMetric(err);
throw err;
}
}
}
@@ -1,39 +1,18 @@
import { z } from 'zod';
import { OneDay, OneGB, OneMB } from '../../base';
export interface UserQuota {
name: string;
blobLimit: number;
businessBlobLimit?: number;
storageQuota: number;
historyPeriod: number;
memberLimit: number;
copilotActionLimit?: number;
}
const UserPlanQuotaConfig = z.object({
// quota name
name: z.string(),
// single blob limit
blobLimit: z.number(),
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: z.number().optional(),
// total blob limit
storageQuota: z.number(),
// history period of validity
historyPeriod: z.number(),
// member limit
memberLimit: z.number(),
// copilot action limit
copilotActionLimit: z.number().optional(),
});
export type UserQuota = z.infer<typeof UserPlanQuotaConfig>;
const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({
// seat quota
seatQuota: z.number(),
}).omit({
copilotActionLimit: true,
});
export type WorkspaceQuota = z.infer<typeof WorkspaceQuotaConfig>;
const EMPTY_CONFIG = z.object({});
export interface WorkspaceQuota extends UserQuota {
seatQuota: number;
}
export enum FeatureType {
Feature,
@@ -41,120 +20,25 @@ export enum FeatureType {
}
export enum Feature {
// user
Admin = 'administrator',
UnlimitedCopilot = 'unlimited_copilot',
FreePlan = 'free_plan_v1',
ProPlan = 'pro_plan_v1',
LifetimeProPlan = 'lifetime_pro_plan_v1',
// workspace
UnlimitedWorkspace = 'unlimited_workspace',
TeamPlan = 'team_plan_v1',
QuotaExceededReadonlyWorkspace = 'quota_exceeded_readonly_workspace_v1',
}
// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
export const FeaturesShapes = {
unlimited_workspace: EMPTY_CONFIG,
unlimited_copilot: EMPTY_CONFIG,
administrator: EMPTY_CONFIG,
free_plan_v1: UserPlanQuotaConfig,
pro_plan_v1: UserPlanQuotaConfig,
lifetime_pro_plan_v1: UserPlanQuotaConfig,
team_plan_v1: WorkspaceQuotaConfig,
quota_exceeded_readonly_workspace_v1: EMPTY_CONFIG,
} satisfies Record<Feature, z.ZodObject<any>>;
administrator: z.object({}),
};
export type UserFeatureName = keyof Pick<
typeof FeaturesShapes,
| 'unlimited_copilot'
| 'administrator'
| 'free_plan_v1'
| 'pro_plan_v1'
| 'lifetime_pro_plan_v1'
>;
export type WorkspaceFeatureName = keyof Pick<
typeof FeaturesShapes,
| 'unlimited_workspace'
| 'team_plan_v1'
| 'quota_exceeded_readonly_workspace_v1'
>;
export type FeatureName = UserFeatureName | WorkspaceFeatureName;
export type UserFeatureName = 'administrator';
export type FeatureName = UserFeatureName;
export type FeatureConfig<T extends FeatureName> = z.infer<
(typeof FeaturesShapes)[T]
>;
const FreeFeature = {
type: FeatureType.Quota,
configs: {
// quota name
name: 'Free',
blobLimit: 10 * OneMB,
businessBlobLimit: 100 * OneMB,
storageQuota: 10 * OneGB,
historyPeriod: 7 * OneDay,
memberLimit: 3,
copilotActionLimit: 10,
export const FeatureConfigs = {
administrator: {
type: FeatureType.Feature,
configs: {},
},
} as const;
const ProFeature = {
type: FeatureType.Quota,
configs: {
name: 'Pro',
blobLimit: 100 * OneMB,
storageQuota: 100 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
} as const;
const LifetimeProFeature = {
type: FeatureType.Quota,
configs: {
name: 'Lifetime Pro',
blobLimit: 100 * OneMB,
storageQuota: 1024 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
} as const;
const TeamFeature = {
type: FeatureType.Quota,
configs: {
name: 'Team Workspace',
blobLimit: 500 * OneMB,
storageQuota: 100 * OneGB,
seatQuota: 20 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 1,
},
} as const;
const EmptyFeature = {
type: FeatureType.Feature,
configs: {},
} as const;
export const FeatureConfigs: {
[K in FeatureName]: {
type: FeatureType;
configs: FeatureConfig<K>;
};
} = {
get free_plan_v1() {
return env.selfhosted ? ProFeature : FreeFeature;
},
pro_plan_v1: ProFeature,
lifetime_pro_plan_v1: LifetimeProFeature,
team_plan_v1: TeamFeature,
unlimited_workspace: EmptyFeature,
quota_exceeded_readonly_workspace_v1: EmptyFeature,
unlimited_copilot: EmptyFeature,
administrator: EmptyFeature,
};
} satisfies Record<
FeatureName,
{ type: FeatureType; configs: FeatureConfig<FeatureName> }
>;
+11 -11
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
import { DocGrant, WorkspaceDocUserRole } from '@prisma/client';
import { DocGrant } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
import { BaseModel } from './base';
@@ -82,20 +82,12 @@ export class DocUserModel extends BaseModel {
@Transactional()
async deleteByUserId(userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.docGrant.deleteMany({
where: {
principalType: 'user',
principalId: userId,
},
});
await this.withPermissionProjectionMetric(
this.db.workspaceDocUserRole.deleteMany({
where: {
userId,
},
})
);
}
async getOwner(workspaceId: string, docId: string) {
@@ -160,7 +152,7 @@ export class DocUserModel extends BaseModel {
workspaceId: string,
docId: string,
pagination: PaginationInput
): Promise<[WorkspaceDocUserRole[], number]> {
): Promise<[DocUserCompat[], number]> {
const [grants, total] = await Promise.all([
this.db.docGrant.findMany({
where: {
@@ -184,7 +176,7 @@ export class DocUserModel extends BaseModel {
return [grants.map(grant => this.docGrantToCompat(grant)), total];
}
private docGrantToCompat(grant: DocGrant): WorkspaceDocUserRole {
private docGrantToCompat(grant: DocGrant): DocUserCompat {
return {
workspaceId: grant.workspaceId,
docId: grant.docId,
@@ -194,3 +186,11 @@ export class DocUserModel extends BaseModel {
};
}
}
type DocUserCompat = {
workspaceId: string;
docId: string;
userId: string;
type: DocRole;
createdAt: Date;
};
+142 -74
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { Update } from '@prisma/client';
import type { Update, WorkspaceDoc } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { EventBus, PaginationInput } from '../base';
@@ -33,7 +33,10 @@ declare global {
export type DocMetaUpsertInput = Omit<
Prisma.WorkspaceDocUncheckedCreateInput,
'workspaceId' | 'docId'
>;
> & {
public?: boolean;
defaultRole?: DocRole;
};
/**
* Workspace Doc Model
@@ -50,6 +53,24 @@ export class DocModel extends BaseModel {
super();
}
private docRoleFromPolicy(role: string | null | undefined) {
switch (role) {
case 'none':
return DocRole.None;
case 'reader':
return DocRole.Reader;
case 'commenter':
return DocRole.Commenter;
case 'editor':
return DocRole.Editor;
case 'owner':
return DocRole.Owner;
case 'manager':
default:
return DocRole.Manager;
}
}
// #region Update
private updateToDocRecord(row: Update): Doc {
@@ -365,56 +386,64 @@ export class DocModel extends BaseModel {
docId: string,
data?: DocMetaUpsertInput
) {
if (
data &&
('public' in data || 'defaultRole' in data || 'publishedAt' in data)
) {
const { public: isPublic, defaultRole, ...meta } = data ?? {};
if (data && ('public' in data || 'defaultRole' in data)) {
await this.models.docAccessPolicy.upsert(workspaceId, docId, {
public: data.public,
defaultRole: data.defaultRole,
publishedAt:
typeof data.publishedAt === 'string'
? new Date(data.publishedAt)
: data.publishedAt,
public: isPublic,
defaultRole,
});
}
const doc = await this.withPermissionProjectionMetric(
this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId,
docId,
},
},
update: {
...data,
},
create: {
...data,
const doc = await this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId,
docId,
},
})
);
},
update: {
...meta,
},
create: {
...meta,
workspaceId,
docId,
},
});
this.event.emit('doc.updated', {
workspaceId,
docId,
});
return doc;
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
});
return {
...doc,
public: policy?.visibility === 'public',
defaultRole: this.docRoleFromPolicy(policy?.memberDefaultRole),
};
}
/**
* Get the doc meta.
*/
async getMeta(
workspaceId: string,
docId: string
): Promise<(WorkspaceDoc & { public: boolean; defaultRole: DocRole }) | null>;
async getMeta<Select extends Prisma.WorkspaceDocSelect>(
workspaceId: string,
docId: string,
options?: {
select?: Select;
options: {
select: Select;
}
) {
return (await this.db.workspaceDoc.findUnique({
): Promise<Prisma.WorkspaceDocGetPayload<{ select: Select }> | null>;
async getMeta(
workspaceId: string,
docId: string,
options?: { select: Prisma.WorkspaceDocSelect }
): Promise<unknown> {
const doc = await this.db.workspaceDoc.findUnique({
where: {
workspaceId_docId: {
workspaceId,
@@ -422,7 +451,18 @@ export class DocModel extends BaseModel {
},
},
select: options?.select,
})) as Prisma.WorkspaceDocGetPayload<{ select: Select }> | null;
});
if (!doc || options?.select) {
return doc;
}
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
});
return {
...doc,
public: policy?.visibility === 'public',
defaultRole: this.docRoleFromPolicy(policy?.memberDefaultRole),
};
}
async setDefaultRole(workspaceId: string, docId: string, role: DocRole) {
@@ -432,22 +472,15 @@ export class DocModel extends BaseModel {
}
async findDefaultRoles(workspaceId: string, docIds: string[]) {
const docs = await this.findMetas(
docIds.map(docId => ({
workspaceId,
docId,
})),
{
select: {
defaultRole: true,
public: true,
},
}
);
const policies = await this.db.docAccessPolicy.findMany({
where: { workspaceId, docId: { in: docIds } },
});
const byDocId = new Map(policies.map(policy => [policy.docId, policy]));
return docs.map(doc => ({
external: doc?.public ? DocRole.External : null,
workspace: doc?.defaultRole ?? DocRole.Manager,
return docIds.map(docId => ({
external:
byDocId.get(docId)?.publicRole === 'external' ? DocRole.External : null,
workspace: this.docRoleFromPolicy(byDocId.get(docId)?.memberDefaultRole),
}));
}
@@ -509,21 +542,29 @@ export class DocModel extends BaseModel {
* Find the workspace public doc metas.
*/
async findPublics(workspaceId: string, order: 'asc' | 'desc' = 'asc') {
return await this.db.workspaceDoc.findMany({
where: { workspaceId, public: true },
const policies = await this.db.docAccessPolicy.findMany({
where: { workspaceId, visibility: 'public', publicRole: 'external' },
});
const byDocId = new Map(policies.map(policy => [policy.docId, policy]));
const metas = await this.db.workspaceDoc.findMany({
where: { workspaceId, docId: { in: [...byDocId.keys()] } },
orderBy: { publishedAt: order },
});
return metas.map(meta => ({
...meta,
public: true,
defaultRole: this.docRoleFromPolicy(
byDocId.get(meta.docId)?.memberDefaultRole
),
}));
}
/**
* Get the workspace public docs count.
*/
async getPublicsCount(workspaceId: string) {
return await this.db.workspaceDoc.count({
where: {
workspaceId,
public: true,
},
return await this.db.docAccessPolicy.count({
where: { workspaceId, visibility: 'public', publicRole: 'external' },
});
}
@@ -552,8 +593,7 @@ export class DocModel extends BaseModel {
@Transactional()
async unpublish(workspaceId: string, docId: string) {
const docMeta = await this.getMeta(workspaceId, docId);
if (!docMeta?.public) {
if (!(await this.isPublic(workspaceId, docId))) {
throw new DocIsNotPublic();
}
@@ -567,12 +607,11 @@ export class DocModel extends BaseModel {
* Check if the doc is public.
*/
async isPublic(workspaceId: string, docId: string) {
const docMeta = await this.getMeta(workspaceId, docId, {
select: {
public: true,
},
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
select: { visibility: true, publicRole: true },
});
return docMeta?.public ?? false;
return policy?.visibility === 'public' && policy.publicRole === 'external';
}
async getDocInfo(workspaceId: string, docId: string) {
@@ -582,7 +621,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
title: string | null;
summary: string | null;
createdAt: Date;
@@ -595,8 +634,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"workspace_pages"."title" as "title",
"workspace_pages"."summary" as "summary",
"snapshots"."created_at" as "createdAt",
@@ -607,13 +646,24 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND "workspace_pages"."page_id" = ${docId}
LIMIT 1;
`;
return rows.at(0) ?? null;
const row = rows.at(0);
if (!row) {
return null;
}
const { defaultRolePolicy, ...doc } = row;
return {
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
};
}
async paginateDocInfo(workspaceId: string, pagination: PaginationInput) {
@@ -633,7 +683,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
createdAt: Date;
updatedAt: Date;
creatorId?: string;
@@ -644,8 +694,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"snapshots"."created_at" as "createdAt",
"snapshots"."updated_at" as "updatedAt",
"snapshots"."created_by" as "creatorId",
@@ -654,6 +704,9 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
${after}
@@ -663,7 +716,13 @@ export class DocModel extends BaseModel {
OFFSET ${pagination.offset}
`;
return [count, rows] as const;
return [
count,
rows.map(({ defaultRolePolicy, ...doc }) => ({
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
})),
] as const;
}
async paginateDocInfoByUpdatedAt(
@@ -690,7 +749,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
title: string | null;
createdAt: Date;
updatedAt: Date;
@@ -702,8 +761,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"workspace_pages"."title" as "title",
"snapshots"."created_at" as "createdAt",
"snapshots"."updated_at" as "updatedAt",
@@ -713,6 +772,9 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND ${readablePredicate}
@@ -723,7 +785,13 @@ export class DocModel extends BaseModel {
OFFSET ${pagination.offset}
`;
return [count, rows] as const;
return [
count,
rows.map(({ defaultRolePolicy, ...doc }) => ({
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
})),
] as const;
}
async findEmptySummaryDocIds(workspaceId: string) {
+1 -9
View File
@@ -32,7 +32,6 @@ import { MagicLinkOtpModel } from './magic-link-otp';
import { MailDeliveryModel } from './mail-delivery';
import { McpCredentialModel } from './mcp-credential';
import { NotificationModel } from './notification';
import { PermissionProjectionModel } from './permission-projection';
import {
DocAccessPolicyModel,
DocGrantModel,
@@ -50,8 +49,6 @@ import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
import { WorkspaceAnalyticsModel } from './workspace-analytics';
import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceRuntimeStateModel } from './workspace-runtime-state';
import { WorkspaceUserModel } from './workspace-user';
const MODELS = {
@@ -64,15 +61,12 @@ const MODELS = {
feature: FeatureModel,
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
workspaceFeature: WorkspaceFeatureModel,
workspaceRuntimeState: WorkspaceRuntimeStateModel,
doc: DocModel,
userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
history: HistoryModel,
notification: NotificationModel,
permissionProjection: PermissionProjectionModel,
workspaceMember: WorkspaceMemberModel,
workspaceInvitation: WorkspaceInvitationModel,
workspaceAccessPolicy: WorkspaceAccessPolicyModel,
@@ -172,7 +166,6 @@ export * from './history';
export * from './magic-link-otp';
export * from './mail-delivery';
export * from './notification';
export * from './permission-projection';
export * from './permission-write';
export * from './session';
export * from './user';
@@ -183,6 +176,5 @@ export * from './verification-token';
export * from './workspace';
export * from './workspace-analytics';
export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-runtime-state';
export * from './workspace-user';
export type { WorkspaceUserCompat } from './workspace-user-compat';
@@ -1,581 +0,0 @@
import { Injectable, Optional } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { metrics } from '../base';
import { BaseModel } from './base';
type CountRow = { count: bigint };
type ProjectionIssueRow = {
category: string;
count: bigint;
};
type ProjectionBackfillDb = {
$transaction: (
callback: (
tx: Pick<Prisma.TransactionClient, '$executeRaw'>
) => Promise<void>,
options?: { timeout?: number }
) => Promise<void>;
};
export type PermissionProjectionCheckReport = {
oldWorkspacePolicyMismatch: number;
oldAcceptedMemberMismatch: number;
extraProjectedMember: number;
oldInvitationMismatch: number;
extraProjectedInvitation: number;
oldDocGrantMismatch: number;
extraProjectedDocGrant: number;
oldDocPolicyMismatch: number;
extraProjectedDocPolicy: number;
runtimeStateMissing: number;
runtimeStateMismatch: number;
ownerConflict: number;
oldNewDecisionMismatch: number;
invalidLegacyRows: Record<string, number>;
};
export const PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES = [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
] as const;
export function permissionProjectionTriggerErrorCategory(error: unknown) {
const message =
error instanceof Error ? error.message : String(error ?? 'unknown');
const match = message.match(/permission_projection_error:([^:]+):/);
const category = match?.[1];
if (!category) {
return null;
}
return PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES.includes(
category as (typeof PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES)[number]
)
? category
: 'unknown';
}
async function count(first: Promise<CountRow[]>) {
const rows = await first;
return Number(rows[0]?.count ?? 0);
}
@Injectable()
export class PermissionProjectionModel extends BaseModel {
constructor(@Optional() private readonly prisma?: PrismaClient) {
super();
}
async backfillLegacyProjection() {
const db = (this.prisma ?? this.db) as unknown as ProjectionBackfillDb;
await db.$transaction(
async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'legacy', true)
`;
await tx.$executeRaw`
DELETE FROM workspace_members projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_invitations projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_grants projected
WHERE projected.principal_type = 'user'
AND NOT EXISTS (
SELECT 1
FROM workspace_page_user_permissions old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
AND affine_permission_legacy_doc_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspace_pages old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspaces old
WHERE old.id = projected.workspace_id
)
`;
await tx.$executeRaw`
INSERT INTO workspace_access_policies (
workspace_id,
visibility,
sharing_enabled,
url_preview_enabled,
updated_at
)
SELECT
id,
CASE WHEN public THEN 'public' ELSE 'private' END,
enable_sharing,
enable_url_preview,
now()
FROM workspaces
ON CONFLICT (workspace_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
sharing_enabled = EXCLUDED.sharing_enabled,
url_preview_enabled = EXCLUDED.url_preview_enabled,
updated_at = now()
`;
await tx.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
affine_permission_legacy_workspace_role(type),
'active',
CASE source
WHEN 'Email'::"WorkspaceMemberSource" THEN 'email'
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'legacy'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
inviter_id,
CASE WHEN affine_permission_legacy_workspace_role(type) = 'admin' THEN 'admin' ELSE 'member' END,
affine_permission_workspace_invitation_state(status),
CASE source
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'email'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(status) IS NOT NULL
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
invitee_user_id = EXCLUDED.invitee_user_id,
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
published_at,
updated_at
)
SELECT
workspace_id,
page_id,
CASE WHEN public THEN 'public' ELSE 'private' END,
CASE WHEN public THEN 'external' ELSE NULL END,
affine_permission_legacy_default_doc_role("defaultRole"),
published_at,
now()
FROM workspace_pages
WHERE affine_permission_legacy_default_doc_role("defaultRole") IS NOT NULL
ON CONFLICT (workspace_id, doc_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
public_role = EXCLUDED.public_role,
member_default_role = EXCLUDED.member_default_role,
published_at = EXCLUDED.published_at,
updated_at = now()
`;
await tx.$executeRaw`
WITH legacy_doc_grants AS (
SELECT
workspace_id,
page_id,
user_id,
type,
created_at,
row_number() OVER (
PARTITION BY workspace_id, page_id, affine_permission_legacy_doc_role(type)
ORDER BY created_at ASC, user_id ASC
) AS role_rank
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
)
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
legacy_workspace_id,
legacy_doc_id,
legacy_user_id,
created_at,
updated_at
)
SELECT
workspace_id,
page_id,
'user',
user_id,
affine_permission_legacy_doc_role(type),
workspace_id,
page_id,
user_id,
created_at,
now()
FROM legacy_doc_grants
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
AND (
affine_permission_legacy_doc_role(type) <> 'owner'
OR role_rank = 1
)
ON CONFLICT (workspace_id, doc_id, principal_type, principal_id)
DO UPDATE SET
role = EXCLUDED.role,
updated_at = now()
`;
},
{ timeout: 10 * 60 * 1000 }
);
}
recordTriggerErrorMetric(error: unknown) {
const category = permissionProjectionTriggerErrorCategory(error);
if (!category) {
return null;
}
metrics.permission
.counter('projection_trigger_errors', {
description: 'Permission projection trigger error count',
})
.add(1, { category });
return category;
}
async checkLegacyProjection(): Promise<PermissionProjectionCheckReport> {
const [
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
ownerConflict,
invalidLegacyRows,
] = await Promise.all([
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspaces old
LEFT JOIN workspace_access_policies projected
ON projected.workspace_id = old.id
WHERE projected.workspace_id IS NULL
OR projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END
OR projected.sharing_enabled <> old.enable_sharing
OR projected.url_preview_enabled <> old.enable_url_preview
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_members projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.user_id = old.user_id
AND projected.state = 'active'
)
WHERE old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.user_id <> old.user_id OR
projected.role <> affine_permission_legacy_workspace_role(old.type) OR
projected.state <> 'active'
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_members projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.user_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
)
WHERE
projected.state = 'active'
AND (
old.id IS NULL OR
old.status <> 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_invitations projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.invitee_user_id = old.user_id
)
WHERE old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.invitee_user_id <> old.user_id OR
projected.requested_role <> CASE WHEN affine_permission_legacy_workspace_role(old.type) = 'admin' THEN 'admin' ELSE 'member' END OR
projected.status <> affine_permission_workspace_invitation_state(old.status)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_invitations projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.invitee_user_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
)
WHERE projected.invitee_user_id IS NOT NULL
AND (
old.id IS NULL OR
old.status = 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_workspace_invitation_state(old.status) IS NULL OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_page_user_permissions old
LEFT JOIN doc_grants projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
AND projected.principal_type = 'user'
AND projected.principal_id = old.user_id
WHERE affine_permission_legacy_doc_role(old.type) IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.role <> affine_permission_legacy_doc_role(old.type)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_grants projected
LEFT JOIN workspace_page_user_permissions old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
WHERE projected.principal_type = 'user'
AND (
old.workspace_id IS NULL OR
affine_permission_legacy_doc_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_pages old
LEFT JOIN doc_access_policies projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
WHERE affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END OR
projected.public_role IS DISTINCT FROM CASE WHEN old.public THEN 'external' ELSE NULL END OR
projected.member_default_role IS DISTINCT FROM affine_permission_legacy_default_doc_role(old."defaultRole")
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_access_policies projected
LEFT JOIN workspace_pages old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
WHERE old.workspace_id IS NULL
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COALESCE(SUM(conflicts.count - 1), 0)::bigint AS count
FROM (
SELECT workspace_id, COUNT(*)::bigint AS count
FROM workspace_members
WHERE state = 'active'
AND role = 'owner'
GROUP BY workspace_id
HAVING COUNT(*) > 1
UNION ALL
SELECT workspace_id || ':' || doc_id AS workspace_id, COUNT(*)::bigint AS count
FROM doc_grants
WHERE principal_type = 'user'
AND role = 'owner'
GROUP BY workspace_id, doc_id
HAVING COUNT(*) > 1
) conflicts
`),
this.db.$queryRaw<ProjectionIssueRow[]>`
SELECT category, COUNT(*)::bigint AS count
FROM (
SELECT 'unknown_workspace_role' AS category
FROM workspace_user_permissions
WHERE affine_permission_legacy_workspace_role(type) IS NULL
AND type <> -99
UNION ALL
SELECT 'unknown_doc_role' AS category
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NULL
AND type NOT IN (0, -32768)
UNION ALL
SELECT 'legacy_doc_external_row' AS category
FROM workspace_page_user_permissions
WHERE type = 0
UNION ALL
SELECT 'legacy_doc_none_row' AS category
FROM workspace_page_user_permissions
WHERE type = -32768
UNION ALL
SELECT 'doc_default_owner' AS category
FROM workspace_pages
WHERE "defaultRole" = 99
) issues
GROUP BY category
`,
]);
return {
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict,
oldNewDecisionMismatch: 0,
invalidLegacyRows: Object.fromEntries(
invalidLegacyRows.map(row => [row.category, Number(row.count)])
),
};
}
async lockWorkspaceOwnerTransfer(workspaceId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 16))
`;
}
async lockDocOwnerTransfer(workspaceId: string, docId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${docId}`}, 16))
`;
}
async markNewWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'new', true)
`;
}
async markLegacyWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'legacy', true)
`;
}
}
@@ -55,19 +55,6 @@ export function workspaceStatusFromNew(
}
}
export function workspaceSourceToNew(
source?: WorkspaceMemberSource
): PermissionSource {
switch (source) {
case WorkspaceMemberSource.Email:
return 'email';
case WorkspaceMemberSource.Link:
return 'link';
default:
return 'legacy';
}
}
export function workspaceSourceFromNew(
source?: PermissionSource | WorkspaceInvitationKind
): WorkspaceMemberSource {
@@ -141,10 +128,8 @@ export class WorkspaceMemberModel extends BaseModel {
userId: string,
fallbackRole: WorkspaceRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockWorkspaceOwnerTransfer(
workspaceId
);
await this.db
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:workspace-owner:${workspaceId}`}, 0))`;
const ownerCount = await this.db.workspaceMember.count({
where: { workspaceId, role: 'owner', state: 'active' },
});
@@ -197,13 +182,20 @@ export class WorkspaceMemberModel extends BaseModel {
workspaceId: string,
userId: string,
role: WorkspaceRole,
data: { legacyPermissionId?: string | null; source?: PermissionSource } = {}
data: { source?: PermissionSource } = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
const invitation = await this.db.workspaceInvitation.findUnique({
where: {
workspaceId_inviteeUserId: {
workspaceId,
inviteeUserId: userId,
},
},
});
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
@@ -218,23 +210,21 @@ export class WorkspaceMemberModel extends BaseModel {
},
update: {
role: workspaceRoleToNew(role),
legacyPermissionId: data.legacyPermissionId,
source: data.source,
},
create: {
id: invitation?.id,
workspaceId,
userId,
role: workspaceRoleToNew(role),
state: 'active',
source: data.source ?? 'legacy',
legacyPermissionId: data.legacyPermissionId,
source: data.source ?? invitation?.kind ?? 'legacy',
},
});
}
@Transactional()
async delete(workspaceId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT id
FROM workspace_members
@@ -272,8 +262,6 @@ export class WorkspaceMemberModel extends BaseModel {
@Injectable()
export class WorkspaceInvitationModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
@Transactional()
async set(
workspaceId: string,
@@ -285,7 +273,6 @@ export class WorkspaceInvitationModel extends BaseModel {
inviterId?: string;
} = {}
): Promise<void> {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
@@ -307,7 +294,6 @@ export class WorkspaceInvitationModel extends BaseModel {
requestedRole: role === WorkspaceRole.Admin ? 'admin' : 'member',
status: invitationStatus,
kind: workspaceInvitationKindToNew(data.source),
source: workspaceSourceToNew(data.source),
});
}
@@ -320,7 +306,6 @@ export class WorkspaceInvitationModel extends BaseModel {
inviterId?: string;
} = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const invitationStatus = workspaceStatusToInvitationState(status);
if (!invitationStatus) {
const invitation = await this.findInvitation(workspaceId, userId);
@@ -335,10 +320,7 @@ export class WorkspaceInvitationModel extends BaseModel {
workspaceId,
userId,
role,
{
legacyPermissionId: invitation.legacyPermissionId,
source: invitation.source,
}
{ source: invitation.source }
);
}
@@ -352,7 +334,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async deleteNonAccepted(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: { workspaceId },
});
@@ -360,7 +341,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async cancelPendingByActor(actorUserId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
inviterUserId: actorUserId,
@@ -373,7 +353,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async cancelPendingByWorkspace(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
workspaceId,
@@ -384,18 +363,6 @@ export class WorkspaceInvitationModel extends BaseModel {
});
}
private async supportsCurrentInvitationColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async upsertInvitation(input: {
workspaceId: string;
userId: string;
@@ -403,99 +370,41 @@ export class WorkspaceInvitationModel extends BaseModel {
requestedRole: 'admin' | 'member';
status: WorkspaceInvitationStatus;
kind: WorkspaceInvitationKind;
source: PermissionSource;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.kind},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = now()
`;
}
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_id,
role,
state,
source,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.source},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_id = EXCLUDED.inviter_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = now()
`;
return await this.db.workspaceInvitation.upsert({
where: {
workspaceId_inviteeUserId: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
},
},
update: {
inviterUserId: input.inviterId,
requestedRole: input.requestedRole,
status: input.status,
kind: input.kind,
},
create: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
inviterUserId: input.inviterId,
requestedRole: input.requestedRole,
status: input.status,
kind: input.kind,
},
});
}
private async findInvitation(workspaceId: string, userId: string) {
if (await this.supportsCurrentInvitationColumns()) {
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
requested_role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
kind AS source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
LIMIT 1
`;
return rows[0] ?? null;
}
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
source
requested_role AS "requestedRole",
kind AS source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
@@ -510,27 +419,16 @@ export class WorkspaceInvitationModel extends BaseModel {
status: WorkspaceInvitationStatus;
inviterId?: string;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
status = ${input.status},
inviter_user_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
}
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
state = ${input.status},
inviter_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
return await this.db.workspaceInvitation.updateMany({
where: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
},
data: {
status: input.status,
inviterUserId: input.inviterId,
},
});
}
}
@@ -545,7 +443,6 @@ export class WorkspaceAccessPolicyModel extends BaseModel {
enableUrlPreview?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceAccessPolicy.upsert({
where: { workspaceId },
update: {
@@ -592,7 +489,6 @@ export class DocAccessPolicyModel extends BaseModel {
urlPreviewEnabled?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const publicRole = policy.public ? 'external' : null;
return await this.db.docAccessPolicy.upsert({
where: { workspaceId_docId: { workspaceId, docId } },
@@ -635,11 +531,8 @@ export class DocAccessPolicyModel extends BaseModel {
export class DocGrantModel extends BaseModel {
@Transactional()
async setOwner(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockDocOwnerTransfer(
workspaceId,
docId
);
await this.db
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:doc-owner:${workspaceId}:${docId}`}, 0))`;
await this.db.docGrant.updateMany({
where: {
workspaceId,
@@ -656,7 +549,6 @@ export class DocGrantModel extends BaseModel {
@Transactional()
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
await this.models.permissionProjection.markNewWriteOrigin();
assert(role !== DocRole.None && role !== DocRole.External);
return await this.db.docGrant.upsert({
@@ -688,7 +580,6 @@ export class DocGrantModel extends BaseModel {
userIds: string[],
role: DocRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
@@ -724,7 +615,6 @@ export class DocGrantModel extends BaseModel {
@Transactional()
async delete(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT 1
FROM doc_grants
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
@@ -23,22 +22,6 @@ export class UserFeatureModel extends BaseModel {
return await this.models.feature.get(name);
}
async getQuota(userId: string) {
const quota = await this.db.userFeature.findFirst({
where: {
userId,
type: FeatureType.Quota,
activated: true,
},
});
if (!quota) {
return null;
}
return await this.models.feature.get<'free_plan_v1'>(quota.name as any);
}
async has(userId: string, name: UserFeatureName) {
const count = await this.db.userFeature.count({
where: {
@@ -51,18 +34,13 @@ export class UserFeatureModel extends BaseModel {
return count > 0;
}
async list(userId: string, type?: FeatureType) {
const filter: Prisma.UserFeatureWhereInput =
type === undefined
? {
userId,
activated: true,
}
: {
userId,
activated: true,
type,
};
async list(userId: string, type?: FeatureType, names?: UserFeatureName[]) {
const filter: Prisma.UserFeatureWhereInput = {
userId,
activated: true,
type,
name: names ? { in: names } : undefined,
};
const userFeatures = await this.db.userFeature.findMany({
where: filter,
@@ -123,29 +101,4 @@ export class UserFeatureModel extends BaseModel {
);
}
}
@Transactional()
async switchQuota(userId: string, to: UserFeatureName, reason: string) {
const quotas = await this.list(userId, FeatureType.Quota);
// deactivate the previous quota
if (quotas.length) {
// db state error
if (quotas.length > 1) {
this.logger.error(
`User ${userId} has multiple quotas, please check the database state.`
);
}
const from = quotas.at(-1) as UserFeatureName;
if (from === to) {
return;
}
await this.remove(userId, from);
}
await this.add(userId, to, reason);
}
}
@@ -326,6 +326,8 @@ export class WorkspaceAnalyticsModel extends BaseModel {
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
@@ -339,7 +341,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`
@@ -642,6 +644,8 @@ export class WorkspaceAnalyticsModel extends BaseModel {
COALESCE(wp.published_at, to_timestamp(0)) AS "sortValueDatePublishedAt",
COALESCE(v.views, 0) AS "sortValueViews"
FROM workspace_pages wp
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
@@ -655,7 +659,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
${keywordCondition}
${workspaceCondition}
${updatedAfterCondition}
@@ -1101,7 +1105,9 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
SELECT COUNT(*) AS total
FROM workspace_pages wp
WHERE wp.public = TRUE
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
${keywordCondition}
${workspaceCondition}
${updatedAfterCondition}
@@ -1,210 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
import {
type FeatureConfig,
FeatureType,
type WorkspaceFeatureName,
} from './common';
@Injectable()
export class WorkspaceFeatureModel extends BaseModel {
async get<T extends WorkspaceFeatureName>(workspaceId: string, name: T) {
const workspaceFeature = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
name,
activated: true,
},
});
if (!workspaceFeature) {
return null;
}
const feature = await this.models.feature.get_unchecked(name);
return {
...feature,
configs: this.models.feature.check(name, {
...feature.configs,
...(workspaceFeature?.configs as {}),
}),
};
}
async getQuota(workspaceId: string) {
const quota = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
type: FeatureType.Quota,
activated: true,
},
orderBy: {
createdAt: 'desc',
},
});
if (!quota) {
return null;
}
const rawFeature = await this.models.feature.get_unchecked(
quota.name as WorkspaceFeatureName
);
const feature = {
...rawFeature,
configs: this.models.feature.check(quota.name as 'team_plan_v1', {
...rawFeature.configs,
...(quota?.configs as {}),
}),
};
// workspace's storage quota is the sum of base quota and seats * quota per seat
feature.configs.storageQuota =
feature.configs.seatQuota * feature.configs.memberLimit +
feature.configs.storageQuota;
return feature;
}
async has(workspaceId: string, name: WorkspaceFeatureName) {
const count = await this.db.workspaceFeature.count({
where: {
workspaceId,
name,
activated: true,
},
});
return count > 0;
}
/**
* helper function to check if a list of workspaces have a standalone quota feature when calculating owner's quota usage
*/
async batchHasQuota(workspaceIds: string[]) {
const workspaceFeatures = await this.db.workspaceFeature.findMany({
select: {
workspaceId: true,
},
where: {
workspaceId: { in: workspaceIds },
type: FeatureType.Quota,
activated: true,
},
});
return workspaceFeatures.map(feature => feature.workspaceId);
}
async list(workspaceId: string, type?: FeatureType) {
const filter: Prisma.WorkspaceFeatureWhereInput =
type === undefined
? {
workspaceId,
activated: true,
}
: {
workspaceId,
activated: true,
type,
};
const workspaceFeatures = await this.db.workspaceFeature.findMany({
select: {
name: true,
},
where: filter,
});
return workspaceFeatures.map(
workspaceFeature => workspaceFeature.name
) as WorkspaceFeatureName[];
}
@Transactional()
async add<T extends WorkspaceFeatureName>(
workspaceId: string,
name: T,
reason: string,
overrides?: Partial<FeatureConfig<T>>
) {
// ensure feature exists
await this.models.feature.get_unchecked(name);
const existing = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
name: name,
activated: true,
},
});
if (existing && !overrides) {
return existing;
}
const configs = {
...(existing?.configs as {}),
...overrides,
};
const parseResult = this.models.feature
.getConfigShape(name)
.partial()
.safeParse(configs);
if (!parseResult.success) {
throw new Error(`Invalid feature config for ${name}`, {
cause: parseResult.error,
});
}
let workspaceFeature;
if (existing) {
workspaceFeature = await this.db.workspaceFeature.update({
where: {
id: existing.id,
},
data: {
configs: parseResult.data,
reason,
},
});
} else {
workspaceFeature = await this.db.workspaceFeature.create({
data: {
workspaceId,
name,
type: this.models.feature.getFeatureType(name),
activated: true,
reason,
configs: parseResult.data,
},
});
}
this.logger.verbose(`Feature ${name} added to workspace ${workspaceId}`);
return workspaceFeature;
}
async remove(workspaceId: string, featureName: WorkspaceFeatureName) {
const { count } = await this.db.workspaceFeature.deleteMany({
where: {
workspaceId,
name: featureName,
},
});
if (count > 0) {
this.logger.verbose(
`Feature ${featureName} removed from workspace ${workspaceId}`
);
}
}
}
@@ -1,223 +0,0 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
export type WorkspaceRuntimeState = {
workspaceId: string;
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date | null;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type WorkspaceRuntimeStateRow = {
workspaceId: string;
known: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type LegacyWorkspaceRuntimeStateRow = {
workspaceId: string;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
staleAt: Date | null;
};
function isMissingRuntimeStateColumn(error: unknown) {
const meta = (error as { meta?: { code?: string } })?.meta;
return meta?.code === '42703';
}
@Injectable()
export class WorkspaceRuntimeStateModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
async get(workspaceId: string): Promise<WorkspaceRuntimeState> {
const rows = await this.loadRows(workspaceId);
const row = rows[0];
if (!row) {
return {
workspaceId,
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
updatedAt: null,
lastReconciledAt: null,
staleAfter: null,
};
}
return {
workspaceId,
known: row.known,
stale:
!row.known || (row.staleAfter !== null && row.staleAfter <= new Date()),
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.lastReconciledAt,
staleAfter: row.staleAfter,
};
}
async upsert(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
if (await this.supportsCurrentRuntimeStateColumns()) {
await this.upsertCurrent(workspaceId, state);
} else {
await this.upsertLegacy(workspaceId, state);
}
}
private async loadRows(workspaceId: string) {
if (!(await this.supportsCurrentRuntimeStateColumns())) {
return await this.loadLegacyRows(workspaceId);
}
try {
return await this.db.$queryRaw<WorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
known,
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
last_reconciled_at AS "lastReconciledAt",
stale_after AS "staleAfter"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
} catch (error) {
if (!isMissingRuntimeStateColumn(error)) {
throw error;
}
return await this.loadLegacyRows(workspaceId);
}
}
private async supportsCurrentRuntimeStateColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async loadLegacyRows(workspaceId: string) {
const rows = await this.db.$queryRaw<LegacyWorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
stale_at AS "staleAt"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows.map(row => ({
workspaceId: row.workspaceId,
known: true,
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.updatedAt,
staleAfter: row.staleAt,
}));
}
private async upsertCurrent(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (
${workspaceId},
${state.known ?? true},
${state.readonly},
${state.readonlyReasons},
${state.lastReconciledAt ?? new Date()},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
known = EXCLUDED.known,
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
last_reconciled_at = EXCLUDED.last_reconciled_at,
stale_after = EXCLUDED.stale_after,
updated_at = now()
`;
}
private async upsertLegacy(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (
${workspaceId},
${state.readonly},
${state.readonlyReasons},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
stale_at = EXCLUDED.stale_at,
updated_at = now()
`;
}
}
@@ -6,7 +6,6 @@ import {
WorkspaceMember,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
@@ -17,7 +16,16 @@ import {
workspaceStatusFromNew,
} from './permission-write';
export type WorkspaceUserCompat = WorkspaceUserRole & {
export type WorkspaceUserCompat = {
id: string;
workspaceId: string;
userId: string;
type: WorkspaceRole;
status: WorkspaceMemberStatus;
source: WorkspaceMemberSource;
inviterId: string | null;
createdAt: Date;
updatedAt: Date;
user?: Pick<User, keyof typeof workspaceUserSelect>;
};
@@ -53,7 +61,7 @@ export function workspaceMemberToCompat(
member: WorkspaceMemberWithUser
): WorkspaceUserCompat {
return {
id: member.legacyPermissionId ?? member.id,
id: member.id,
workspaceId: member.workspaceId,
userId: member.userId,
type: workspaceRoleFromNew(member.role as never),
@@ -70,7 +78,7 @@ export function workspaceInvitationToCompat(
invitation: WorkspaceInvitationWithUser
): WorkspaceUserCompat {
return {
id: invitation.legacyPermissionId ?? invitation.id,
id: invitation.id,
workspaceId: invitation.workspaceId,
userId: invitation.inviteeUserId ?? '',
type: workspaceRoleFromNew(invitation.requestedRole as never),
@@ -117,7 +125,7 @@ export async function queryCompatRows(
SELECT *
FROM (
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.id AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
@@ -143,7 +151,7 @@ export async function queryCompatRows(
AND wm.state = 'active'
UNION ALL
SELECT
COALESCE(wi.legacy_permission_id, wi.id) AS id,
wi.id AS id,
wi.workspace_id AS "workspaceId",
wi.invitee_user_id AS "userId",
CASE wi.requested_role
@@ -192,7 +200,7 @@ export async function searchCompatRows(
SELECT *
FROM (
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.id AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
@@ -218,7 +226,7 @@ export async function searchCompatRows(
AND wm.state = 'active'
UNION ALL
SELECT
COALESCE(wi.legacy_permission_id, wi.id) AS id,
wi.id AS id,
wi.workspace_id AS "workspaceId",
wi.invitee_user_id AS "userId",
CASE wi.requested_role
@@ -329,7 +337,6 @@ export async function hasSharedWorkspace(
export async function allocateWorkspaceSeats(
db: WorkspaceUserCompatDb,
models: {
permissionProjection: { markNewWriteOrigin(): Promise<void> };
workspaceMember: {
setActive(
workspaceId: string,
@@ -341,7 +348,6 @@ export async function allocateWorkspaceSeats(
workspaceId: string,
limit: number
) {
await models.permissionProjection.markNewWriteOrigin();
const [activeCount, pendingCount] = await Promise.all([
db.workspaceMember.count({
where: {
@@ -4,7 +4,6 @@ import {
Prisma,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
@@ -20,6 +19,7 @@ import {
searchCompatRows,
workspaceInvitationToCompat,
workspaceMemberToCompat,
type WorkspaceUserCompat,
} from './workspace-user-compat';
export { WorkspaceMemberStatus };
@@ -190,39 +190,22 @@ export class WorkspaceUserModel extends BaseModel {
private async setExternal(
workspaceId: string,
userId: string,
oldRole: WorkspaceUserRole | null
oldRole: WorkspaceUserCompat | null
) {
await this.models.permissionProjection.markLegacyWriteOrigin();
if (!oldRole) {
throw new Error(`Workspace member ${workspaceId}/${userId} not found.`);
}
await this.db.workspaceMember.deleteMany({
where: { workspaceId, userId, state: 'active' },
});
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
await this.models.permissionProjection.markNewWriteOrigin();
if (oldRole) {
return await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.update({
where: { id: oldRole.id },
data: {
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
},
})
);
}
return await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
},
})
);
return {
...oldRole,
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
};
}
async setStatus(
@@ -261,32 +244,16 @@ export class WorkspaceUserModel extends BaseModel {
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.deleteMany({
where: {
workspaceId,
userId,
},
})
);
}
@Transactional()
async deleteByUserId(userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.workspaceMember.deleteMany({
where: { userId },
});
await this.db.workspaceInvitation.deleteMany({
where: { inviteeUserId: userId },
});
await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.deleteMany({
where: {
userId,
},
})
);
}
async deleteNonAccepted(workspaceId: string) {
@@ -295,7 +262,6 @@ export class WorkspaceUserModel extends BaseModel {
@Transactional()
async demoteAcceptedAdmins(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceMember.updateMany({
where: { workspaceId, role: 'admin', state: 'active' },
data: { role: 'member' },
@@ -326,20 +292,12 @@ export class WorkspaceUserModel extends BaseModel {
return workspaceInvitationToCompat(invitation);
}
return await this.db.workspaceUserRole.findFirst({
where: {
workspaceId,
userId,
type: WorkspaceRole.External,
},
});
return null;
}
async getById(id: string) {
const member = await this.db.workspaceMember.findFirst({
where: {
OR: [{ id }, { legacyPermissionId: id }],
},
where: { id },
});
if (member) {
return workspaceMemberToCompat(member);
@@ -347,7 +305,7 @@ export class WorkspaceUserModel extends BaseModel {
const invitation = await this.db.workspaceInvitation.findFirst({
where: {
OR: [{ id }, { legacyPermissionId: id }],
id,
inviteeUserId: {
not: null,
},
@@ -357,9 +315,7 @@ export class WorkspaceUserModel extends BaseModel {
return workspaceInvitationToCompat(invitation);
}
return await this.db.workspaceUserRole.findUnique({
where: { id },
});
return null;
}
/**
+108 -173
View File
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma, type Workspace } from '@prisma/client';
import { Prisma, type Workspace as WorkspaceRecord } from '@prisma/client';
import { EventBus } from '../base';
import { BaseModel } from './base';
import type { WorkspaceFeatureName } from './common';
type RawWorkspaceSummary = {
id: string;
@@ -22,7 +21,6 @@ type RawWorkspaceSummary = {
snapshotSize: bigint | number | null;
blobCount: bigint | number | null;
blobSize: bigint | number | null;
features: WorkspaceFeatureName[] | null;
ownerId: string | null;
ownerName: string | null;
ownerEmail: string | null;
@@ -45,7 +43,6 @@ export type AdminWorkspaceSummary = {
snapshotSize: number;
blobCount: number;
blobSize: number;
features: WorkspaceFeatureName[];
owner: {
id: string;
name: string;
@@ -63,19 +60,24 @@ declare global {
}
}
export type { Workspace };
export type Workspace = WorkspaceRecord & {
public: boolean;
enableSharing: boolean;
enableUrlPreview: boolean;
};
export type UpdateWorkspaceInput = Pick<
Partial<Workspace>,
| 'public'
Partial<WorkspaceRecord>,
| 'enableAi'
| 'enableSharing'
| 'enableUrlPreview'
| 'enableDocEmbedding'
| 'name'
| 'avatarKey'
| 'indexed'
| 'lastCheckEmbeddings'
>;
> & {
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
};
@Injectable()
export class WorkspaceModel extends BaseModel {
@@ -89,44 +91,60 @@ export class WorkspaceModel extends BaseModel {
*/
@Transactional()
async create(userId: string) {
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.create({
data: { public: false },
})
);
const workspace = await this.db.workspace.create({
data: {
accessPolicy: {
create: {
visibility: 'private',
sharingEnabled: true,
urlPreviewEnabled: false,
},
},
},
include: { accessPolicy: true },
});
this.logger.log(`Workspace created with id ${workspace.id}`);
await this.models.workspaceUser.setOwner(workspace.id, userId);
return workspace;
return this.withAccessPolicy(workspace);
}
/**
* Update the workspace with the given data.
*/
@Transactional()
async update(
workspaceId: string,
data: UpdateWorkspaceInput,
notifyUpdate = true
) {
const {
public: isPublic,
enableSharing,
enableUrlPreview,
...workspaceData
} = data;
if (
data.public !== undefined ||
data.enableSharing !== undefined ||
data.enableUrlPreview !== undefined
isPublic !== undefined ||
enableSharing !== undefined ||
enableUrlPreview !== undefined
) {
await this.models.workspaceAccessPolicy.upsert(workspaceId, {
public: data.public,
enableSharing: data.enableSharing,
enableUrlPreview: data.enableUrlPreview,
public: isPublic,
enableSharing,
enableUrlPreview,
});
}
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.update({
where: {
id: workspaceId,
},
data,
})
);
await this.db.workspace.update({
where: {
id: workspaceId,
},
data: workspaceData,
});
const workspace = await this.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace ${workspaceId} not found after update`);
}
this.logger.debug(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
);
@@ -139,19 +157,23 @@ export class WorkspaceModel extends BaseModel {
}
async get(workspaceId: string) {
return await this.db.workspace.findUnique({
const workspace = await this.db.workspace.findUnique({
where: {
id: workspaceId,
},
include: { accessPolicy: true },
});
return workspace ? this.withAccessPolicy(workspace) : null;
}
async findMany(ids: string[]) {
return await this.db.workspace.findMany({
const workspaces = await this.db.workspace.findMany({
where: {
id: { in: ids },
},
include: { accessPolicy: true },
});
return workspaces.map(workspace => this.withAccessPolicy(workspace));
}
async list<S extends Prisma.WorkspaceSelect>(
@@ -169,14 +191,13 @@ export class WorkspaceModel extends BaseModel {
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
}
@Transactional()
async delete(workspaceId: string) {
const rawResult = await this.withPermissionProjectionMetric(
this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
})
);
const rawResult = await this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
});
if (rawResult.count > 0) {
this.event.emit('workspace.deleted', { id: workspaceId });
@@ -185,13 +206,19 @@ export class WorkspaceModel extends BaseModel {
}
async allowUrlPreview(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableUrlPreview ?? false;
const policy = await this.db.workspaceAccessPolicy.findUnique({
where: { workspaceId },
select: { urlPreviewEnabled: true },
});
return policy?.urlPreviewEnabled ?? false;
}
async allowSharing(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableSharing ?? true;
const policy = await this.db.workspaceAccessPolicy.findUnique({
where: { workspaceId },
select: { sharingEnabled: true },
});
return policy?.sharingEnabled ?? true;
}
async allowEmbedding(workspaceId: string) {
@@ -218,6 +245,20 @@ export class WorkspaceModel extends BaseModel {
return count > 0;
}
private withAccessPolicy(
workspace: Prisma.WorkspaceGetPayload<{
include: { accessPolicy: true };
}>
): Workspace {
const { accessPolicy, ...data } = workspace;
return {
...data,
public: accessPolicy?.visibility === 'public',
enableSharing: accessPolicy?.sharingEnabled ?? true,
enableUrlPreview: accessPolicy?.urlPreviewEnabled ?? false,
};
}
// #endregion
// #region admin
@@ -225,7 +266,6 @@ export class WorkspaceModel extends BaseModel {
skip: number;
first: number;
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
@@ -244,89 +284,34 @@ export class WorkspaceModel extends BaseModel {
includeTotal?: boolean;
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const flags = options.flags ?? {};
const includeTotal = options.includeTotal ?? true;
const total = includeTotal
? await this.adminCountWorkspaces({ keyword, features, flags })
? await this.adminCountWorkspaces({ keyword, flags })
: 0;
if (includeTotal && total === 0) {
return { rows: [], total: 0 };
}
const featuresHaving =
features.length > 0
? Prisma.sql`
HAVING COUNT(
DISTINCT CASE
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
END
) = ${features.length}
`
: Prisma.empty;
const featureJoin =
features.length > 0
? Prisma.sql`
LEFT JOIN workspace_features wf
ON wf.workspace_id = w.id AND wf.activated = TRUE
`
: Prisma.empty;
const groupAndHaving =
features.length > 0
? Prisma.sql`
GROUP BY w.id,
w.public,
w.created_at,
w.name,
w.avatar_key,
w.enable_ai,
w.enable_url_preview,
w.enable_doc_embedding,
o.owner_id,
o.owner_name,
o.owner_email,
o.owner_avatar_url
${featuresHaving}
`
: Prisma.empty;
const workspaceOnlyGroupAndHaving =
features.length > 0
? Prisma.sql`
GROUP BY w.id,
w.public,
w.created_at,
w.name,
w.avatar_key,
w.enable_ai,
w.enable_sharing,
w.enable_url_preview,
w.enable_doc_embedding
${featuresHaving}
`
: Prisma.empty;
if (!keyword) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS (
SELECT w.id,
w.public,
(wap.visibility = 'public') AS public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding"
FROM workspaces w
${featureJoin}
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
${workspaceOnlyGroupAndHaving}
),
page AS (
SELECT f.*,
@@ -335,8 +320,7 @@ export class WorkspaceModel extends BaseModel {
COALESCE(s.blob_count, 0) AS "blobCount",
COALESCE(s.blob_size, 0) AS "blobSize",
COALESCE(s.member_count, 0) AS "memberCount",
COALESCE(s.public_page_count, 0) AS "publicPageCount",
COALESCE(s.features, ARRAY[]::text[]) AS features
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM filtered f
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
@@ -371,19 +355,20 @@ export class WorkspaceModel extends BaseModel {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS (
SELECT w.id,
w.public,
(wap.visibility = 'public') AS public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
o.owner_email AS "ownerEmail",
o.owner_avatar_url AS "ownerAvatarUrl"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
@@ -397,7 +382,6 @@ export class WorkspaceModel extends BaseModel {
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
WHERE ${
keyword
? Prisma.sql`
@@ -417,7 +401,6 @@ export class WorkspaceModel extends BaseModel {
)}`
: Prisma.empty
}
${groupAndHaving}
)
SELECT f.*,
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
@@ -425,8 +408,7 @@ export class WorkspaceModel extends BaseModel {
COALESCE(s.blob_count, 0) AS "blobCount",
COALESCE(s.blob_size, 0) AS "blobSize",
COALESCE(s.member_count, 0) AS "memberCount",
COALESCE(s.public_page_count, 0) AS "publicPageCount",
COALESCE(s.features, ARRAY[]::text[]) AS features
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM filtered f
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
@@ -454,7 +436,6 @@ export class WorkspaceModel extends BaseModel {
snapshotSize: Number(row.snapshotSize ?? 0),
blobCount: Number(row.blobCount ?? 0),
blobSize: Number(row.blobSize ?? 0),
features: (row.features ?? []) as WorkspaceFeatureName[],
owner: row.ownerId
? {
id: row.ownerId,
@@ -469,13 +450,13 @@ export class WorkspaceModel extends BaseModel {
async adminGetWorkspace(id: string) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
SELECT w.id,
w.public,
(wap.visibility = 'public') AS public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
@@ -486,9 +467,9 @@ export class WorkspaceModel extends BaseModel {
COALESCE(s.blob_count, 0) AS "blobCount",
COALESCE(s.blob_size, 0) AS "blobSize",
COALESCE(s.member_count, 0) AS "memberCount",
COALESCE(s.public_page_count, 0) AS "publicPageCount",
COALESCE(s.features, ARRAY[]::text[]) AS features
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
@@ -512,7 +493,6 @@ export class WorkspaceModel extends BaseModel {
async adminCountWorkspaces(options: {
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
@@ -522,13 +502,13 @@ export class WorkspaceModel extends BaseModel {
};
}) {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const flags = options.flags ?? {};
if (!keyword && features.length === 0) {
if (!keyword) {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
SELECT COUNT(*) AS total
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
@@ -539,58 +519,13 @@ export class WorkspaceModel extends BaseModel {
return row?.total ? Number(row.total) : 0;
}
const featureJoin =
features.length > 0
? Prisma.sql`
LEFT JOIN workspace_features wf
ON wf.workspace_id = w.id AND wf.activated = TRUE
`
: Prisma.empty;
const featuresHaving =
features.length > 0
? Prisma.sql`
HAVING COUNT(
DISTINCT CASE
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
END
) = ${features.length}
`
: Prisma.empty;
if (!keyword) {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
WITH filtered AS (
SELECT w.id
FROM workspaces w
${featureJoin}
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
GROUP BY w.id
${featuresHaving}
)
SELECT COUNT(*) AS total FROM filtered
`;
return row?.total ? Number(row.total) : 0;
}
const groupAndHaving =
features.length > 0
? Prisma.sql`
GROUP BY w.id, o.owner_id, o.owner_email
${featuresHaving}
`
: Prisma.empty;
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
WITH filtered AS (
SELECT w.id,
o.owner_id AS "ownerId",
o.owner_email AS "ownerEmail"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT wm.workspace_id,
u.id AS owner_id,
@@ -603,7 +538,6 @@ export class WorkspaceModel extends BaseModel {
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
WHERE ${
keyword
? Prisma.sql`
@@ -623,7 +557,6 @@ export class WorkspaceModel extends BaseModel {
)}`
: Prisma.empty
}
${groupAndHaving}
)
SELECT COUNT(*) AS total FROM filtered
`;
@@ -640,17 +573,19 @@ export class WorkspaceModel extends BaseModel {
}) {
const conditions: Prisma.Sql[] = [];
if (flags.public !== undefined) {
conditions.push(Prisma.sql`w.public = ${flags.public}`);
conditions.push(
Prisma.sql`(wap.visibility = 'public') = ${flags.public}`
);
}
if (flags.enableAi !== undefined) {
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
}
if (flags.enableSharing !== undefined) {
conditions.push(Prisma.sql`w.enable_sharing = ${flags.enableSharing}`);
conditions.push(Prisma.sql`wap.sharing_enabled = ${flags.enableSharing}`);
}
if (flags.enableUrlPreview !== undefined) {
conditions.push(
Prisma.sql`w.enable_url_preview = ${flags.enableUrlPreview}`
Prisma.sql`wap.url_preview_enabled = ${flags.enableUrlPreview}`
);
}
if (flags.enableDocEmbedding !== undefined) {
@@ -5,6 +5,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequest,
Cache,
Config,
CryptoHelper,
metrics,
safeFetch,
@@ -115,11 +116,12 @@ export class ByokService {
private readonly models: Models,
private readonly crypto: CryptoHelper,
private readonly cache: Cache,
private readonly entitlement: ByokEntitlementPolicy
private readonly entitlement: ByokEntitlementPolicy,
private readonly config: Config
) {}
get customEndpointSupported() {
return env.selfhosted;
return env.selfhosted && this.config.copilot.byok.allowCustomEndpoint;
}
async getSettings(
@@ -188,10 +188,7 @@ export class IndexerResolver {
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
} as const;
const predicate = this.permission.docReadableSqlPredicate(input);
const fallbackPredicate =
this.permission.fallbackDocReadableSqlPredicate(input);
const query = (predicate: Prisma.Sql) =>
this.db.$queryRaw<{ docId: string }[]>`
const rows = await this.db.$queryRaw<{ docId: string }[]>`
WITH candidate_docs AS (
SELECT "workspace_pages"."page_id" AS doc_id
FROM "workspace_pages"
@@ -205,12 +202,6 @@ export class IndexerResolver {
FROM candidate_docs
WHERE ${predicate}
`;
const rows = await query(predicate).catch(error => {
if (!fallbackPredicate) {
throw error;
}
return query(fallbackPredicate);
});
return rows.map(row => row.docId);
}
}
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Transactional } from '@nestjs-cls/transactional';
import { InstalledLicense, PrismaClient } from '@prisma/client';
import {
@@ -104,6 +105,7 @@ export class LicenseService {
});
}
@Transactional()
async installLicense(workspaceId: string, license: Buffer) {
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
if (!resolved.valid) {
@@ -111,13 +113,37 @@ export class LicenseService {
}
const validatedAt = new Date();
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
const previous = await this.db.installedLicense.findUnique({
where: { workspaceId },
});
const installed = await this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: this.licenseSubjectId(resolved),
quantity: this.licenseQuantity(resolved),
recurring: this.licenseRecurring(resolved),
variant: SubscriptionVariant.Onetime,
validateKey: '',
validatedAt,
expiredAt: this.licenseExpiresAt(resolved),
license,
},
create: {
workspaceId,
key: this.licenseSubjectId(resolved),
quantity: this.licenseQuantity(resolved),
recurring: this.licenseRecurring(resolved),
variant: SubscriptionVariant.Onetime,
validateKey: '',
validatedAt,
expiredAt: this.licenseExpiresAt(resolved),
license,
},
});
if (previous && previous.key !== installed.key) {
await this.entitlement.revokeBySubject('selfhost_license', previous.key);
}
await this.entitlement.upsertFromSelfhostLicense({
workspaceId,
recurring: this.licenseRecurring(resolved),
@@ -127,10 +153,14 @@ export class LicenseService {
variant: SubscriptionVariant.Onetime,
license,
});
return this.db.installedLicense.findUniqueOrThrow({
where: { workspaceId },
await this.event.emitAsync('workspace.subscription.activated', {
workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
});
return installed;
}
previewLicense(license: Buffer): LicensePreview {
@@ -157,6 +187,7 @@ export class LicenseService {
};
}
@Transactional()
async activateTeamLicense(workspaceId: string, licenseKey: string) {
const installedLicense = await this.getLicense(workspaceId);
@@ -177,35 +208,8 @@ export class LicenseService {
const validatedAt = new Date();
const expiresAt = new Date(data.expiresAt);
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan as SubscriptionPlan,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
});
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey: data.validateKey,
});
return this.db.installedLicense.upsert({
where: { workspaceId },
update: {
key: licenseKey,
quantity: data.quantity,
recurring: data.recurring as SubscriptionRecurring,
variant: null,
validateKey: data.validateKey,
validatedAt,
expiredAt: expiresAt,
license: null,
},
create: {
const installed = await this.db.installedLicense.create({
data: {
workspaceId,
key: licenseKey,
quantity: data.quantity,
@@ -217,6 +221,22 @@ export class LicenseService {
license: null,
},
});
await this.entitlement.upsertFromValidatedSelfhostLicense({
workspaceId,
licenseKey,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
expiresAt,
validatedAt,
validateKey: data.validateKey,
});
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: data.plan as SubscriptionPlan,
recurring: data.recurring as SubscriptionRecurring,
quantity: data.quantity,
});
return installed;
}
async removeTeamLicense(workspaceId: string) {
@@ -487,18 +507,33 @@ export class LicenseService {
if (!resolved.valid) {
valid = false;
} else {
const recurring = this.licenseRecurring(resolved);
const quantity = this.licenseQuantity(resolved);
const expiresAt = this.licenseExpiresAt(resolved);
const validatedAt = new Date();
await this.db.installedLicense.update({
where: { key: license.key },
data: {
recurring,
quantity,
expiredAt: expiresAt,
validatedAt,
},
});
this.event.emit('workspace.subscription.activated', {
workspaceId: license.workspaceId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
recurring,
quantity,
});
await this.entitlement.upsertFromSelfhostLicense({
workspaceId: license.workspaceId,
recurring: this.licenseRecurring(resolved),
quantity: this.licenseQuantity(resolved),
expiresAt: this.licenseExpiresAt(resolved),
validatedAt: new Date(),
recurring,
quantity,
expiresAt,
validatedAt,
variant: SubscriptionVariant.Onetime,
license: Buffer.from(buf),
});
}
@@ -105,21 +105,22 @@ export class SubscriptionCronJobs {
const { start: before180DaysStart, end: before180DaysEnd } =
this.getDateRange(-180);
const subscriptions = await this.db.subscription.findMany({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
targetType: 'workspace',
plan: SubscriptionPlan.Team,
OR: [
{
// subscription will cancel after 30 days
status: 'active',
canceledAt: { not: null },
end: { gte: after30DayStart, lte: after30DayEnd },
periodEnd: { gte: after30DayStart, lte: after30DayEnd },
},
{
// subscription will cancel today
status: 'active',
canceledAt: { not: null },
end: { gte: todayStart, lte: todayEnd },
periodEnd: { gte: todayStart, lte: todayEnd },
},
{
// subscription has been canceled for 150 days
@@ -138,13 +139,13 @@ export class SubscriptionCronJobs {
});
for (const subscription of subscriptions) {
const end = subscription.end;
const end = subscription.periodEnd;
if (!end) {
// should not reach here
continue;
}
if (!subscription.nextBillAt) {
if (subscription.canceledAt) {
this.event.emit('workspace.subscription.notify', {
workspaceId: subscription.targetId,
expirationDate: end,
@@ -156,10 +157,14 @@ export class SubscriptionCronJobs {
@OnJob('nightly.cleanExpiredOnetimeSubscriptions')
async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.subscription.findMany({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
variant: SubscriptionVariant.Onetime,
end: {
targetType: 'user',
metadata: {
path: ['variant'],
equals: SubscriptionVariant.Onetime,
},
periodEnd: {
lte: new Date(),
},
},
@@ -170,21 +175,16 @@ export class SubscriptionCronJobs {
targetId: subscription.targetId,
plan: subscription.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
stripeSubscriptionId: subscription.externalSubscriptionId,
});
await this.db.subscription.delete({
where: {
targetId_plan: {
targetId: subscription.targetId,
plan: subscription.plan,
},
},
await this.db.providerSubscription.delete({
where: { id: subscription.id },
});
this.event.emit('user.subscription.canceled', {
userId: subscription.targetId,
plan: subscription.plan as SubscriptionPlan,
recurring: subscription.variant as SubscriptionRecurring,
recurring: subscription.recurring as SubscriptionRecurring,
});
}
}
@@ -192,9 +192,10 @@ export class SubscriptionCronJobs {
@OnJob('nightly.reconcileRevenueCatSubscriptions')
async reconcileRevenueCatSubscriptions() {
// Find active/trialing/past_due RC subscriptions and resync via RC REST
const subs = await this.db.subscription.findMany({
const subs = await this.db.providerSubscription.findMany({
where: {
provider: Provider.revenuecat,
targetType: 'user',
status: {
in: [
SubscriptionStatus.Active,
@@ -287,10 +288,11 @@ export class SubscriptionCronJobs {
@OnJob('nightly.reconcileStripeSubscriptions')
async reconcileStripeSubscriptions() {
const stripe = this.stripeFactory.stripe;
const subs = await this.db.subscription.findMany({
const subs = await this.db.providerSubscription.findMany({
where: {
provider: Provider.stripe,
stripeSubscriptionId: { not: null },
externalSubscriptionId: { not: null },
recurring: { not: SubscriptionRecurring.Lifetime },
status: {
in: [
SubscriptionStatus.Active,
@@ -299,13 +301,13 @@ export class SubscriptionCronJobs {
],
},
},
select: { stripeSubscriptionId: true },
select: { externalSubscriptionId: true },
});
const subscriptionIds = Array.from(
new Set(
subs
.map(sub => sub.stripeSubscriptionId)
.map(sub => sub.externalSubscriptionId)
.filter((id): id is string => !!id)
)
);
@@ -11,7 +11,7 @@ import {
Post,
Res,
} from '@nestjs/common';
import { PrismaClient, Subscription } from '@prisma/client';
import { PrismaClient, Provider } from '@prisma/client';
import type { Response } from 'express';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -24,6 +24,7 @@ import {
Mutex,
} from '../../../base';
import { Public } from '../../../core/auth';
import type { Subscription } from '../manager';
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
import { SubscriptionService } from '../service';
import { StripeFactory } from '../stripe';
@@ -237,19 +238,23 @@ export class LicenseController {
@Post('/:license/create-customer-portal')
async createCustomerPortal(@Param('license') key: string) {
const subscription = await this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: key,
plan: SubscriptionPlan.SelfHostedTeam,
},
orderBy: { updatedAt: 'desc' },
});
if (!subscription || !subscription.stripeSubscriptionId) {
if (!subscription?.externalSubscriptionId) {
throw new LicenseNotFound();
}
const subscriptionData =
await this.stripeProvider.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId,
subscription.externalSubscriptionId,
{
expand: ['customer'],
}
@@ -1,4 +1,9 @@
import { type Prisma, PrismaClient, UserStripeCustomer } from '@prisma/client';
import {
type Prisma,
PrismaClient,
type ProviderSubscription,
UserStripeCustomer,
} from '@prisma/client';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -19,13 +24,13 @@ import {
export function validSubscriptionPeriodWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
return { OR: [{ end: null }, { end: { gt: now } }] };
): Prisma.ProviderSubscriptionWhereInput {
return { OR: [{ periodEnd: null }, { periodEnd: { gt: now } }] };
}
export function activeSubscriptionWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
): Prisma.ProviderSubscriptionWhereInput {
return {
status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing] },
...validSubscriptionPeriodWhere(now),
@@ -34,7 +39,7 @@ export function activeSubscriptionWhere(
export function visibleSubscriptionWhere(
now = new Date()
): Prisma.SubscriptionWhereInput {
): Prisma.ProviderSubscriptionWhereInput {
return {
status: {
in: [
@@ -98,10 +103,30 @@ export abstract class SubscriptionManager {
this.scheduleManager = new ScheduleManager(this.stripeProvider);
}
protected async patchProviderSubscriptionMetadata(
id: string,
patch: Prisma.InputJsonObject
) {
await this.db.$executeRaw`
UPDATE provider_subscriptions
SET metadata = metadata || ${JSON.stringify(patch)}::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
`;
return this.db.providerSubscription.findUniqueOrThrow({ where: { id } });
}
get stripe() {
return this.stripeProvider.stripe;
}
protected requireStripeSubscriptionId(id: string | null) {
if (!id) {
throw new Error('Stripe subscription identity is missing.');
}
return id;
}
abstract filterPrices(
prices: KnownStripePrice[],
customer?: UserStripeCustomer
@@ -171,6 +196,42 @@ export abstract class SubscriptionManager {
};
}
transformProviderSubscription(
subscription: ProviderSubscription
): Subscription {
const metadata = subscription.metadata as Record<string, unknown>;
const nextBillAt = metadata.nextBillAt;
return {
stripeSubscriptionId:
subscription.provider === 'stripe' &&
subscription.recurring !== SubscriptionRecurring.Lifetime
? subscription.externalSubscriptionId
: null,
stripeScheduleId:
typeof metadata.stripeScheduleId === 'string'
? metadata.stripeScheduleId
: null,
status: subscription.status,
plan: subscription.plan,
recurring: subscription.recurring ?? SubscriptionRecurring.Monthly,
variant: typeof metadata.variant === 'string' ? metadata.variant : null,
quantity: subscription.quantity ?? 1,
start: subscription.periodStart ?? subscription.createdAt,
end: subscription.periodEnd,
trialStart: subscription.trialStart,
trialEnd: subscription.trialEnd,
nextBillAt: subscription.canceledAt
? null
: typeof nextBillAt === 'string'
? new Date(nextBillAt)
: subscription.periodEnd,
canceledAt: subscription.canceledAt,
provider: subscription.provider,
iapStore: subscription.iapStore,
};
}
async transformInvoice({
stripeInvoice,
}: KnownStripeInvoice): Promise<Invoice> {
@@ -1,8 +1,12 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
import { omit } from 'lodash-es';
import {
Prisma,
PrismaClient,
Provider,
UserStripeCustomer,
} from '@prisma/client';
import { z } from 'zod';
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
@@ -119,70 +123,44 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { stripeSubscription, userEmail } = subscription;
const subscriptionData = this.transformSubscription(subscription);
const existingSubscription = await this.db.subscription.findFirst({
const existingSubscription = await this.db.providerSubscription.findUnique({
where: {
stripeSubscriptionId: stripeSubscription.id,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
},
},
});
const key = existingSubscription?.targetId ?? randomUUID();
const saved = await this.db.$transaction(async db => {
const saved = await this.upsertStripeProviderSubscription(
key,
subscription,
subscriptionData,
db
);
await db.license.upsert({
where: { key: saved.targetId },
update: {},
create: { key: saved.targetId },
});
return saved;
});
if (!existingSubscription) {
const key = randomUUID();
const [saved] = await this.db.$transaction([
this.db.subscription.create({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
data: {
provider: Provider.stripe,
targetId: key,
...omit(subscriptionData, 'provider', 'iapStore'),
},
}),
this.db.license.create({
data: { key },
}),
]);
await this.mailer.send({
name: 'TeamLicense',
to: userEmail,
props: { license: key },
props: { license: saved.targetId },
metadata: {
dedupeKey: `selfhost-license:${key}`,
dedupeKey: `selfhost-license:${saved.targetId}`,
source: { trusted: false },
},
});
await this.upsertStripeProviderSubscription(
key,
subscription,
subscriptionData
);
return saved;
} else {
const saved = await this.db.subscription.update({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
where: {
stripeSubscriptionId: stripeSubscription.id,
},
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
});
await this.upsertStripeProviderSubscription(
saved.targetId,
subscription,
subscriptionData
);
return saved;
}
return this.transformProviderSubscription(saved);
}
async deleteStripeSubscription({
@@ -199,80 +177,111 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const subscription = await this.db.subscription.findFirst({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (!subscription) {
return;
}
await this.db.$transaction([
this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
}),
this.db.license.deleteMany({
where: { key: subscription.targetId },
}),
]);
}
getSubscription(identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: { targetId: identity.key },
async getSubscription(
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
) {
const subscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: identity.key,
plan: identity.plan,
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
getActiveSubscription(
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
) {
return this.db.subscription.findFirst({
where: {
targetId: identity.key,
plan: identity.plan,
...activeSubscriptionWhere(),
},
});
return this.db.providerSubscription
.findFirst({
where: {
provider: Provider.stripe,
targetType: 'instance',
targetId: identity.key,
plan: identity.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
async resumeSubscription(subscription: Subscription) {
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
) {
return this.db.providerSubscription
.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
})
.then(subscription => this.transformProviderSubscription(subscription));
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
@@ -284,12 +293,13 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
private async upsertStripeProviderSubscription(
targetId: string,
known: KnownStripeSubscription,
subscriptionData: Subscription
subscriptionData: Subscription,
db: Prisma.TransactionClient = this.db
) {
const { lookupKey, stripeSubscription } = known;
const price = stripeSubscription.items.data[0]?.price;
await this.db.providerSubscription.upsert({
return db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -297,8 +307,6 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
},
},
update: {
targetType: 'instance',
targetId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
status: stripeSubscription.status,
@@ -319,7 +327,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata: {
...known.metadata,
variant: subscriptionData.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
},
},
create: {
provider: Provider.stripe,
@@ -346,7 +359,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata: {
...known.metadata,
variant: subscriptionData.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
},
},
});
}
@@ -197,32 +197,47 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async getActiveSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: args.userId,
plan: args.plan,
...visibleSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
return subscription
? this.transformProviderSubscription(subscription)
: null;
}
async saveStripeSubscription(subscription: KnownStripeSubscription) {
@@ -249,7 +264,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
const subscriptionData = this.transformSubscription(subscription);
await this.upsertStripeProviderSubscription(subscription, subscriptionData);
const saved = await this.upsertStripeProviderSubscription(
subscription,
subscriptionData
);
if (
lookupKey.plan === SubscriptionPlan.AI &&
@@ -265,41 +283,13 @@ export class UserSubscriptionManager extends SubscriptionManager {
});
}
const existingByStripeId = await this.db.subscription.findUnique({
where: { stripeSubscriptionId: stripeSubscription.id },
const result = this.transformProviderSubscription(saved);
await this.entitlement.upsertFromCloudSubscription({
...result,
targetId: saved.targetId,
subscriptionId: saved.id,
});
const saved = existingByStripeId
? await this.db.subscription.update({
where: { id: existingByStripeId.id },
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
})
: await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: { targetId_plan: { targetId: userId, plan: lookupKey.plan } },
update: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
create: {
targetId: userId,
...omit(subscriptionData, ['provider', 'iapStore']),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
return result;
}
async deleteStripeSubscription({
@@ -308,7 +298,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
stripeSubscription,
}: KnownStripeSubscription) {
this.assertUserIdExists(userId);
await this.db.providerSubscription.updateMany({
const result = await this.db.providerSubscription.updateMany({
where: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
@@ -319,11 +309,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const result = await this.db.subscription.deleteMany({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
@@ -340,42 +325,85 @@ export class UserSubscriptionManager extends SubscriptionManager {
}
async cancelSubscription(subscription: Subscription) {
return this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
async resumeSubscription(subscription: Subscription) {
return this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
async updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
) {
return this.db.subscription.update({
const saved = await this.db.providerSubscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
});
return this.transformProviderSubscription(saved);
}
async saveInvoice(knownInvoice: KnownStripeInvoice) {
@@ -418,60 +446,54 @@ export class UserSubscriptionManager extends SubscriptionManager {
async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) {
this.assertUserIdExists(knownInvoice.userId);
// cancel previous non-lifetime subscription
const prevSubscription = await this.db.subscription.findUnique({
const prevSubscription = await this.db.providerSubscription.findFirst({
where: {
targetId_plan: {
targetId: knownInvoice.userId,
plan: SubscriptionPlan.Pro,
},
provider: Provider.stripe,
targetType: 'user',
targetId: knownInvoice.userId,
plan: SubscriptionPlan.Pro,
recurring: { not: SubscriptionRecurring.Lifetime },
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
});
if (prevSubscription) {
if (prevSubscription.stripeSubscriptionId) {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const subscription = await this.db.subscription.update({
where: {
id: prevSubscription.id,
},
data: {
stripeScheduleId: null,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.stripe.subscriptions.cancel(
prevSubscription.stripeSubscriptionId,
{
prorate: true,
}
);
}
} else {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const subscription = await this.db.subscription.create({
if (prevSubscription?.externalSubscriptionId) {
await this.stripe.subscriptions.cancel(
prevSubscription.externalSubscriptionId,
{ prorate: true },
{
idempotencyKey: `lifetime:${knownInvoice.stripeInvoice.id}:cancel:${prevSubscription.id}`,
}
);
const now = new Date();
await this.db.providerSubscription.update({
where: { id: prevSubscription.id },
data: {
targetId: knownInvoice.userId,
stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
start: new Date(),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
status: SubscriptionStatus.Canceled,
canceledAt: now,
periodEnd: now,
},
});
await this.entitlement.upsertFromCloudSubscription(subscription);
await this.patchProviderSubscriptionMetadata(prevSubscription.id, {
nextBillAt: null,
});
await this.entitlement.revokeCloudSubscription({
targetId: knownInvoice.userId,
plan: prevSubscription.plan,
subscriptionId: prevSubscription.id,
stripeSubscriptionId: prevSubscription.externalSubscriptionId,
});
}
const saved = await this.upsertLifetimeProviderSubscription(knownInvoice);
await this.entitlement.upsertFromCloudSubscription({
...this.transformProviderSubscription(saved),
targetId: saved.targetId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.activated', {
userId: knownInvoice.userId,
plan: knownInvoice.lookupKey.plan,
@@ -483,11 +505,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey } = knownInvoice;
const subscription = await this.db.subscription.findFirst({
const subscription = await this.db.providerSubscription.findUnique({
where: {
targetId: userId,
plan: lookupKey.plan,
provider: Provider.stripe,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.lifetimeExternalId(knownInvoice),
},
},
});
@@ -495,22 +518,24 @@ export class UserSubscriptionManager extends SubscriptionManager {
return;
}
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
await this.db.subscription.update({
await this.db.providerSubscription.update({
where: {
id: subscription.id,
},
data: {
status: SubscriptionStatus.Canceled,
nextBillAt: null,
canceledAt: new Date(),
},
});
const saved = await this.patchProviderSubscriptionMetadata(
subscription.id,
{ nextBillAt: null }
);
await this.entitlement.revokeCloudSubscription({
targetId: userId,
plan: lookupKey.plan,
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
@@ -524,48 +549,22 @@ export class UserSubscriptionManager extends SubscriptionManager {
this.assertUserIdExists(knownInvoice.userId);
const { userId, lookupKey, stripeInvoice } = knownInvoice;
const subscription = await this.db.subscription.findFirst({
where: {
targetId: userId,
plan: lookupKey.plan,
provider: Provider.stripe,
},
});
const start =
stripeInvoice.lines.data[0]?.period?.start ??
(typeof stripeInvoice.created === 'number'
? stripeInvoice.created
: Date.now() / 1000);
if (subscription) {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const saved = await this.db.subscription.update({
where: { id: subscription.id },
data: {
status: SubscriptionStatus.Active,
canceledAt: null,
nextBillAt: null,
start: subscription.start ?? new Date(start * 1000),
end: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
} else {
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
const saved = await this.db.subscription.create({
data: {
targetId: userId,
stripeSubscriptionId: null,
...lookupKey,
start: new Date(start * 1000),
end: null,
status: SubscriptionStatus.Active,
nextBillAt: null,
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
}
const saved = await this.upsertLifetimeProviderSubscription(
knownInvoice,
new Date(start * 1000)
);
await this.entitlement.upsertFromCloudSubscription({
...this.transformProviderSubscription(saved),
targetId: saved.targetId,
subscriptionId: saved.id,
stripeSubscriptionId: saved.externalSubscriptionId,
});
this.event.emit('user.subscription.activated', {
userId,
@@ -696,8 +695,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
const { userId, lookupKey, stripeSubscription } = known;
this.assertUserIdExists(userId);
const price = stripeSubscription.items.data[0]?.price;
const metadata = {
...known.metadata,
variant: lookupKey.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
};
await this.db.providerSubscription.upsert({
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -727,7 +732,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
create: {
provider: Provider.stripe,
@@ -754,7 +759,61 @@ export class UserSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
});
}
private lifetimeExternalId(knownInvoice: KnownStripeInvoice) {
return `stripe_invoice:${knownInvoice.stripeInvoice.id}`;
}
private upsertLifetimeProviderSubscription(
knownInvoice: KnownStripeInvoice,
start = new Date()
) {
const { userId, lookupKey } = knownInvoice;
this.assertUserIdExists(userId);
const externalSubscriptionId = this.lifetimeExternalId(knownInvoice);
const metadata = {
...knownInvoice.metadata,
variant: lookupKey.variant,
stripeScheduleId: null,
nextBillAt: null,
lifetimeInvoiceId: knownInvoice.stripeInvoice.id,
};
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId,
},
},
update: {
targetType: 'user',
targetId: userId,
plan: lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
quantity: 1,
periodStart: start,
periodEnd: null,
canceledAt: null,
metadata,
},
create: {
provider: Provider.stripe,
targetType: 'user',
targetId: userId,
plan: lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
externalSubscriptionId,
quantity: 1,
periodStart: start,
periodEnd: null,
metadata,
},
});
}
@@ -896,14 +955,26 @@ export class UserSubscriptionManager extends SubscriptionManager {
@OnEvent('user.deleted')
async onUserDeleted({ id }: Events['user.deleted']) {
const subscription = await this.db.subscription.findFirst({
const subscriptions = await this.db.providerSubscription.findMany({
where: {
provider: Provider.stripe,
targetType: 'user',
targetId: id,
recurring: { not: SubscriptionRecurring.Lifetime },
externalSubscriptionId: { not: null },
...activeSubscriptionWhere(),
},
});
if (subscription?.stripeSubscriptionId) {
await this.stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
}
await Promise.all(
subscriptions.map(subscription =>
this.stripe.subscriptions.cancel(
this.requireStripeSubscriptionId(subscription.externalSubscriptionId)
)
)
);
await this.db.providerSubscription.deleteMany({
where: { targetType: 'user', targetId: id },
});
}
}
@@ -139,7 +139,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
const subscriptionData = this.transformSubscription(subscription);
await this.upsertStripeProviderSubscription(
const saved = await this.upsertStripeProviderSubscription(
workspaceId,
subscription,
subscriptionData
@@ -163,46 +163,13 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
});
}
const existingByStripeId = await this.db.subscription.findUnique({
where: { stripeSubscriptionId: stripeSubscription.id },
const result = this.transformProviderSubscription(saved);
await this.entitlement.upsertFromCloudSubscription({
...result,
targetId: saved.targetId,
subscriptionId: saved.id,
});
const saved = existingByStripeId
? await this.db.subscription.update({
where: { id: existingByStripeId.id },
data: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
})
: await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: {
targetId_plan: {
targetId: workspaceId,
plan: lookupKey.plan,
},
},
update: {
...omit(subscriptionData, ['provider', 'iapStore']),
provider: Provider.stripe,
iapStore: null,
rcEntitlement: null,
rcProductId: null,
rcExternalRef: null,
},
create: {
targetId: workspaceId,
...omit(subscriptionData, 'provider', 'iapStore'),
},
});
await this.entitlement.upsertFromCloudSubscription(saved);
return saved;
return result;
}
async deleteStripeSubscription({
@@ -217,7 +184,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
);
}
await this.db.providerSubscription.updateMany({
const result = await this.db.providerSubscription.updateMany({
where: {
provider: Provider.stripe,
externalSubscriptionId: stripeSubscription.id,
@@ -228,10 +195,6 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
periodEnd: new Date(),
},
});
const result = await this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: workspaceId,
@@ -247,61 +210,104 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
}
getSubscription(identity: z.infer<typeof WorkspaceSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: {
targetId: identity.workspaceId,
},
});
return this.db.providerSubscription
.findFirst({
where: {
targetType: 'workspace',
targetId: identity.workspaceId,
plan: identity.plan,
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
getActiveSubscription(
identity: z.infer<typeof WorkspaceSubscriptionIdentity>
) {
return this.db.subscription.findFirst({
where: {
targetId: identity.workspaceId,
...activeSubscriptionWhere(),
},
});
return this.db.providerSubscription
.findFirst({
where: {
targetType: 'workspace',
targetId: identity.workspaceId,
plan: identity.plan,
...activeSubscriptionWhere(),
},
orderBy: { updatedAt: 'desc' },
})
.then(subscription =>
subscription ? this.transformProviderSubscription(subscription) : null
);
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: null,
});
return this.transformProviderSubscription(saved);
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
async resumeSubscription(subscription: Subscription) {
const current = await this.db.providerSubscription.findUniqueOrThrow({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
});
await this.db.providerSubscription.update({
where: { id: current.id },
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
const saved = await this.patchProviderSubscriptionMetadata(current.id, {
variant: subscription.variant,
stripeScheduleId: subscription.stripeScheduleId,
nextBillAt: subscription.end?.toISOString() ?? null,
});
return this.transformProviderSubscription(saved);
}
updateSubscriptionRecurring(
async updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
) {
const saved = await this.db.providerSubscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
provider_externalSubscriptionId: {
provider: Provider.stripe,
externalSubscriptionId: this.requireStripeSubscriptionId(
subscription.stripeSubscriptionId
),
},
},
data: { recurring },
});
return this.transformProviderSubscription(saved);
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
@@ -379,8 +385,14 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
) {
const { lookupKey, stripeSubscription } = known;
const price = stripeSubscription.items.data[0]?.price;
const metadata = {
...known.metadata,
variant: lookupKey.variant,
stripeScheduleId: subscriptionData.stripeScheduleId,
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
};
await this.db.providerSubscription.upsert({
return this.db.providerSubscription.upsert({
where: {
provider_externalSubscriptionId: {
provider: Provider.stripe,
@@ -410,7 +422,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
create: {
provider: Provider.stripe,
@@ -437,7 +449,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
trialStart: subscriptionData.trialStart,
trialEnd: subscriptionData.trialEnd,
canceledAt: subscriptionData.canceledAt,
metadata: known.metadata,
metadata,
},
});
}
@@ -511,13 +511,18 @@ export class UserSubscriptionResolver {
variant?: string | null;
stripeSubscriptionId?: string | null;
};
const providerMetadata = providerFact?.metadata as {
variant?: string | null;
stripeScheduleId?: string | null;
nextBillAt?: string | null;
} | null;
return this.normalizeSubscription({
stripeSubscriptionId:
providerFact?.externalSubscriptionId ??
metadata.stripeSubscriptionId ??
null,
stripeScheduleId: null,
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
plan,
recurring:
@@ -527,16 +532,24 @@ export class UserSubscriptionResolver {
? SubscriptionRecurring.Lifetime
: SubscriptionRecurring.Monthly),
variant:
providerMetadata?.variant ??
metadata.variant ??
(entitlement.plan === 'lifetime_pro'
? SubscriptionVariant.Onetime
: null),
quantity: entitlement.quantity ?? 1,
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
start:
providerFact?.periodStart ??
entitlement.startsAt ??
entitlement.createdAt,
end: providerFact?.periodEnd ?? entitlement.expiresAt,
trialStart: providerFact?.trialStart ?? null,
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
nextBillAt: providerMetadata?.nextBillAt
? new Date(providerMetadata.nextBillAt)
: providerFact?.canceledAt
? null
: (providerFact?.periodEnd ?? entitlement.expiresAt),
canceledAt: providerFact?.canceledAt ?? null,
provider: providerFact?.provider ?? metadata.provider ?? null,
iapStore: providerFact?.iapStore ?? null,
@@ -613,8 +626,11 @@ export class UserSubscriptionResolver {
throw new AuthenticationRequired();
}
let existsSubscription = await this.db.subscription.findFirst({
where: { rcExternalRef: transactionId },
const existsSubscription = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.revenuecat,
externalRef: transactionId,
},
});
// subscription with the transactionId already exists
@@ -622,8 +638,7 @@ export class UserSubscriptionResolver {
if (existsSubscription.targetId !== user.id) {
throw new InvalidSubscriptionParameters();
} else {
this.normalizeSubscription(existsSubscription);
return [existsSubscription];
return this.currentUserSubscriptions(user.id);
}
}
@@ -649,8 +664,9 @@ export class UserSubscriptionResolver {
throw new AuthenticationRequired();
}
let current = await this.db.subscription.findMany({
const current = await this.db.providerSubscription.findMany({
where: {
targetType: 'user',
targetId: user.id,
...visibleSubscriptionWhere(),
},
@@ -727,13 +743,18 @@ export class WorkspaceSubscriptionResolver {
variant?: string | null;
stripeSubscriptionId?: string | null;
};
const providerMetadata = providerFact?.metadata as {
variant?: string | null;
stripeScheduleId?: string | null;
nextBillAt?: string | null;
} | null;
return {
stripeSubscriptionId:
providerFact?.externalSubscriptionId ??
metadata.stripeSubscriptionId ??
null,
stripeScheduleId: null,
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
status:
providerFact?.status ??
(entitlement.status === 'grace'
@@ -744,13 +765,20 @@ export class WorkspaceSubscriptionResolver {
providerFact?.recurring ??
metadata.recurring ??
SubscriptionRecurring.Monthly,
variant: metadata.variant ?? null,
quantity: entitlement.quantity ?? 1,
start: entitlement.startsAt ?? entitlement.createdAt,
end: entitlement.expiresAt,
variant: providerMetadata?.variant ?? metadata.variant ?? null,
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
start:
providerFact?.periodStart ??
entitlement.startsAt ??
entitlement.createdAt,
end: providerFact?.periodEnd ?? entitlement.expiresAt,
trialStart: providerFact?.trialStart ?? null,
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
nextBillAt: providerFact?.periodEnd ?? entitlement.expiresAt,
nextBillAt: providerMetadata?.nextBillAt
? new Date(providerMetadata.nextBillAt)
: providerFact?.canceledAt
? null
: (providerFact?.periodEnd ?? entitlement.expiresAt),
canceledAt: providerFact?.canceledAt ?? null,
provider: providerFact?.provider ?? metadata.provider ?? null,
iapStore: providerFact?.iapStore ?? null,
@@ -100,6 +100,7 @@ const zRcV2RawCustomerAliasEnvelope = z
// v2 minimal, simplified structure exposed to callers
export const Subscription = z.object({
externalRef: z.string().optional(),
identifier: z.string(),
isTrial: z.boolean(),
isActive: z.boolean(),
@@ -351,6 +352,7 @@ export class RevenueCatService {
}
return {
externalRef: sub.id,
identifier: product.display_name,
isTrial: sub.status === 'trialing',
isActive:
@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { IapStore, PrismaClient, Provider } from '@prisma/client';
import {
@@ -18,12 +19,21 @@ import {
SubscriptionStatus,
} from '../types';
import { RcEvent } from './controller';
import { ProductMapping, resolveProductMapping } from './map';
import { resolveProductMapping } from './map';
import { RevenueCatService, Subscription } from './service';
const REFRESH_INTERVAL = 5 * 1000; // 5 seconds
const REFRESH_MAX_TIMES = 10 * OneMinute;
function isLegacyIdentityPlaceholder(metadata: Prisma.JsonValue) {
return (
typeof metadata === 'object' &&
metadata !== null &&
!Array.isArray(metadata) &&
metadata.legacyRevenueCatIdentityIncomplete === true
);
}
@Injectable()
export class RevenueCatWebhookHandler {
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
@@ -114,20 +124,18 @@ export class RevenueCatWebhookHandler {
externalRef?: string,
overrideExpirationDate?: Date
): Promise<boolean> {
const cond = { targetId: appUserId, provider: Provider.revenuecat };
const toBeCleanup = await this.db.subscription.findMany({
where: cond,
const toBeCleanup = await this.db.providerSubscription.findMany({
where: {
provider: Provider.revenuecat,
targetType: 'user',
targetId: appUserId,
},
});
const productOverride = this.config.payment.revenuecat?.productMap;
const removeExists = (mapping: ProductMapping, sub: Subscription) => {
// Remove from cleanup list
const index = toBeCleanup.findIndex(s => {
return (
s.targetId === appUserId &&
s.rcProductId === sub.productId &&
s.plan === mapping.plan
);
});
const removeExists = (id: string) => {
const index = toBeCleanup.findIndex(
subscription => subscription.id === id
);
if (index >= 0) {
toBeCleanup.splice(index, 1);
}
@@ -159,65 +167,77 @@ export class RevenueCatWebhookHandler {
overrideExpirationDate
);
const rcExternalRef = externalRef || this.pickExternalRef(event);
// Upsert by unique (targetId, plan) for idempotency
const rcExternalRef =
externalRef || sub.externalRef || this.pickExternalRef(event);
if (!rcExternalRef || !iapStore) {
this.logger.warn('RevenueCat subscription missing external identity', {
subscription: sub,
});
continue;
}
const start = sub.latestPurchaseDate || new Date();
const end = overrideExpirationDate || sub.expirationDate || null;
const nextBillAt = end; // period end serves as next bill anchor for IAP
if (rcExternalRef && iapStore) {
await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId:
{
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
},
update: {
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
canceledAt,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
const matched = await this.db.providerSubscription.findFirst({
where: {
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
});
const existing =
matched ??
toBeCleanup.find(
subscription =>
subscription.plan === mapping.plan &&
isLegacyIdentityPlaceholder(subscription.metadata)
);
const data = {
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
quantity: 1,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
trialStart: sub.isTrial ? start : null,
trialEnd: sub.isTrial ? end : null,
canceledAt: canceledAt ?? null,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
},
};
const saved = existing
? await this.db.providerSubscription.update({
where: { id: existing.id },
data,
})
: await this.db.providerSubscription.upsert({
where: {
provider_iapStore_externalRef_externalProductId_externalCustomerId:
{
provider: Provider.revenuecat,
iapStore,
externalRef: rcExternalRef,
externalProductId: sub.productId,
externalCustomerId: sub.customerId,
},
},
},
create: {
provider: Provider.revenuecat,
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
status,
externalCustomerId: sub.customerId,
externalProductId: sub.productId,
iapStore,
externalRef: rcExternalRef,
periodStart: start,
periodEnd: end,
canceledAt,
metadata: {
entitlement: sub.identifier,
isTrial: sub.isTrial,
willRenew: sub.willRenew,
update: data,
create: {
provider: Provider.revenuecat,
...data,
},
},
});
}
});
removeExists(saved.id);
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
await this.db.subscriptionTrialUsage.upsert({
@@ -245,8 +265,10 @@ export class RevenueCatWebhookHandler {
}
// Mutual exclusion: skip if Stripe already active for the same plan
const conflict = await this.db.subscription.findFirst({
const conflicts = await this.db.providerSubscription.findMany({
where: {
id: { not: saved.id },
targetType: 'user',
targetId: appUserId,
plan: mapping.plan,
status: {
@@ -254,13 +276,21 @@ export class RevenueCatWebhookHandler {
},
},
});
const conflict = conflicts.find(
subscription => !isLegacyIdentityPlaceholder(subscription.metadata)
);
if (conflict) {
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
subscriptionId: saved.id,
});
if (conflict.provider === Provider.stripe) {
this.logger.warn(
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
);
continue;
} else if (conflict.end && end && conflict.end > end) {
} else if (conflict.periodEnd && end && conflict.periodEnd > end) {
this.logger.warn(
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
);
@@ -269,89 +299,66 @@ export class RevenueCatWebhookHandler {
}
if (deleteInstead) {
// delete record and emit cancellation if any record removed
const result = await this.db.subscription.deleteMany({
where: {
targetId: appUserId,
plan: mapping.plan,
provider: Provider.revenuecat,
},
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
subscriptionId: saved.id,
});
if (result.count > 0) {
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: mapping.plan,
});
if (existing && existing.status !== SubscriptionStatus.Canceled) {
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
}
removeExists(mapping, sub);
continue;
}
const saved = await this.db.subscription.upsert({
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
where: {
targetId_plan: { targetId: appUserId, plan: mapping.plan },
},
update: {
recurring: mapping.recurring,
variant: null,
quantity: 1,
stripeSubscriptionId: null,
stripeScheduleId: null,
provider: Provider.revenuecat,
iapStore: iapStore,
rcEntitlement: sub.identifier ?? null,
rcProductId: sub.productId || null,
rcExternalRef: rcExternalRef,
status: status,
start,
end,
nextBillAt,
canceledAt: canceledAt ?? null,
trialStart: null,
trialEnd: null,
},
create: {
targetId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
variant: null,
quantity: 1,
stripeSubscriptionId: null,
stripeScheduleId: null,
provider: Provider.revenuecat,
iapStore: iapStore,
rcEntitlement: sub.identifier ?? null,
rcProductId: sub.productId || null,
rcExternalRef: rcExternalRef,
status: status,
start,
end,
nextBillAt,
canceledAt: canceledAt ?? null,
trialStart: null,
trialEnd: null,
},
await this.entitlement.upsertFromCloudSubscription({
targetId: saved.targetId,
plan: saved.plan,
recurring: saved.recurring ?? mapping.recurring,
status: saved.status,
quantity: saved.quantity,
provider: saved.provider,
subscriptionId: saved.id,
start: saved.periodStart,
end: saved.periodEnd,
trialStart: saved.trialStart,
trialEnd: saved.trialEnd,
canceledAt: saved.canceledAt,
});
await this.entitlement.upsertFromCloudSubscription(saved);
if (existing && existing.targetId !== saved.targetId) {
this.event.emit('entitlement.changed', {
targetType: 'user',
targetId: existing.targetId,
});
this.event.emit('user.subscription.canceled', {
userId: existing.targetId,
plan: existing.plan as SubscriptionPlan,
recurring: existing.recurring as SubscriptionRecurring,
});
}
if (
status === SubscriptionStatus.Active ||
status === SubscriptionStatus.Trialing
) {
this.event.emit('user.subscription.activated', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
if (
existing?.status !== SubscriptionStatus.Active &&
existing?.status !== SubscriptionStatus.Trialing
) {
this.event.emit('user.subscription.activated', {
userId: appUserId,
plan: mapping.plan,
recurring: mapping.recurring,
});
}
success += 1;
} else if (status !== SubscriptionStatus.PastDue) {
} else if (
status !== SubscriptionStatus.PastDue &&
existing?.status !== status
) {
// Do not emit canceled for PastDue (still within retry/grace window)
this.event.emit('user.subscription.canceled', {
userId: appUserId,
@@ -359,24 +366,23 @@ export class RevenueCatWebhookHandler {
recurring: mapping.recurring,
});
}
removeExists(mapping, sub);
}
if (toBeCleanup.length) {
for (const sub of toBeCleanup) {
await this.db.subscription.deleteMany({ where: { id: sub.id } });
await this.entitlement.revokeCloudSubscription({
targetId: appUserId,
plan: sub.plan as SubscriptionPlan,
subscriptionId: sub.id,
stripeSubscriptionId: sub.stripeSubscriptionId,
});
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: sub.plan as SubscriptionPlan,
recurring: sub.recurring as SubscriptionRecurring,
});
await this.db.providerSubscription.delete({ where: { id: sub.id } });
if (!isLegacyIdentityPlaceholder(sub.metadata)) {
this.event.emit('user.subscription.canceled', {
userId: appUserId,
plan: sub.plan as SubscriptionPlan,
recurring: sub.recurring as SubscriptionRecurring,
});
}
}
this.logger.log(
`Cleanup ${toBeCleanup.length} subscriptions for ${appUserId}`,
@@ -385,7 +391,7 @@ export class RevenueCatWebhookHandler {
subscriptions: toBeCleanup.map(s => ({
plan: s.plan,
recurring: s.recurring,
end: s.end,
end: s.periodEnd,
})),
}
);
@@ -401,16 +401,30 @@ export class SubscriptionService {
throw new InvalidLicenseSessionId();
}
let subInDB = await this.db.subscription.findUnique({
let subInDB = await this.db.providerSubscription.findUnique({
where: {
stripeSubscriptionId: subscription.id,
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.id,
},
},
});
// subscription not found in db
if (!subInDB) {
subInDB =
await this.selfhostManager.saveStripeSubscription(knownSubscription);
await this.selfhostManager.saveStripeSubscription(knownSubscription);
subInDB = await this.db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: subscription.id,
},
},
});
}
if (!subInDB) {
throw new InvalidLicenseSessionId();
}
const license = await this.db.license.findUnique({
-12
View File
@@ -174,7 +174,6 @@ type AdminWorkspace {
enableDocEmbedding: Boolean!
enableSharing: Boolean!
enableUrlPreview: Boolean!
features: [FeatureType!]!
id: String!
memberCount: Int!
@@ -1176,13 +1175,6 @@ type ExpectToUpdateDocUserRoleDataType {
enum FeatureType {
Admin
FreePlan
LifetimeProPlan
ProPlan
QuotaExceededReadonlyWorkspace
TeamPlan
UnlimitedCopilot
UnlimitedWorkspace
}
input ForkChatSessionInput {
@@ -1505,7 +1497,6 @@ input ListWorkspaceInput {
enableDocEmbedding: Boolean
enableSharing: Boolean
enableUrlPreview: Boolean
features: [FeatureType!]
first: Int! = 20
keyword: String
orderBy: AdminWorkspaceSort
@@ -2321,9 +2312,6 @@ type ServerConfigType {
"""Features for user that can be configured"""
availableUserFeatures: [FeatureType!]!
"""Workspace features available for admin configuration"""
availableWorkspaceFeatures: [FeatureType!]!
"""server base url"""
baseUrl: String!
calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]!
+2 -2
View File
@@ -18,8 +18,8 @@ examples:
$ seed User Create an User
$ seed User 3 Create 3 Users
$ seed User feature=pro_plan_v1 Create an User with Pro feature
$ seed TeamWorkspace id=xxx Seed a workspace with Team feature
$ seed User feature=administrator Create an administrator
$ seed TeamWorkspace id=xxx Seed a workspace with Team entitlement
$ seed Workspace id=xxx public=true Seed with boolean property
$ seed TeamWorkspace id=xxx quantity=10n Seed with numberic property, use \`={num}n\` suffix
`);
@@ -19,6 +19,5 @@ query adminServerConfig {
url
}
availableUserFeatures
availableWorkspaceFeatures
}
}
@@ -9,7 +9,6 @@ mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
@@ -14,7 +14,6 @@ query adminWorkspace(
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
@@ -9,7 +9,6 @@ query adminWorkspaces($filter: ListWorkspaceInput!) {
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
@@ -253,7 +253,6 @@ export const adminServerConfigQuery = {
url
}
availableUserFeatures
availableWorkspaceFeatures
}
}
${passwordLimitsFragment}
@@ -274,7 +273,6 @@ export const adminUpdateWorkspaceMutation = {
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
@@ -305,7 +303,6 @@ export const adminWorkspaceQuery = {
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
@@ -349,7 +346,6 @@ export const adminWorkspacesQuery = {
enableSharing
enableUrlPreview
enableDocEmbedding
features
owner {
id
name
-15
View File
@@ -221,7 +221,6 @@ export interface AdminWorkspace {
enableDocEmbedding: Scalars['Boolean']['output'];
enableSharing: Scalars['Boolean']['output'];
enableUrlPreview: Scalars['Boolean']['output'];
features: Array<FeatureType>;
id: Scalars['String']['output'];
memberCount: Scalars['Int']['output'];
/** Members of workspace */
@@ -1406,13 +1405,6 @@ export interface ExpectToUpdateDocUserRoleDataType {
export enum FeatureType {
Admin = 'Admin',
FreePlan = 'FreePlan',
LifetimeProPlan = 'LifetimeProPlan',
ProPlan = 'ProPlan',
QuotaExceededReadonlyWorkspace = 'QuotaExceededReadonlyWorkspace',
TeamPlan = 'TeamPlan',
UnlimitedCopilot = 'UnlimitedCopilot',
UnlimitedWorkspace = 'UnlimitedWorkspace',
}
export interface ForkChatSessionInput {
@@ -1725,7 +1717,6 @@ export interface ListWorkspaceInput {
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
features?: InputMaybe<Array<FeatureType>>;
first?: Scalars['Int']['input'];
keyword?: InputMaybe<Scalars['String']['input']>;
orderBy?: InputMaybe<AdminWorkspaceSort>;
@@ -3071,8 +3062,6 @@ export interface ServerConfigType {
availableUpgrade: Maybe<ReleaseVersionType>;
/** Features for user that can be configured */
availableUserFeatures: Array<FeatureType>;
/** Workspace features available for admin configuration */
availableWorkspaceFeatures: Array<FeatureType>;
/** server base url */
baseUrl: Scalars['String']['output'];
calendarCalDAVProviders: Array<CalendarCalDavProviderPresetObjectType>;
@@ -4076,7 +4065,6 @@ export type AdminServerConfigQuery = {
type: ServerDeploymentType;
initialized: boolean;
availableUserFeatures: Array<FeatureType>;
availableWorkspaceFeatures: Array<FeatureType>;
credentialsRequirement: {
__typename?: 'CredentialsRequirementType';
password: {
@@ -4112,7 +4100,6 @@ export type AdminUpdateWorkspaceMutation = {
enableSharing: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
@@ -4149,7 +4136,6 @@ export type AdminWorkspaceQuery = {
enableSharing: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
@@ -4198,7 +4184,6 @@ export type AdminWorkspacesQuery = {
enableSharing: boolean;
enableUrlPreview: boolean;
enableDocEmbedding: boolean;
features: Array<FeatureType>;
memberCount: number;
publicPageCount: number;
snapshotCount: number;
-12
View File
@@ -227,18 +227,6 @@
"desc": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated."
}
},
"permission": {
"readModel": {
"type": "String",
"desc": "Permission data source for Rust evaluation",
"env": "AFFINE_PERMISSION_READ_MODEL"
},
"fallbackLegacyLoader": {
"type": "Boolean",
"desc": "Fallback from projection loader to legacy loader when projection input loading fails",
"env": "AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER"
}
},
"storages": {
"avatar.publicPath": {
"type": "String",
@@ -35,20 +35,6 @@ export const useColumns = () => {
<div className="w-full truncate font-mono text-xs text-muted-foreground">
{workspace.id}
</div>
<div className="flex flex-wrap gap-2 text-xxs">
{workspace.features.length ? (
workspace.features.map(feature => (
<span
key={feature}
className="rounded-md border border-border/60 bg-chip-white px-2 py-0.5"
>
{feature}
</span>
))
) : (
<span className="text-muted-foreground">No features</span>
)}
</div>
</div>
);
},
@@ -1,6 +1,5 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import type { FeatureType } from '@affine/graphql';
import { AdminWorkspaceSort } from '@affine/graphql';
import type { Table } from '@tanstack/react-table';
import {
@@ -11,22 +10,18 @@ import {
useState,
} from 'react';
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../../components/ui/popover';
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
import { useServerConfig } from '../../common';
import type { WorkspaceFlagFilter } from '../schema';
interface DataTableToolbarProps<TData> {
table?: Table<TData>;
keyword: string;
onKeywordChange: (keyword: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
flags: WorkspaceFlagFilter;
onFlagsChange: (flags: WorkspaceFlagFilter) => void;
sort: AdminWorkspaceSort | undefined;
@@ -47,8 +42,6 @@ const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
export function DataTableToolbar<TData>({
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
flags,
onFlagsChange,
sort,
@@ -57,8 +50,6 @@ export function DataTableToolbar<TData>({
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState(keyword);
const debouncedValue = useDebouncedValue(value, 400);
const serverConfig = useServerConfig();
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
useEffect(() => {
setValue(keyword);
@@ -116,15 +107,7 @@ export function DataTableToolbar<TData>({
);
return (
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
<FeatureFilterPopover
selectedFeatures={selectedFeatures}
availableFeatures={availableFeatures}
onChange={onFeaturesChange}
align="start"
disabled={disabled}
/>
<div className="flex items-center justify-end gap-y-2 gap-x-4 flex-wrap">
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
<Popover open={disabled ? false : undefined}>
<PopoverTrigger asChild>
@@ -1,4 +1,4 @@
import type { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
import type { AdminWorkspaceSort } from '@affine/graphql';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import type { Dispatch, SetStateAction } from 'react';
@@ -13,8 +13,6 @@ interface DataTableProps<TData, TValue> {
workspacesCount: number;
keyword: string;
onKeywordChange: (value: string) => void;
selectedFeatures: FeatureType[];
onFeaturesChange: (features: FeatureType[]) => void;
flags: WorkspaceFlagFilter;
onFlagsChange: Dispatch<SetStateAction<WorkspaceFlagFilter>>;
sort: AdminWorkspaceSort | undefined;
@@ -35,8 +33,6 @@ export function DataTable<TData extends { id: string }, TValue>({
workspacesCount,
keyword,
onKeywordChange,
selectedFeatures,
onFeaturesChange,
flags,
onFlagsChange,
sort,
@@ -51,14 +47,12 @@ export function DataTable<TData extends { id: string }, TValue>({
totalCount={workspacesCount}
pagination={pagination}
onPaginationChange={onPaginationChange}
resetFiltersDeps={[keyword, selectedFeatures, sort, flags]}
resetFiltersDeps={[keyword, sort, flags]}
renderToolbar={table => (
<DataTableToolbar
table={table}
keyword={keyword}
onKeywordChange={onKeywordChange}
selectedFeatures={selectedFeatures}
onFeaturesChange={onFeaturesChange}
flags={flags}
onFlagsChange={onFlagsChange}
sort={sort}

Some files were not shown because too many files have changed in this diff Show More