mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 01:26:37 +08:00
feat(server): cleanup legacy compatibility (#15239)
This commit is contained in:
@@ -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",
|
||||
|
||||
+494
@@ -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
|
||||
$$;
|
||||
+73
@@ -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";
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
|
||||
@@ -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
-28
@@ -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
-8
@@ -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> }
|
||||
>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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!]!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user