mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-06 08:50:50 +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": {
|
"storages": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Configuration for storages module",
|
"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[]
|
features UserFeature[]
|
||||||
userStripeCustomer UserStripeCustomer?
|
userStripeCustomer UserStripeCustomer?
|
||||||
workspaces WorkspaceUserRole[]
|
|
||||||
workspaceMembers WorkspaceMember[]
|
workspaceMembers WorkspaceMember[]
|
||||||
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
|
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
|
||||||
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
|
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
|
||||||
// Invite others to join the workspace
|
// Invite others to join the workspace
|
||||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
|
||||||
docPermissions WorkspaceDocUserRole[]
|
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
calendarAccounts CalendarAccount[]
|
calendarAccounts CalendarAccount[]
|
||||||
mcpCredentials McpCredential[]
|
mcpCredentials McpCredential[]
|
||||||
@@ -181,12 +178,9 @@ model Workspace {
|
|||||||
// NOTE: manually set this column type to identity in migration file
|
// NOTE: manually set this column type to identity in migration file
|
||||||
sid Int @unique @default(autoincrement())
|
sid Int @unique @default(autoincrement())
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
public Boolean
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
// workspace level feature flags
|
// workspace level feature flags
|
||||||
enableAi Boolean @default(true) @map("enable_ai")
|
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")
|
enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding")
|
||||||
name String? @db.VarChar
|
name String? @db.VarChar
|
||||||
avatarKey String? @map("avatar_key") @db.VarChar
|
avatarKey String? @map("avatar_key") @db.VarChar
|
||||||
@@ -195,8 +189,6 @@ model Workspace {
|
|||||||
|
|
||||||
features WorkspaceFeature[]
|
features WorkspaceFeature[]
|
||||||
docs WorkspaceDoc[]
|
docs WorkspaceDoc[]
|
||||||
permissions WorkspaceUserRole[]
|
|
||||||
docPermissions WorkspaceDocUserRole[]
|
|
||||||
blobs Blob[]
|
blobs Blob[]
|
||||||
ignoredDocs AiWorkspaceIgnoredDocs[]
|
ignoredDocs AiWorkspaceIgnoredDocs[]
|
||||||
embedFiles AiWorkspaceFiles[]
|
embedFiles AiWorkspaceFiles[]
|
||||||
@@ -210,11 +202,10 @@ model Workspace {
|
|||||||
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
|
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
|
||||||
workspaceDocViewDaily WorkspaceDocViewDaily[]
|
workspaceDocViewDaily WorkspaceDocViewDaily[]
|
||||||
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
|
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
|
||||||
runtimeState WorkspaceRuntimeState?
|
|
||||||
quotaState EffectiveWorkspaceQuotaState?
|
quotaState EffectiveWorkspaceQuotaState?
|
||||||
accessPolicy WorkspaceAccessPolicy?
|
accessPolicy WorkspaceAccessPolicy?
|
||||||
projectedMembers WorkspaceMember[]
|
members WorkspaceMember[]
|
||||||
projectedInvitations WorkspaceInvitation[]
|
invitations WorkspaceInvitation[]
|
||||||
docAccessPolicies DocAccessPolicy[]
|
docAccessPolicies DocAccessPolicy[]
|
||||||
docGrants DocGrant[]
|
docGrants DocGrant[]
|
||||||
mcpCredentials McpCredential[]
|
mcpCredentials McpCredential[]
|
||||||
@@ -224,21 +215,6 @@ model Workspace {
|
|||||||
@@map("workspaces")
|
@@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 {
|
model Entitlement {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
targetType String @map("target_type") @db.Text
|
targetType String @map("target_type") @db.Text
|
||||||
@@ -328,15 +304,14 @@ model EffectiveWorkspaceQuotaState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WorkspaceMember {
|
model WorkspaceMember {
|
||||||
id String @id @default(dbgenerated()) @db.VarChar
|
id String @id @default(dbgenerated()) @db.VarChar
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
role String @db.Text
|
role String @db.Text
|
||||||
state String @default("active") @db.Text
|
state String @default("active") @db.Text
|
||||||
source String @default("legacy") @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)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -348,21 +323,20 @@ model WorkspaceMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model WorkspaceInvitation {
|
model WorkspaceInvitation {
|
||||||
id String @id @default(dbgenerated()) @db.VarChar
|
id String @id @default(dbgenerated()) @db.VarChar
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
inviteeUserId String? @map("invitee_user_id") @db.VarChar
|
inviteeUserId String? @map("invitee_user_id") @db.VarChar
|
||||||
normalizedEmail String? @map("normalized_email") @db.VarChar
|
normalizedEmail String? @map("normalized_email") @db.VarChar
|
||||||
inviterUserId String? @map("inviter_user_id") @db.VarChar
|
inviterUserId String? @map("inviter_user_id") @db.VarChar
|
||||||
requestedRole String @default("member") @map("requested_role") @db.Text
|
requestedRole String @default("member") @map("requested_role") @db.Text
|
||||||
status String @db.Text
|
status String @db.Text
|
||||||
kind String @default("email") @db.Text
|
kind String @default("email") @db.Text
|
||||||
// Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL.
|
// Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL.
|
||||||
tokenHash String? @map("token_hash") @db.Text
|
tokenHash String? @map("token_hash") @db.Text
|
||||||
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
|
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
|
||||||
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_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)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
|
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
|
||||||
@@ -462,22 +436,18 @@ model MailDelivery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model DocGrant {
|
model DocGrant {
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
docId String @map("doc_id") @db.VarChar
|
docId String @map("doc_id") @db.VarChar
|
||||||
principalType String @map("principal_type") @db.Text
|
principalType String @map("principal_type") @db.Text
|
||||||
principalId String @map("principal_id") @db.VarChar
|
principalId String @map("principal_id") @db.VarChar
|
||||||
role String @db.Text
|
role String @db.Text
|
||||||
grantedBy String? @map("granted_by") @db.VarChar
|
grantedBy String? @map("granted_by") @db.VarChar
|
||||||
legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
legacyDocId String? @map("legacy_doc_id") @db.VarChar
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
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)
|
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([workspaceId, docId, principalType, principalId])
|
@@id([workspaceId, docId, principalType, principalId])
|
||||||
// Partial unique index exists in migration for non-null legacy ids.
|
|
||||||
@@index([principalType, principalId, role])
|
@@index([principalType, principalId, role])
|
||||||
@@index([workspaceId, docId, role])
|
@@index([workspaceId, docId, role])
|
||||||
@@map("doc_grants")
|
@@map("doc_grants")
|
||||||
@@ -491,9 +461,6 @@ model DocGrant {
|
|||||||
model WorkspaceDoc {
|
model WorkspaceDoc {
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
docId String @map("page_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
|
// Page/Edgeless
|
||||||
mode Int @default(0) @db.SmallInt
|
mode Int @default(0) @db.SmallInt
|
||||||
// Whether the doc is blocked
|
// Whether the doc is blocked
|
||||||
@@ -505,8 +472,6 @@ model WorkspaceDoc {
|
|||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([workspaceId, docId])
|
@@id([workspaceId, docId])
|
||||||
@@index([workspaceId, public])
|
|
||||||
@@index([public, publishedAt])
|
|
||||||
@@map("workspace_pages")
|
@@map("workspace_pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,45 +497,6 @@ enum WorkspaceMemberSource {
|
|||||||
Link
|
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 {
|
model UserFeature {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
@@ -623,7 +549,6 @@ model WorkspaceAdminStats {
|
|||||||
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
|
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
|
||||||
memberCount BigInt @default(0) @map("member_count") @db.BigInt
|
memberCount BigInt @default(0) @map("member_count") @db.BigInt
|
||||||
publicPageCount BigInt @default(0) @map("public_page_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)
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
@@ -1174,50 +1099,6 @@ model UserStripeCustomer {
|
|||||||
@@map("user_stripe_customers")
|
@@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 {
|
model ProviderSubscription {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
provider Provider
|
provider Provider
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Sinon from 'sinon';
|
|||||||
|
|
||||||
import { AuthSessionService, CurrentUser } from '../../core/auth';
|
import { AuthSessionService, CurrentUser } from '../../core/auth';
|
||||||
import { AuthService } from '../../core/auth/service';
|
import { AuthService } from '../../core/auth/service';
|
||||||
|
import { EntitlementModule } from '../../core/entitlement';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
import { QuotaModule } from '../../core/quota';
|
import { QuotaModule } from '../../core/quota';
|
||||||
import { UserModule } from '../../core/user';
|
import { UserModule } from '../../core/user';
|
||||||
@@ -21,7 +22,7 @@ const test = ava as TestFn<{
|
|||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
const m = await createTestingModule({
|
const m = await createTestingModule({
|
||||||
imports: [QuotaModule, FeatureModule, UserModule],
|
imports: [EntitlementModule, QuotaModule, FeatureModule, UserModule],
|
||||||
providers: [AuthService],
|
providers: [AuthService],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
|||||||
import ava, { type ExecutionContext, type TestFn } from 'ava';
|
import ava, { type ExecutionContext, type TestFn } from 'ava';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { Cache, CryptoHelper } from '../../base';
|
import { Cache, ConfigFactory, CryptoHelper } from '../../base';
|
||||||
import { EntitlementService } from '../../core/entitlement';
|
import { EntitlementService } from '../../core/entitlement';
|
||||||
import { Models, WorkspaceRole } from '../../models';
|
import { Models, WorkspaceRole } from '../../models';
|
||||||
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
|
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 => {
|
test('local leases persist normalized custom endpoints', async t => {
|
||||||
const customEndpointSupported = Sinon.stub(
|
const config = t.context.module.get(ConfigFactory);
|
||||||
t.context.byok,
|
const deploymentType = globalThis.env.DEPLOYMENT_TYPE;
|
||||||
'customEndpointSupported'
|
Object.assign(globalThis.env, { DEPLOYMENT_TYPE: 'selfhosted' });
|
||||||
).get(() => true);
|
t.false(t.context.byok.customEndpointSupported);
|
||||||
t.teardown(() => customEndpointSupported.restore());
|
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);
|
const { user, workspace } = await createUserWorkspace(t);
|
||||||
await grantUserPlan(t, user.id);
|
await grantUserPlan(t, user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ test('should be able to merge updates as snapshot', async t => {
|
|||||||
await db.workspace.create({
|
await db.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
id: '1',
|
id: '1',
|
||||||
public: false,
|
accessPolicy: { create: {} },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
|
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { Workspace, WorkspaceRole } from '../../../models';
|
import { Workspace, WorkspaceRole } from '../../../models';
|
||||||
|
import { LicenseService } from '../../../plugins/license/service';
|
||||||
|
import {
|
||||||
|
SubscriptionRecurring,
|
||||||
|
SubscriptionVariant as PaymentSubscriptionVariant,
|
||||||
|
} from '../../../plugins/payment/types';
|
||||||
import {
|
import {
|
||||||
createApp,
|
createApp,
|
||||||
e2e,
|
e2e,
|
||||||
@@ -70,6 +76,59 @@ e2e('should install file license', async t => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
t.is(res.installLicense.variant, SubscriptionVariant.Onetime);
|
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 => {
|
e2e('should not allow to install license if not owner', async t => {
|
||||||
|
|||||||
@@ -298,9 +298,9 @@ e2e(
|
|||||||
|
|
||||||
await db.$executeRaw`
|
await db.$executeRaw`
|
||||||
INSERT INTO workspace_admin_stats (
|
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)
|
ON CONFLICT (workspace_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
snapshot_count = EXCLUDED.snapshot_count,
|
snapshot_count = EXCLUDED.snapshot_count,
|
||||||
@@ -309,7 +309,6 @@ e2e(
|
|||||||
blob_size = EXCLUDED.blob_size,
|
blob_size = EXCLUDED.blob_size,
|
||||||
member_count = EXCLUDED.member_count,
|
member_count = EXCLUDED.member_count,
|
||||||
public_page_count = EXCLUDED.public_page_count,
|
public_page_count = EXCLUDED.public_page_count,
|
||||||
features = EXCLUDED.features,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
updated_at = EXCLUDED.updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -479,9 +478,9 @@ e2e(
|
|||||||
|
|
||||||
await db.$executeRaw`
|
await db.$executeRaw`
|
||||||
INSERT INTO workspace_admin_stats (
|
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)
|
ON CONFLICT (workspace_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
snapshot_count = EXCLUDED.snapshot_count,
|
snapshot_count = EXCLUDED.snapshot_count,
|
||||||
@@ -490,7 +489,6 @@ e2e(
|
|||||||
blob_size = EXCLUDED.blob_size,
|
blob_size = EXCLUDED.blob_size,
|
||||||
member_count = EXCLUDED.member_count,
|
member_count = EXCLUDED.member_count,
|
||||||
public_page_count = EXCLUDED.public_page_count,
|
public_page_count = EXCLUDED.public_page_count,
|
||||||
features = EXCLUDED.features,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
updated_at = EXCLUDED.updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
revokePublicPageMutation,
|
revokePublicPageMutation,
|
||||||
WorkspaceMemberStatus,
|
WorkspaceMemberStatus,
|
||||||
} from '@affine/graphql';
|
} 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 { QuotaService } from '../../../core/quota/service';
|
||||||
import { WorkspaceRole } from '../../../models';
|
import { WorkspaceRole } from '../../../models';
|
||||||
import {
|
import {
|
||||||
@@ -109,8 +110,9 @@ const cancelTeamWorkspace = async (workspaceId: string) => {
|
|||||||
},
|
},
|
||||||
data: { status: 'revoked' },
|
data: { status: 'revoked' },
|
||||||
});
|
});
|
||||||
await db.subscription.updateMany({
|
await db.providerSubscription.updateMany({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'workspace',
|
||||||
targetId: workspaceId,
|
targetId: workspaceId,
|
||||||
plan: SubscriptionPlan.Team,
|
plan: SubscriptionPlan.Team,
|
||||||
},
|
},
|
||||||
@@ -176,16 +178,16 @@ e2e('should allocate seats', async t => {
|
|||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Email',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const u2 = await app.createUser();
|
const u2 = await app.createUser();
|
||||||
await app.create(Mockers.WorkspaceUser, {
|
const linkInvitation = await app.create(Mockers.WorkspaceUser, {
|
||||||
userId: u2.id,
|
userId: u2.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Link',
|
kind: 'link',
|
||||||
});
|
});
|
||||||
|
t.is(linkInvitation.source, WorkspaceMemberSource.Link);
|
||||||
|
|
||||||
const invitationCount = app.queue.count('notification.sendInvitation');
|
const invitationCount = app.queue.count('notification.sendInvitation');
|
||||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||||
@@ -219,7 +221,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
|||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Email',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const u2 = await app.createUser();
|
const u2 = await app.createUser();
|
||||||
@@ -227,7 +228,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
|||||||
userId: u2.id,
|
userId: u2.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Email',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const u3 = await app.createUser();
|
const u3 = await app.createUser();
|
||||||
@@ -235,7 +235,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
|
|||||||
userId: u3.id,
|
userId: u3.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Link',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
|
||||||
@@ -275,7 +274,6 @@ e2e(
|
|||||||
userId: allocating.id,
|
userId: allocating.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||||
source: 'Email',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const underReview = await app.create(Mockers.User);
|
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.workspace.isTeamWorkspace(workspace.id));
|
||||||
t.false(
|
t.false(
|
||||||
await app.models.workspaceFeature.has(
|
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||||
workspace.id,
|
.isReadonly
|
||||||
'quota_exceeded_readonly_workspace_v1'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
|
(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.false(await app.models.workspace.isTeamWorkspace(workspace.id));
|
||||||
t.true(
|
t.true(
|
||||||
await app.models.workspaceFeature.has(
|
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||||
workspace.id,
|
.isReadonly
|
||||||
'quota_exceeded_readonly_workspace_v1'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
|
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
|
||||||
@@ -371,10 +365,8 @@ e2e(
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.false(
|
t.false(
|
||||||
await app.models.workspaceFeature.has(
|
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
|
||||||
workspace.id,
|
.isReadonly
|
||||||
'quota_exceeded_readonly_workspace_v1'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
import type { WorkspaceDoc } from '@prisma/client';
|
import type { WorkspaceDoc } from '@prisma/client';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { DocRole } from '../../models';
|
||||||
import { Mocker } from './factory';
|
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> {
|
export class MockDocMeta extends Mocker<MockDocMetaInput, MockedDocMeta> {
|
||||||
override async create(input: MockDocMetaInput) {
|
override async create(input: MockDocMetaInput) {
|
||||||
return await this.db.workspaceDoc.create({
|
const {
|
||||||
data: input,
|
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 { DocRole } from '../../models';
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
|
|
||||||
import { Mocker } from './factory';
|
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> {
|
export class MockDocUser extends Mocker<MockDocUserInput, MockedDocUser> {
|
||||||
override async create(input: MockDocUserInput) {
|
override async create(input: MockDocUserInput) {
|
||||||
return await this.db.workspaceDocUserRole.create({
|
const grant = await this.db.docGrant.create({
|
||||||
data: input,
|
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 { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
import { Feature, FeatureType } from '../../models';
|
|
||||||
import { Mocker } from './factory';
|
import { Mocker } from './factory';
|
||||||
|
|
||||||
interface MockTeamWorkspaceInput {
|
interface MockTeamWorkspaceInput {
|
||||||
@@ -16,17 +15,6 @@ export class MockTeamWorkspace extends Mocker<
|
|||||||
const id = input?.id ?? faker.string.uuid();
|
const id = input?.id ?? faker.string.uuid();
|
||||||
const quantity = input?.quantity ?? 10;
|
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({
|
await this.db.entitlement.create({
|
||||||
data: {
|
data: {
|
||||||
targetType: 'workspace',
|
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 };
|
return { id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
import type { Prisma, WorkspaceUserRole } from '@prisma/client';
|
import {
|
||||||
|
WorkspaceMemberStatus,
|
||||||
import { WorkspaceMemberStatus, WorkspaceRole } from '../../models';
|
WorkspaceRole,
|
||||||
|
type WorkspaceUserCompat,
|
||||||
|
} from '../../models';
|
||||||
|
import { workspaceInvitationToCompat } from '../../models/workspace-user-compat';
|
||||||
import { Mocker } from './factory';
|
import { Mocker } from './factory';
|
||||||
|
|
||||||
export type MockWorkspaceUserInput = Omit<
|
export type MockWorkspaceUserInput = {
|
||||||
Prisma.WorkspaceUserRoleUncheckedCreateInput,
|
workspaceId: string;
|
||||||
'type'
|
userId: string;
|
||||||
> & {
|
|
||||||
type?: WorkspaceRole;
|
type?: WorkspaceRole;
|
||||||
|
status?: WorkspaceMemberStatus;
|
||||||
|
inviterId?: string | null;
|
||||||
|
kind?: 'email' | 'link';
|
||||||
};
|
};
|
||||||
|
|
||||||
export class MockWorkspaceUser extends Mocker<
|
export class MockWorkspaceUser extends Mocker<
|
||||||
MockWorkspaceUserInput,
|
MockWorkspaceUserInput,
|
||||||
WorkspaceUserRole
|
WorkspaceUserCompat
|
||||||
> {
|
> {
|
||||||
override async create(input: MockWorkspaceUserInput) {
|
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: {
|
data: {
|
||||||
type: WorkspaceRole.Collaborator,
|
workspaceId: input.workspaceId,
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
inviteeUserId: input.userId,
|
||||||
...input,
|
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 path from 'node:path';
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker';
|
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 { omit } from 'lodash-es';
|
||||||
|
|
||||||
import { WorkspaceRole } from '../../models';
|
import type { Workspace } from '../../models';
|
||||||
import { Mocker } from './factory';
|
import { Mocker } from './factory';
|
||||||
|
|
||||||
export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & {
|
export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & {
|
||||||
owner?: { id: string };
|
owner?: { id: string };
|
||||||
snapshot?: Uint8Array | true;
|
snapshot?: Uint8Array | true;
|
||||||
|
public?: boolean;
|
||||||
|
enableSharing?: boolean;
|
||||||
|
enableUrlPreview?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MockedWorkspace = Workspace;
|
export type MockedWorkspace = Workspace;
|
||||||
@@ -28,72 +31,51 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
|
|||||||
input.snapshot = snapshot;
|
input.snapshot = snapshot;
|
||||||
}
|
}
|
||||||
const snapshot = input?.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({
|
const workspace = await this.db.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
name: faker.animal.cat(),
|
name: faker.animal.cat(),
|
||||||
public: false,
|
|
||||||
...input,
|
...input,
|
||||||
permissions: owner
|
accessPolicy: {
|
||||||
|
create: {
|
||||||
|
visibility: isPublic ? 'public' : 'private',
|
||||||
|
sharingEnabled: enableSharing,
|
||||||
|
urlPreviewEnabled: enableUrlPreview,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: owner
|
||||||
? {
|
? {
|
||||||
create: {
|
create: {
|
||||||
userId: owner.id,
|
userId: owner.id,
|
||||||
type: WorkspaceRole.Owner,
|
role: 'owner',
|
||||||
status: 'Accepted',
|
state: 'active',
|
||||||
|
source: 'legacy',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const runtimeStateColumns = await this.db.$queryRaw<
|
await this.db.effectiveWorkspaceQuotaState.create({
|
||||||
Array<{ exists: boolean }>
|
data: {
|
||||||
>`
|
workspaceId: workspace.id,
|
||||||
SELECT EXISTS (
|
plan: 'free',
|
||||||
SELECT 1
|
seatLimit: 0,
|
||||||
FROM information_schema.columns
|
blobLimit: 0,
|
||||||
WHERE table_name = 'workspace_runtime_states'
|
storageQuota: 0,
|
||||||
AND column_name = 'known'
|
historyPeriodSeconds: 0,
|
||||||
) AS "exists"
|
known: true,
|
||||||
`;
|
},
|
||||||
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()
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// create a rootDoc snapshot
|
// create a rootDoc snapshot
|
||||||
if (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() {
|
async function create() {
|
||||||
return db.workspace.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) => ({
|
data: Array.from({ length: 200 }, (_, i) => ({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
docId,
|
docId,
|
||||||
userId: String(i),
|
principalType: 'user',
|
||||||
type: DocRole.Editor,
|
principalId: String(i),
|
||||||
|
role: 'editor',
|
||||||
createdAt: new Date(Date.now() + i * 1000),
|
createdAt: new Date(Date.now() + i * 1000),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import ava, { TestFn } from 'ava';
|
|||||||
|
|
||||||
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
|
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
|
||||||
import { AvailableUserFeatureConfig } from '../../core/features/types';
|
import { AvailableUserFeatureConfig } from '../../core/features/types';
|
||||||
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
|
import {
|
||||||
import { Feature } from '../../models/common/feature';
|
Feature,
|
||||||
|
FeatureType,
|
||||||
|
UserFeatureModel,
|
||||||
|
UserModel,
|
||||||
|
} from '../../models';
|
||||||
import { createTestingModule, TestingModule } from '../utils';
|
import { createTestingModule, TestingModule } from '../utils';
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
@@ -18,7 +22,6 @@ const test = ava as TestFn<Context>;
|
|||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
const module = await createTestingModule({});
|
const module = await createTestingModule({});
|
||||||
|
|
||||||
t.context.model = module.get(UserFeatureModel);
|
t.context.model = module.get(UserFeatureModel);
|
||||||
t.context.resolver = module.get(AdminFeatureManagementResolver);
|
t.context.resolver = module.get(AdminFeatureManagementResolver);
|
||||||
t.context.module = module;
|
t.context.module = module;
|
||||||
@@ -36,140 +39,39 @@ test.after(async t => {
|
|||||||
await t.context.module.close();
|
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();
|
const config = new AvailableUserFeatureConfig();
|
||||||
|
t.deepEqual([...config.availableUserFeatures()], [Feature.Admin]);
|
||||||
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
|
t.deepEqual([...config.configurableUserFeatures()], [Feature.Admin]);
|
||||||
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), []);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should get null if user feature not found', async t => {
|
test('should get null if user feature not found', async t => {
|
||||||
const { model, u1 } = t.context;
|
t.is(await t.context.model.get(t.context.u1.id, Feature.Admin), null);
|
||||||
const userFeature = await model.get(u1.id, 'administrator');
|
|
||||||
t.is(userFeature, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should get user feature', async t => {
|
test('should add and get user feature', async t => {
|
||||||
const { model, u1 } = t.context;
|
const { model, u1 } = t.context;
|
||||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
await model.add(u1.id, Feature.Admin, 'test');
|
||||||
const userFeature = await model.get(u1.id, 'free_plan_v1');
|
t.is((await model.get(u1.id, Feature.Admin))?.name, Feature.Admin);
|
||||||
t.is(userFeature?.name, 'free_plan_v1');
|
t.true(await model.has(u1.id, Feature.Admin));
|
||||||
});
|
t.deepEqual(await model.list(u1.id, FeatureType.Feature), [Feature.Admin]);
|
||||||
|
|
||||||
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'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not add existing user feature', async t => {
|
test('should not add existing user feature', async t => {
|
||||||
const { model, u1 } = t.context;
|
const { model, u1 } = t.context;
|
||||||
|
await model.add(u1.id, Feature.Admin, 'test');
|
||||||
await model.add(u1.id, 'free_plan_v1', 'test');
|
await model.add(u1.id, Feature.Admin, 'test');
|
||||||
await model.add(u1.id, 'free_plan_v1', 'test');
|
t.deepEqual(await model.list(u1.id), [Feature.Admin]);
|
||||||
|
|
||||||
t.like(await model.list(u1.id), ['free_plan_v1']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should remove user feature', async t => {
|
test('should remove user feature', async t => {
|
||||||
const { model, u1 } = t.context;
|
const { model, u1 } = t.context;
|
||||||
|
await model.add(u1.id, Feature.Admin, 'test');
|
||||||
await model.remove(u1.id, 'free_plan_v1');
|
await model.remove(u1.id, Feature.Admin);
|
||||||
t.false(await model.has(u1.id, 'free_plan_v1'));
|
t.false(await model.has(u1.id, Feature.Admin));
|
||||||
t.false((await model.list(u1.id)).includes('free_plan_v1'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should switch user quota', async t => {
|
test('admin resolver updates administrator feature', async t => {
|
||||||
const { model, u1 } = t.context;
|
await t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.Admin]);
|
||||||
|
t.true(await t.context.model.has(t.context.u1.id, Feature.Admin));
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 => {
|
test('should get feature', async t => {
|
||||||
const { feature } = t.context;
|
const { feature } = t.context;
|
||||||
const freePlanFeature = await feature.get('free_plan_v1');
|
const adminFeature = await feature.get('administrator');
|
||||||
|
|
||||||
t.snapshot(freePlanFeature.configs);
|
t.deepEqual(adminFeature.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',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 Sinon from 'sinon';
|
||||||
|
|
||||||
import { EventBus, NewOwnerIsNotActiveMember } from '../../base';
|
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 { createModule, TestingModule } from '../create-module';
|
||||||
import { Mockers } from '../mocks';
|
import { Mockers } from '../mocks';
|
||||||
|
|
||||||
@@ -48,10 +53,12 @@ test('should transfer workespace owner', async t => {
|
|||||||
owner: { id: user.id },
|
owner: { id: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
await module.create(Mockers.WorkspaceUser, {
|
await models.workspaceUser.set(
|
||||||
workspaceId: workspace.id,
|
workspace.id,
|
||||||
userId: user2.id,
|
user2.id,
|
||||||
});
|
WorkspaceRole.Collaborator,
|
||||||
|
{ status: WorkspaceMemberStatus.Accepted }
|
||||||
|
);
|
||||||
|
|
||||||
await models.workspaceUser.setOwner(workspace.id, user2.id);
|
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,
|
id: workspace.id,
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
});
|
});
|
||||||
await module.create(Mockers.WorkspaceUser, {
|
await models.workspaceUser.set(
|
||||||
workspaceId: workspace.id,
|
workspace.id,
|
||||||
userId: user2.id,
|
user2.id,
|
||||||
});
|
WorkspaceRole.Collaborator,
|
||||||
|
{ status: WorkspaceMemberStatus.Accepted }
|
||||||
|
);
|
||||||
|
|
||||||
await models.workspaceUser.setOwner(workspace.id, user2.id);
|
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,
|
instanceOf: NewOwnerIsNotActiveMember,
|
||||||
});
|
});
|
||||||
|
|
||||||
await module.create(Mockers.WorkspaceUser, {
|
await models.workspaceUser.set(
|
||||||
workspaceId: workspace.id,
|
workspace.id,
|
||||||
userId: user2.id,
|
user2.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
WorkspaceRole.Collaborator,
|
||||||
});
|
{ status: WorkspaceMemberStatus.AllocatingSeat }
|
||||||
|
);
|
||||||
|
|
||||||
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
|
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
|
||||||
instanceOf: NewOwnerIsNotActiveMember,
|
instanceOf: NewOwnerIsNotActiveMember,
|
||||||
@@ -231,22 +241,7 @@ test('should delete workspace user role', async t => {
|
|||||||
t.is(role, null);
|
t.is(role, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete legacy-only external workspace user 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);
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
const workspace = await module.create(Mockers.Workspace);
|
const workspace = await module.create(Mockers.Workspace);
|
||||||
const u1 = await module.create(Mockers.User);
|
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,
|
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, {
|
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const role = await models.workspaceUser.get(workspace.id, u1.id);
|
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
|
||||||
t.is(role?.type, WorkspaceRole.External);
|
|
||||||
t.is(
|
t.is(
|
||||||
await db.workspaceMember.count({
|
await db.workspaceMember.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
state: 'active',
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
});
|
t.is(
|
||||||
|
await db.workspaceInvitation.count({
|
||||||
test('should backfill legacy permission id for new workspace member writes', async t => {
|
where: {
|
||||||
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: {
|
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId: u1.id,
|
inviteeUserId: u1.id,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
0
|
||||||
const member = await db.workspaceMember.findFirstOrThrow({
|
);
|
||||||
where: {
|
t.is(
|
||||||
workspaceId: workspace.id,
|
(await models.docUser.get(workspace.id, docId, u1.id))?.type,
|
||||||
userId: u1.id,
|
DocRole.Editor
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 => {
|
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 ws2 = await module.create(Mockers.Workspace);
|
||||||
const user = await module.create(Mockers.User);
|
const user = await module.create(Mockers.User);
|
||||||
|
|
||||||
await db.workspaceUserRole.createMany({
|
await db.workspaceMember.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
workspaceId: ws1.id,
|
workspaceId: ws1.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: WorkspaceRole.Admin,
|
role: 'admin',
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
state: 'active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
workspaceId: ws2.id,
|
workspaceId: ws2.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: WorkspaceRole.Collaborator,
|
role: 'member',
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
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) => ({
|
data: users.map((user, i) => ({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: WorkspaceRole.Collaborator,
|
role: 'member',
|
||||||
status: Object.values(WorkspaceMemberStatus)[
|
state: 'active',
|
||||||
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
|
|
||||||
],
|
|
||||||
createdAt: new Date(Date.now() + i * 1000),
|
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);
|
const workspace = await module.create(Mockers.Workspace);
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
await module.create(Mockers.WorkspaceUser, {
|
await models.workspaceUser.set(
|
||||||
workspaceId: workspace.id,
|
workspace.id,
|
||||||
userId: user.id,
|
user.id,
|
||||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
WorkspaceRole.Collaborator,
|
||||||
});
|
{ status: WorkspaceMemberStatus.AllocatingSeat }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await models.workspaceUser.allocateSeats(workspace.id, 1);
|
await models.workspaceUser.allocateSeats(workspace.id, 1);
|
||||||
|
|
||||||
let count = await db.workspaceUserRole.count({
|
let count = await db.workspaceInvitation.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: workspace.id,
|
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);
|
await models.workspaceUser.allocateSeats(workspace.id, 3);
|
||||||
|
|
||||||
count = await db.workspaceUserRole.count({
|
count = await db.workspaceInvitation.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
status: WorkspaceMemberStatus.Pending,
|
status: 'pending',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
activatedCount: 1,
|
activatedCount: 1,
|
||||||
canceledCount: 0,
|
canceledCount: 0,
|
||||||
dbObservability: {
|
dbObservability: {
|
||||||
|
externalProductId: 'app.affine.pro.Annual',
|
||||||
|
externalRef: 'rc:app.affine.pro.Annual',
|
||||||
iapStore: 'app_store',
|
iapStore: 'app_store',
|
||||||
|
metadata: {
|
||||||
|
entitlement: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
willRenew: true,
|
||||||
|
},
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
rcEntitlement: 'Pro',
|
|
||||||
rcExternalRef: 'orig-tx-1',
|
|
||||||
rcProductId: 'app.affine.pro.Annual',
|
|
||||||
},
|
},
|
||||||
lastActivated: {
|
lastActivated: {
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
@@ -53,14 +57,14 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
subscriberCount: 1,
|
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
|
> should process expiration/refund and emit canceled
|
||||||
|
|
||||||
{
|
{
|
||||||
activatedEventCount: 0,
|
activatedEventCount: 0,
|
||||||
canceledEventCount: 2,
|
canceledEventCount: 1,
|
||||||
finalDBCount: 0,
|
finalDBCount: 1,
|
||||||
lastCanceled: {
|
lastCanceled: {
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
recurring: 'yearly',
|
recurring: 'yearly',
|
||||||
@@ -116,12 +120,16 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
activatedCount: 1,
|
activatedCount: 1,
|
||||||
name: 'Pro monthly on iOS',
|
name: 'Pro monthly on iOS',
|
||||||
rec: {
|
rec: {
|
||||||
|
externalProductId: 'app.affine.pro.Monthly',
|
||||||
|
externalRef: 'rc:app.affine.pro.Monthly',
|
||||||
iapStore: 'app_store',
|
iapStore: 'app_store',
|
||||||
|
metadata: {
|
||||||
|
entitlement: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
willRenew: true,
|
||||||
|
},
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
rcEntitlement: 'Pro',
|
|
||||||
rcExternalRef: 'orig-ios-1',
|
|
||||||
rcProductId: 'app.affine.pro.Monthly',
|
|
||||||
recurring: 'monthly',
|
recurring: 'monthly',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
@@ -130,12 +138,16 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
activatedCount: 1,
|
activatedCount: 1,
|
||||||
name: 'AI annual on Android',
|
name: 'AI annual on Android',
|
||||||
rec: {
|
rec: {
|
||||||
|
externalProductId: 'app.affine.pro.ai.Annual',
|
||||||
|
externalRef: 'rc:app.affine.pro.ai.Annual',
|
||||||
iapStore: 'play_store',
|
iapStore: 'play_store',
|
||||||
|
metadata: {
|
||||||
|
entitlement: 'AI',
|
||||||
|
isTrial: false,
|
||||||
|
willRenew: true,
|
||||||
|
},
|
||||||
plan: 'ai',
|
plan: 'ai',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
rcEntitlement: 'AI',
|
|
||||||
rcExternalRef: 'token-android-1',
|
|
||||||
rcProductId: 'app.affine.pro.ai.Annual',
|
|
||||||
recurring: 'yearly',
|
recurring: 'yearly',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
@@ -148,7 +160,7 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
> should keep active after trial renewal
|
> should keep active after trial renewal
|
||||||
|
|
||||||
{
|
{
|
||||||
activatedCount: 2,
|
activatedCount: 1,
|
||||||
canceledCount: 0,
|
canceledCount: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
@@ -159,7 +171,7 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
|
|
||||||
{
|
{
|
||||||
canceledCount: 1,
|
canceledCount: 1,
|
||||||
finalDBCount: 0,
|
finalDBCount: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
|
## 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,
|
activatedCount: 0,
|
||||||
hasRCRecord: false,
|
hasRCRecord: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
|
## 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
|
> should reconcile and fix missing or out-of-order states for revenuecat records
|
||||||
|
|
||||||
{
|
{
|
||||||
activatedCount: 1,
|
activatedCount: 0,
|
||||||
canceledCount: 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 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,
|
canceledEventCount: 1,
|
||||||
finalDBCount: 0,
|
finalDBCount: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
## should ignore non-whitelisted productId and not write to DB
|
## should ignore non-whitelisted productId and not write to DB
|
||||||
@@ -236,11 +260,11 @@ Generated by [AVA](https://avajs.dev).
|
|||||||
c: 0,
|
c: 0,
|
||||||
},
|
},
|
||||||
afterSecond: {
|
afterSecond: {
|
||||||
a: 3,
|
a: 2,
|
||||||
c: 0,
|
c: 0,
|
||||||
},
|
},
|
||||||
afterThird: {
|
afterThird: {
|
||||||
a: 4,
|
a: 2,
|
||||||
c: 1,
|
c: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,7 @@
|
|||||||
|
import { TransactionHost } from '@nestjs-cls/transactional';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import ava, { TestFn } from 'ava';
|
import ava, { TestFn } from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { CryptoHelper, EventBus, JobQueue } from '../../base';
|
import { CryptoHelper, EventBus, JobQueue } from '../../base';
|
||||||
import { EntitlementService } from '../../core/entitlement';
|
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 => {
|
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 events: Array<{ name: string; payload: unknown }> = [];
|
||||||
const upserts: unknown[] = [];
|
const upserts: unknown[] = [];
|
||||||
const entitlements: unknown[] = [];
|
const entitlements: unknown[] = [];
|
||||||
|
const operations: string[] = [];
|
||||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||||
const service = new LicenseService(
|
const service = new LicenseService(
|
||||||
{
|
{
|
||||||
installedLicense: {
|
installedLicense: {
|
||||||
findUnique: async () => null,
|
findUnique: async () => null,
|
||||||
upsert: async (input: unknown) => {
|
create: async (input: unknown) => {
|
||||||
upserts.push(input);
|
upserts.push(input);
|
||||||
|
operations.push('source');
|
||||||
return {
|
return {
|
||||||
workspaceId: 'ws',
|
workspaceId: 'ws',
|
||||||
key: 'license-key',
|
key: 'license-key',
|
||||||
@@ -156,6 +165,7 @@ test('recurring selfhost license activation returns activation projection withou
|
|||||||
{
|
{
|
||||||
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
|
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
|
||||||
entitlements.push(input);
|
entitlements.push(input);
|
||||||
|
operations.push('entitlement');
|
||||||
},
|
},
|
||||||
} as unknown as EntitlementService,
|
} as unknown as EntitlementService,
|
||||||
{} as unknown as QuotaStateService
|
{} as unknown as QuotaStateService
|
||||||
@@ -186,6 +196,7 @@ test('recurring selfhost license activation returns activation projection withou
|
|||||||
t.is(entitlements.length, 1);
|
t.is(entitlements.length, 1);
|
||||||
t.is(upserts.length, 1);
|
t.is(upserts.length, 1);
|
||||||
t.is(activatedLicenseKey, 'license-key');
|
t.is(activatedLicenseKey, 'license-key');
|
||||||
|
t.deepEqual(operations, ['source', 'entitlement']);
|
||||||
t.deepEqual(events, [
|
t.deepEqual(events, [
|
||||||
{
|
{
|
||||||
name: 'workspace.subscription.activated',
|
name: 'workspace.subscription.activated',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SubscriptionAlreadyExists,
|
SubscriptionAlreadyExists,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { ConfigModule } from '../../base/config';
|
import { ConfigModule } from '../../base/config';
|
||||||
|
import { EntitlementService } from '../../core/entitlement';
|
||||||
import { FeatureService } from '../../core/features';
|
import { FeatureService } from '../../core/features';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { PaymentModule } from '../../plugins/payment';
|
import { PaymentModule } from '../../plugins/payment';
|
||||||
@@ -107,12 +108,20 @@ test.beforeEach(async t => {
|
|||||||
Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]);
|
Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]);
|
||||||
t.context.mockSub = subs =>
|
t.context.mockSub = subs =>
|
||||||
Sinon.stub(rc, 'getSubscriptions').resolves(
|
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 => {
|
t.context.mockSubSeq = sequences => {
|
||||||
const stub = Sinon.stub(rc, 'getSubscriptions');
|
const stub = Sinon.stub(rc, 'getSubscriptions');
|
||||||
sequences.forEach((seq, idx) => {
|
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);
|
if (idx === 0) stub.onFirstCall().resolves(subs);
|
||||||
else if (idx === 1) stub.onSecondCall().resolves(subs);
|
else if (idx === 1) stub.onSecondCall().resolves(subs);
|
||||||
else stub.onCall(idx).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 => {
|
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
|
||||||
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
|
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
|
||||||
|
|
||||||
mockAlias(user.id);
|
const alias = mockAlias(user.id);
|
||||||
const subscriber = mockSub([
|
const subscriber = mockSub([
|
||||||
{
|
{
|
||||||
identifier: 'Pro',
|
identifier: 'Pro',
|
||||||
@@ -217,14 +226,14 @@ test('should standardize RC subscriber response and upsert subscription with obs
|
|||||||
});
|
});
|
||||||
const { activatedCount, canceledCount, events } = collectEvents();
|
const { activatedCount, canceledCount, events } = collectEvents();
|
||||||
|
|
||||||
const record = await t.context.db.subscription.findUnique({
|
const record = await t.context.db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
|
||||||
select: {
|
select: {
|
||||||
provider: true,
|
provider: true,
|
||||||
iapStore: true,
|
iapStore: true,
|
||||||
rcEntitlement: true,
|
metadata: true,
|
||||||
rcProductId: true,
|
externalProductId: true,
|
||||||
rcExternalRef: true,
|
externalRef: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,20 +250,65 @@ test('should standardize RC subscriber response and upsert subscription with obs
|
|||||||
},
|
},
|
||||||
'should standardize payload and have events'
|
'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;
|
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||||
|
|
||||||
mockAlias(user.id);
|
mockAlias(user.id);
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
recurring: 'yearly',
|
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',
|
original_transaction_id: 'orig-tx-2',
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalDBCount = await db.subscription.count({
|
const finalDBCount = await db.providerSubscription.count({
|
||||||
where: { targetId: user.id, plan: 'pro' },
|
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 cron = module.get(SubscriptionCronJobs);
|
||||||
|
|
||||||
const common = { provider: 'revenuecat', start: new Date() } as const;
|
const common = { provider: 'revenuecat', periodStart: new Date() } as const;
|
||||||
await db.subscription.createMany({
|
await db.providerSubscription.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
targetType: 'user',
|
||||||
targetId: 'u1',
|
targetId: 'u1',
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
recurring: 'monthly',
|
recurring: 'monthly',
|
||||||
|
iapStore: 'app_store',
|
||||||
|
externalCustomerId: 'c1',
|
||||||
|
externalProductId: 'pro-monthly',
|
||||||
|
externalRef: 'r1',
|
||||||
...common,
|
...common,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
targetType: 'user',
|
||||||
targetId: 'u2',
|
targetId: 'u2',
|
||||||
plan: 'ai',
|
plan: 'ai',
|
||||||
status: 'trialing',
|
status: 'trialing',
|
||||||
recurring: 'yearly',
|
recurring: 'yearly',
|
||||||
|
iapStore: 'app_store',
|
||||||
|
externalCustomerId: 'c2',
|
||||||
|
externalProductId: 'ai-yearly',
|
||||||
|
externalRef: 'r2',
|
||||||
...common,
|
...common,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
targetType: 'user',
|
||||||
targetId: 'u1',
|
targetId: 'u1',
|
||||||
plan: 'ai',
|
plan: 'ai',
|
||||||
status: 'past_due',
|
status: 'past_due',
|
||||||
recurring: 'monthly',
|
recurring: 'monthly',
|
||||||
|
iapStore: 'play_store',
|
||||||
|
externalCustomerId: 'c1',
|
||||||
|
externalProductId: 'ai-monthly',
|
||||||
|
externalRef: 'r3',
|
||||||
...common,
|
...common,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await cron.reconcileRevenueCatSubscriptions();
|
await cron.reconcileRevenueCatSubscriptions();
|
||||||
|
|
||||||
const calls = module.queue.add.getCalls().map(c => ({
|
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
|
// reset event history between scenarios for clean counts
|
||||||
event.emit.resetHistory?.();
|
event.emit.resetHistory?.();
|
||||||
await triggerWebhook(user.id, s.event);
|
await triggerWebhook(user.id, s.event);
|
||||||
const rec = await db.subscription.findUnique({
|
const rec = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
|
where: {
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
plan: s.expectedPlan,
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
plan: true,
|
plan: true,
|
||||||
recurring: true,
|
recurring: true,
|
||||||
status: true,
|
status: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
iapStore: true,
|
iapStore: true,
|
||||||
rcEntitlement: true,
|
metadata: true,
|
||||||
rcProductId: true,
|
externalProductId: true,
|
||||||
rcExternalRef: true,
|
externalRef: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { activatedCount } = collectEvents();
|
const { activatedCount } = collectEvents();
|
||||||
@@ -479,9 +551,9 @@ test('should keep active and advance period dates when a trialing subscription r
|
|||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rec = await db.subscription.findUnique({
|
const rec = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
|
||||||
select: { status: true, start: true, end: true },
|
select: { status: true, periodStart: true, periodEnd: true },
|
||||||
});
|
});
|
||||||
const { activatedCount, canceledCount } = collectEvents();
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
t.snapshot(
|
t.snapshot(
|
||||||
@@ -535,7 +607,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing
|
|||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalDBCount = await db.subscription.count({
|
const finalDBCount = await db.providerSubscription.count({
|
||||||
where: { targetId: user.id, plan: 'pro' },
|
where: { targetId: user.id, plan: 'pro' },
|
||||||
});
|
});
|
||||||
const { canceledCount } = collectEvents();
|
const { canceledCount } = collectEvents();
|
||||||
@@ -564,8 +636,8 @@ test('should set canceledAt and keep active until expiration when will_renew is
|
|||||||
type: 'CANCELLATION',
|
type: 'CANCELLATION',
|
||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
const rec = await db.subscription.findUnique({
|
const rec = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
|
||||||
select: { status: true, canceledAt: true },
|
select: { status: true, canceledAt: true },
|
||||||
});
|
});
|
||||||
const { activatedCount, canceledCount } = collectEvents();
|
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',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rec = await db.subscription.findUnique({
|
const rec = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
|
||||||
select: { status: true },
|
select: { status: true },
|
||||||
});
|
});
|
||||||
const { canceledCount } = collectEvents();
|
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 { module, db } = t.context;
|
||||||
|
|
||||||
const manager = module.get(UserSubscriptionManager);
|
const manager = module.get(UserSubscriptionManager);
|
||||||
|
let subscriptionId: string;
|
||||||
|
|
||||||
{
|
{
|
||||||
await db.subscription.create({
|
const subscription = await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
recurring: 'monthly',
|
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(
|
await t.throwsAsync(
|
||||||
manager.checkout(
|
manager.checkout(
|
||||||
@@ -649,9 +728,16 @@ test('should block checkout when an existing subscription of the same plan is ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
await db.subscription.update({
|
await db.providerSubscription.update({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { id: subscriptionId },
|
||||||
data: { provider: 'stripe' },
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: 'sub_existing',
|
||||||
|
iapStore: null,
|
||||||
|
externalCustomerId: null,
|
||||||
|
externalProductId: null,
|
||||||
|
externalRef: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.throwsAsync(
|
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 => {
|
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
|
||||||
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||||
mockAlias(user.id);
|
mockAlias(user.id);
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
recurring: 'monthly',
|
recurring: 'monthly',
|
||||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
periodStart: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
externalSubscriptionId: 'sub_conflict',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSub([
|
mockSub([
|
||||||
{
|
{
|
||||||
identifier: 'Pro',
|
identifier: 'Pro',
|
||||||
@@ -708,7 +795,7 @@ test('should skip RC upsert when Stripe active already exists for same plan', as
|
|||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rcRec = await db.subscription.findFirst({
|
const rcRec = await db.providerSubscription.findFirst({
|
||||||
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
|
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
|
||||||
});
|
});
|
||||||
const { activatedCount } = collectEvents();
|
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 => {
|
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
|
||||||
const { db, service } = t.context;
|
const { db, service } = t.context;
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
recurring: 'monthly',
|
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<unknown>) =>
|
||||||
const expectManaged = async (fn: () => Promise<any>) =>
|
|
||||||
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
|
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
|
||||||
|
|
||||||
await expectManaged(() =>
|
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 => {
|
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);
|
mockAlias(user.id);
|
||||||
const subscriber = mockSub([
|
const placeholder = await db.providerSubscription.create({
|
||||||
{
|
data: {
|
||||||
identifier: 'Pro',
|
targetType: 'user',
|
||||||
isTrial: false,
|
targetId: user.id,
|
||||||
isActive: true,
|
plan: 'pro',
|
||||||
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
|
status: 'active',
|
||||||
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
|
provider: 'revenuecat',
|
||||||
productId: 'app.affine.pro.Annual',
|
recurring: 'yearly',
|
||||||
store: 'play_store',
|
periodStart: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
willRenew: true,
|
periodEnd: new Date('2999-01-01T00:00:00.000Z'),
|
||||||
duration: null,
|
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);
|
await webhook.syncAppUser(user.id);
|
||||||
const { activatedCount, canceledCount } = collectEvents();
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
const subscriberCount = subscriber.getCalls()?.length || 0;
|
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(
|
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'
|
'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;
|
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
|
||||||
|
|
||||||
mockAlias(user.id);
|
mockAlias(user.id);
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
provider: 'revenuecat',
|
provider: 'revenuecat',
|
||||||
recurring: 'monthly',
|
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',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const count = await db.subscription.count({
|
const count = await db.providerSubscription.count({
|
||||||
where: { targetId: user.id, plan: 'pro' },
|
where: { targetId: user.id, plan: 'pro' },
|
||||||
});
|
});
|
||||||
const { canceledCount } = collectEvents();
|
const { canceledCount } = collectEvents();
|
||||||
t.snapshot(
|
t.snapshot(
|
||||||
{ finalDBCount: count, canceledEventCount: canceledCount },
|
{ 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',
|
type: 'INITIAL_PURCHASE',
|
||||||
store: 'app_store',
|
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();
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
t.snapshot(
|
t.snapshot(
|
||||||
{ dbCount, activatedCount, canceledCount },
|
{ dbCount, activatedCount, canceledCount },
|
||||||
@@ -901,8 +1053,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
|
|||||||
type: 'INITIAL_PURCHASE',
|
type: 'INITIAL_PURCHASE',
|
||||||
store: 'app_store',
|
store: 'app_store',
|
||||||
});
|
});
|
||||||
const r1 = await db.subscription.findUnique({
|
const r1 = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
|
||||||
select: { plan: true, recurring: true, provider: true },
|
select: { plan: true, recurring: true, provider: true },
|
||||||
});
|
});
|
||||||
const s1 = collectEvents();
|
const s1 = collectEvents();
|
||||||
@@ -913,8 +1065,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
|
|||||||
type: 'INITIAL_PURCHASE',
|
type: 'INITIAL_PURCHASE',
|
||||||
store: 'play_store',
|
store: 'play_store',
|
||||||
});
|
});
|
||||||
const r2 = await db.subscription.findUnique({
|
const r2 = await db.providerSubscription.findFirst({
|
||||||
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
|
where: { targetType: 'user', targetId: user.id, plan: 'ai' },
|
||||||
select: { plan: true, recurring: true, provider: true },
|
select: { plan: true, recurring: true, provider: true },
|
||||||
});
|
});
|
||||||
const s2 = collectEvents();
|
const s2 = collectEvents();
|
||||||
@@ -925,7 +1077,9 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
|
|||||||
type: 'INITIAL_PURCHASE',
|
type: 'INITIAL_PURCHASE',
|
||||||
store: 'app_store',
|
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();
|
const s3 = collectEvents();
|
||||||
|
|
||||||
t.snapshot(
|
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)
|
// 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' },
|
where: { targetId: user.id, provider: 'revenuecat' },
|
||||||
});
|
});
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
recurring: 'monthly',
|
recurring: 'monthly',
|
||||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
periodStart: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
stripeSubscriptionId: 'sub_123',
|
externalSubscriptionId: 'sub_123',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const subs = await subResolver.refreshUserSubscriptions(currentUser);
|
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 => {
|
test('user subscriptions ignore active rows after their current period ended', async t => {
|
||||||
const { db, subResolver } = t.context;
|
const { db, subResolver } = t.context;
|
||||||
|
|
||||||
await db.subscription.createMany({
|
await db.providerSubscription.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'ai',
|
plan: 'ai',
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
recurring: 'yearly',
|
recurring: 'yearly',
|
||||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
periodStart: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
end: new Date('2025-01-08T00:00:00.000Z'),
|
periodEnd: new Date('2025-01-08T00:00:00.000Z'),
|
||||||
stripeSubscriptionId: 'sub_expired_ai',
|
externalSubscriptionId: 'sub_expired_ai',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
plan: 'pro',
|
plan: 'pro',
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
recurring: 'yearly',
|
recurring: 'yearly',
|
||||||
start: new Date('2025-01-01T00:00:00.000Z'),
|
periodStart: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
end: new Date('2099-01-01T00:00:00.000Z'),
|
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
|
||||||
stripeSubscriptionId: 'sub_current_pro',
|
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);
|
const subscriptions = await subResolver.subscriptions(user, user);
|
||||||
t.deepEqual(subscriptions.map(subscription => subscription.plan).sort(), [
|
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`,
|
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({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
provider: 'stripe',
|
provider: 'stripe',
|
||||||
@@ -1117,6 +1279,14 @@ test('user subscriptions preserve provider trialing status', async t => {
|
|||||||
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
|
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);
|
const subscriptions = await subResolver.subscriptions(trialUser, trialUser);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { EventBus } from '../../base';
|
|||||||
import { ConfigFactory, ConfigModule } from '../../base/config';
|
import { ConfigFactory, ConfigModule } from '../../base/config';
|
||||||
import { CurrentUser } from '../../core/auth';
|
import { CurrentUser } from '../../core/auth';
|
||||||
import { AuthService } from '../../core/auth/service';
|
import { AuthService } from '../../core/auth/service';
|
||||||
|
import { EntitlementService } from '../../core/entitlement';
|
||||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||||
|
import { SelfhostTeamSubscriptionManager } from '../../plugins/payment/manager';
|
||||||
import { RevenueCatService } from '../../plugins/payment/revenuecat';
|
import { RevenueCatService } from '../../plugins/payment/revenuecat';
|
||||||
import { SubscriptionService } from '../../plugins/payment/service';
|
import { SubscriptionService } from '../../plugins/payment/service';
|
||||||
import { StripeFactory } from '../../plugins/payment/stripe';
|
import { StripeFactory } from '../../plugins/payment/stripe';
|
||||||
@@ -226,7 +228,7 @@ test.beforeEach(async t => {
|
|||||||
await db.workspace.create({
|
await db.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
id: 'ws_1',
|
id: 'ws_1',
|
||||||
public: false,
|
accessPolicy: { create: {} },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.userStripeCustomer.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 => {
|
test('should throw if user has subscription already', async t => {
|
||||||
const { service, u1, db } = t.context;
|
const { service, u1, db } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
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 => {
|
test('should allow checkout after local subscription period ended', async t => {
|
||||||
const { service, u1, db, stripe } = t.context;
|
const { service, u1, db, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_expired_ai',
|
externalSubscriptionId: 'sub_expired_ai',
|
||||||
plan: SubscriptionPlan.AI,
|
plan: SubscriptionPlan.AI,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date('2026-05-04T13:11:45.000Z'),
|
periodStart: new Date('2026-05-04T13:11:45.000Z'),
|
||||||
end: new Date('2026-05-11T13: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);
|
await service.saveStripeSubscription(sub);
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
|
||||||
where: { targetId: u1.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
t.true(
|
t.true(
|
||||||
event.emit.calledOnceWith('user.subscription.activated', {
|
event.emit.calledOnceWith('user.subscription.activated', {
|
||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
@@ -721,8 +723,6 @@ test('should be able to create subscription', async t => {
|
|||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
t.is(subInDB?.stripeSubscriptionId, sub.id);
|
|
||||||
|
|
||||||
const providerFact = await db.providerSubscription.findUnique({
|
const providerFact = await db.providerSubscription.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_externalSubscriptionId: {
|
provider_externalSubscriptionId: {
|
||||||
@@ -760,26 +760,33 @@ test('should be able to update subscription', async t => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
const subInDB = await db.providerSubscription.findUnique({
|
||||||
where: { targetId: u1.id },
|
where: {
|
||||||
|
provider_externalSubscriptionId: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: sub.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||||
t.is(subInDB?.canceledAt?.getTime(), canceledAt * 1000);
|
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 { service, db, u1 } = t.context;
|
||||||
|
|
||||||
const old = await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_old',
|
externalSubscriptionId: 'sub_old',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Canceled,
|
status: SubscriptionStatus.Canceled,
|
||||||
start: new Date('2026-03-26T08:23:57.000Z'),
|
periodStart: new Date('2026-03-26T08:23:57.000Z'),
|
||||||
end: new Date('2027-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({
|
const subscriptions = await db.providerSubscription.findMany({
|
||||||
where: { targetId: u1.id, plan: SubscriptionPlan.Pro },
|
where: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: u1.id,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
},
|
||||||
|
orderBy: { externalSubscriptionId: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(subscriptions.length, 1);
|
t.is(subscriptions.length, 2);
|
||||||
t.is(subscriptions[0].id, old.id);
|
t.is(subscriptions[0].externalSubscriptionId, 'sub_new');
|
||||||
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new');
|
|
||||||
t.is(subscriptions[0].status, SubscriptionStatus.Active);
|
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 => {
|
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(
|
t.like(
|
||||||
await db.providerSubscription.findUnique({
|
await db.providerSubscription.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -851,15 +860,18 @@ test('should be able to delete subscription', async t => {
|
|||||||
test('should be able to cancel subscription', async t => {
|
test('should be able to cancel subscription', async t => {
|
||||||
const { service, db, u1, stripe } = t.context;
|
const { service, db, u1, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
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.is(subInDB.status, SubscriptionStatus.Active);
|
||||||
t.truthy(subInDB.canceledAt);
|
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 => {
|
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();
|
await cron.reconcileStripeSubscriptions();
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
const subInDB = await db.providerSubscription.findUnique({
|
||||||
where: { targetId: u1.id, stripeSubscriptionId: sub.id },
|
where: {
|
||||||
|
provider_externalSubscriptionId: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: sub.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(subInDB, null);
|
t.is(subInDB?.status, SubscriptionStatus.Canceled);
|
||||||
t.true(
|
t.true(
|
||||||
event.emit.calledWith('user.subscription.canceled', {
|
event.emit.calledWith('user.subscription.canceled', {
|
||||||
userId: u1.id,
|
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 => {
|
test('should be able to resume subscription', async t => {
|
||||||
const { service, db, u1, stripe } = t.context;
|
const { service, db, u1, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
periodEnd: new Date(Date.now() + 100000),
|
||||||
canceledAt: new Date(),
|
canceledAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -976,15 +1004,17 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
|
|||||||
test('should be able to update recurring', async t => {
|
test('should be able to update recurring', async t => {
|
||||||
const { service, db, u1, stripe } = t.context;
|
const { service, db, u1, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
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 => {
|
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;
|
const { service, db, u1, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
stripeScheduleId: 'sub_sched_1',
|
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
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 => {
|
test('should not be able to checkout for lifetime recurring if already subscribed', async t => {
|
||||||
const { service, u1, db } = t.context;
|
const { service, u1, db } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: null,
|
externalSubscriptionId: 'stripe_invoice:existing_lifetime',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Lifetime,
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
status: SubscriptionStatus.Active,
|
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);
|
await service.saveStripeInvoice(lifetimeInvoice);
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
const subInDB = await db.providerSubscription.findFirst({
|
||||||
where: { targetId: u1.id },
|
where: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: u1.id,
|
||||||
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.true(
|
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?.plan, SubscriptionPlan.Pro);
|
||||||
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
||||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
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({
|
const paymentFact = await db.paymentEvent.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -1319,28 +1358,88 @@ test('should be able to subscribe to lifetime recurring', async t => {
|
|||||||
currency: lifetimeInvoice.currency,
|
currency: lifetimeInvoice.currency,
|
||||||
processingStatus: 'processed',
|
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 => {
|
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: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
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);
|
stripe.subscriptions.cancel.resolves(sub as any);
|
||||||
await service.saveStripeInvoice(lifetimeInvoice);
|
await service.saveStripeInvoice(lifetimeInvoice);
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
const subInDB = await db.providerSubscription.findFirst({
|
||||||
where: { targetId: u1.id },
|
where: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: u1.id,
|
||||||
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.true(
|
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?.plan, SubscriptionPlan.Pro);
|
||||||
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
|
||||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
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 => {
|
test('should not be able to cancel lifetime subscription', async t => {
|
||||||
const { service, db, u1 } = t.context;
|
const { service, db, u1 } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
|
externalSubscriptionId: 'stripe_invoice:lifetime_cancel',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Lifetime,
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: null,
|
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 => {
|
test('should not be able to update lifetime recurring', async t => {
|
||||||
const { service, db, u1 } = t.context;
|
const { service, db, u1 } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'user',
|
||||||
targetId: u1.id,
|
targetId: u1.id,
|
||||||
|
externalSubscriptionId: 'stripe_invoice:lifetime_update',
|
||||||
plan: SubscriptionPlan.Pro,
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring: SubscriptionRecurring.Lifetime,
|
recurring: SubscriptionRecurring.Lifetime,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: null,
|
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 => {
|
test('should not be able to checkout for workspace if subscribed', async t => {
|
||||||
const { service, u1, db } = t.context;
|
const { service, u1, db } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'workspace',
|
||||||
targetId: 'ws_1',
|
targetId: 'ws_1',
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Team,
|
plan: SubscriptionPlan.Team,
|
||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
status: SubscriptionStatus.Active,
|
status: SubscriptionStatus.Active,
|
||||||
start: new Date(),
|
periodStart: new Date(),
|
||||||
end: new Date(Date.now() + 100000),
|
periodEnd: new Date(Date.now() + 100000),
|
||||||
quantity: 1,
|
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 => {
|
test('should be able to checkout for workspace after canceled subscription', async t => {
|
||||||
const { service, u1, db, stripe } = t.context;
|
const { service, u1, db, stripe } = t.context;
|
||||||
|
|
||||||
await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'workspace',
|
||||||
targetId: 'ws_1',
|
targetId: 'ws_1',
|
||||||
stripeSubscriptionId: 'sub_1',
|
externalSubscriptionId: 'sub_1',
|
||||||
plan: SubscriptionPlan.Team,
|
plan: SubscriptionPlan.Team,
|
||||||
recurring: SubscriptionRecurring.Monthly,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
status: SubscriptionStatus.Canceled,
|
status: SubscriptionStatus.Canceled,
|
||||||
start: new Date(Date.now() - 100000),
|
periodStart: new Date(Date.now() - 100000),
|
||||||
end: new Date(Date.now() - 1000),
|
periodEnd: new Date(Date.now() - 1000),
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1537,8 +1659,8 @@ test('should be able to create team subscription', async t => {
|
|||||||
|
|
||||||
await service.saveStripeSubscription(teamSub);
|
await service.saveStripeSubscription(teamSub);
|
||||||
|
|
||||||
const subInDB = await db.subscription.findFirst({
|
const subInDB = await db.providerSubscription.findFirst({
|
||||||
where: { targetId: 'ws_1' },
|
where: { targetType: 'workspace', targetId: 'ws_1' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.true(
|
t.true(
|
||||||
@@ -1549,21 +1671,23 @@ test('should be able to create team subscription', async t => {
|
|||||||
quantity: 1,
|
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 { service, db } = t.context;
|
||||||
|
|
||||||
const old = await db.subscription.create({
|
await db.providerSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'workspace',
|
||||||
targetId: 'ws_1',
|
targetId: 'ws_1',
|
||||||
stripeSubscriptionId: 'sub_old_team',
|
externalSubscriptionId: 'sub_old_team',
|
||||||
plan: SubscriptionPlan.Team,
|
plan: SubscriptionPlan.Team,
|
||||||
recurring: SubscriptionRecurring.Yearly,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
status: SubscriptionStatus.Canceled,
|
status: SubscriptionStatus.Canceled,
|
||||||
start: new Date('2026-03-26T08:23:57.000Z'),
|
periodStart: new Date('2026-03-26T08:23:57.000Z'),
|
||||||
end: new Date('2027-03-26T08:23:57.000Z'),
|
periodEnd: new Date('2027-03-26T08:23:57.000Z'),
|
||||||
quantity: 24,
|
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({
|
const subscriptions = await db.providerSubscription.findMany({
|
||||||
where: { targetId: 'ws_1', plan: SubscriptionPlan.Team },
|
where: {
|
||||||
|
provider: 'stripe',
|
||||||
|
targetType: 'workspace',
|
||||||
|
targetId: 'ws_1',
|
||||||
|
plan: SubscriptionPlan.Team,
|
||||||
|
},
|
||||||
|
orderBy: { externalSubscriptionId: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(subscriptions.length, 1);
|
t.is(subscriptions.length, 2);
|
||||||
t.is(subscriptions[0].id, old.id);
|
t.is(subscriptions[0].externalSubscriptionId, 'sub_new_team');
|
||||||
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team');
|
|
||||||
t.is(subscriptions[0].status, SubscriptionStatus.Active);
|
t.is(subscriptions[0].status, SubscriptionStatus.Active);
|
||||||
t.is(subscriptions[0].quantity, 11);
|
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 => {
|
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({
|
const subInDB = await db.providerSubscription.findFirst({
|
||||||
where: { targetId: 'ws_1' },
|
where: { targetType: 'workspace', targetId: 'ws_1' },
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(subInDB?.quantity, 2);
|
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({
|
const entitlement = await db.entitlement.findFirst({
|
||||||
where: {
|
where: {
|
||||||
source: 'cloud_subscription',
|
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,
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
quantity: 9,
|
quantity: 9,
|
||||||
start: new Date(1780000000 * 1000),
|
periodStart: new Date(1780000000 * 1000),
|
||||||
end: new Date(1811536000 * 1000),
|
periodEnd: new Date(1811536000 * 1000),
|
||||||
trialStart: new Date(1780000000 * 1000),
|
trialStart: new Date(1780000000 * 1000),
|
||||||
trialEnd: new Date(1780604800 * 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');
|
await service.handleRefundedInvoice(invoice.id, 'dispute_open');
|
||||||
|
|
||||||
const removed = await db.subscription.findFirst({
|
const suspended = await db.providerSubscription.findUnique({
|
||||||
where: { stripeSubscriptionId: 'sub_1' },
|
where: {
|
||||||
|
provider_externalSubscriptionId: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: 'sub_1',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.is(removed, null);
|
t.is(suspended?.status, SubscriptionStatus.Canceled);
|
||||||
t.true(
|
t.true(
|
||||||
event.emit.calledWith('user.subscription.canceled', {
|
event.emit.calledWith('user.subscription.canceled', {
|
||||||
userId: t.context.u1.id,
|
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');
|
await service.handleRefundedInvoice(invoice.id, 'dispute_won');
|
||||||
|
|
||||||
const restored = await db.subscription.findFirst({
|
const restored = await db.providerSubscription.findUnique({
|
||||||
where: { stripeSubscriptionId: 'sub_1' },
|
where: {
|
||||||
|
provider_externalSubscriptionId: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: 'sub_1',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
t.truthy(restored);
|
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
|
// 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 cancel team subscription', async () => {});
|
||||||
test.skip('should be able to resume team subscription', async () => {});
|
test.skip('should be able to resume team subscription', async () => {});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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';
|
import { UserType } from '../../core/user/types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -11,12 +10,12 @@ export class WorkspaceResolverMock {
|
|||||||
async createWorkspace(user: UserType, _init: null) {
|
async createWorkspace(user: UserType, _init: null) {
|
||||||
const workspace = await this.prisma.workspace.create({
|
const workspace = await this.prisma.workspace.create({
|
||||||
data: {
|
data: {
|
||||||
public: false,
|
accessPolicy: { create: {} },
|
||||||
permissions: {
|
members: {
|
||||||
create: {
|
create: {
|
||||||
type: WorkspaceRole.Owner,
|
role: 'owner',
|
||||||
|
state: 'active',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ConfigFactory } from '../../base';
|
|||||||
import { QuotaStateService } from '../../core/quota/state';
|
import { QuotaStateService } from '../../core/quota/state';
|
||||||
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
|
||||||
import { StorageRuntimeProvider } from '../../core/storage-runtime';
|
import { StorageRuntimeProvider } from '../../core/storage-runtime';
|
||||||
import { BlobModel, WorkspaceFeatureModel } from '../../models';
|
import { BlobModel } from '../../models';
|
||||||
import { getMime } from '../../native';
|
import { getMime } from '../../native';
|
||||||
import {
|
import {
|
||||||
collectAllBlobSizes,
|
collectAllBlobSizes,
|
||||||
@@ -34,7 +34,6 @@ const RESTRICTED_QUOTA = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let app: TestingApp;
|
let app: TestingApp;
|
||||||
let model: WorkspaceFeatureModel;
|
|
||||||
type CompleteResult =
|
type CompleteResult =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
@@ -146,7 +145,6 @@ test.before(async () => {
|
|||||||
builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime);
|
builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
model = app.get(WorkspaceFeatureModel);
|
|
||||||
app.get(ConfigFactory).override({
|
app.get(ConfigFactory).override({
|
||||||
storages: {
|
storages: {
|
||||||
blob: {
|
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 => {
|
test('should throw error when blob size large than max file size', async t => {
|
||||||
await app.signup();
|
await app.signup();
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import Sinon from 'sinon';
|
|||||||
import supertest from 'supertest';
|
import supertest from 'supertest';
|
||||||
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
|
||||||
|
|
||||||
import { ConfigFactory } from '../../base';
|
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||||
import { PermissionReadModel } from '../../core/permission/config';
|
|
||||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||||
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
|
||||||
import {
|
import {
|
||||||
@@ -52,11 +50,10 @@ test.before(async t => {
|
|||||||
workspace: {
|
workspace: {
|
||||||
create: {
|
create: {
|
||||||
id: 'public',
|
id: 'public',
|
||||||
public: true,
|
accessPolicy: { create: { visibility: 'public' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
docId: 'private',
|
docId: 'private',
|
||||||
public: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,11 +62,10 @@ test.before(async t => {
|
|||||||
workspace: {
|
workspace: {
|
||||||
create: {
|
create: {
|
||||||
id: 'private',
|
id: 'private',
|
||||||
public: false,
|
accessPolicy: { create: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
docId: 'public',
|
docId: 'public',
|
||||||
public: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,13 +74,28 @@ test.before(async t => {
|
|||||||
workspace: {
|
workspace: {
|
||||||
create: {
|
create: {
|
||||||
id: 'totally-private',
|
id: 'totally-private',
|
||||||
public: false,
|
accessPolicy: { create: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
docId: 'private',
|
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 => {
|
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');
|
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 => {
|
test('should not be able to get private workspace with no public pages', async t => {
|
||||||
const { app } = t.context;
|
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: {
|
const devUsers: {
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
features: UserFeatureName[];
|
plans: Array<'pro' | 'ai'>;
|
||||||
workspaceFeatures?: WorkspaceFeatureName[];
|
teamWorkspace?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
email: 'dev@affine.pro',
|
email: 'dev@affine.pro',
|
||||||
name: 'Dev User',
|
name: 'Dev User',
|
||||||
password: 'dev',
|
password: 'dev',
|
||||||
features: ['free_plan_v1', 'unlimited_copilot', 'administrator'],
|
plans: ['ai'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'pro@affine.pro',
|
email: 'pro@affine.pro',
|
||||||
name: 'Pro User',
|
name: 'Pro User',
|
||||||
password: 'pro',
|
password: 'pro',
|
||||||
features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'],
|
plans: ['pro', 'ai'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: 'team@affine.pro',
|
email: 'team@affine.pro',
|
||||||
name: 'Team User',
|
name: 'Team User',
|
||||||
password: 'team',
|
password: 'team',
|
||||||
features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'],
|
plans: ['pro', 'ai'],
|
||||||
workspaceFeatures: ['team_plan_v1'],
|
teamWorkspace: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const devWorkspaceBlob = Buffer.from(
|
const devWorkspaceBlob = Buffer.from(
|
||||||
@@ -33,13 +41,7 @@ export async function createDevUsers(models: Models) {
|
|||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const {
|
for (const { email, name, password, plans, teamWorkspace } of devUsers) {
|
||||||
email,
|
|
||||||
name,
|
|
||||||
password,
|
|
||||||
features,
|
|
||||||
workspaceFeatures,
|
|
||||||
} of devUsers) {
|
|
||||||
try {
|
try {
|
||||||
let devUser = await models.user.getUserByEmail(email);
|
let devUser = await models.user.getUserByEmail(email);
|
||||||
if (!devUser) {
|
if (!devUser) {
|
||||||
@@ -49,40 +51,47 @@ export async function createDevUsers(models: Models) {
|
|||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const feature of features) {
|
await models.userFeature.add(devUser.id, 'administrator', name);
|
||||||
if (feature.includes('plan')) {
|
for (const plan of plans) {
|
||||||
await models.userFeature.switchQuota(devUser.id, feature, name);
|
await entitlement.upsertFromCloudSubscription({
|
||||||
} else {
|
targetId: devUser.id,
|
||||||
await models.userFeature.add(devUser.id, feature, name);
|
plan,
|
||||||
}
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
provider: 'dev',
|
||||||
|
subscriptionId: `dev:${devUser.id}:${plan}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (workspaceFeatures) {
|
if (teamWorkspace) {
|
||||||
for (const feature of workspaceFeatures) {
|
const workspaceIds = (
|
||||||
const workspaceIds = (
|
await models.workspaceUser.getUserActiveRoles(devUser.id)
|
||||||
await models.workspaceUser.getUserActiveRoles(devUser.id)
|
).map(row => row.workspaceId);
|
||||||
).map(row => row.workspaceId);
|
const workspaces = await models.workspace.findMany(workspaceIds);
|
||||||
const workspaces = await models.workspace.findMany(workspaceIds);
|
const hasTeamWorkspace = (
|
||||||
let hasFeatureWorkspace = false;
|
await Promise.all(
|
||||||
for (const workspace of workspaces) {
|
workspaces.map(workspace =>
|
||||||
if (await models.workspaceFeature.has(workspace.id, feature)) {
|
entitlement.resolveWorkspaceEntitlement(workspace.id)
|
||||||
hasFeatureWorkspace = true;
|
)
|
||||||
break;
|
)
|
||||||
}
|
).some(resolved => resolved.plan === 'team');
|
||||||
}
|
if (!hasTeamWorkspace) {
|
||||||
if (!hasFeatureWorkspace) {
|
const workspace = await models.workspace.create(devUser.id);
|
||||||
// create a new workspace with the feature
|
await models.doc.upsert({
|
||||||
const workspace = await models.workspace.create(devUser.id);
|
spaceId: workspace.id,
|
||||||
await models.doc.upsert({
|
docId: workspace.id,
|
||||||
spaceId: workspace.id,
|
blob: devWorkspaceBlob,
|
||||||
docId: workspace.id,
|
timestamp: Date.now(),
|
||||||
blob: devWorkspaceBlob,
|
editorId: devUser.id,
|
||||||
timestamp: Date.now(),
|
});
|
||||||
editorId: devUser.id,
|
await entitlement.upsertFromCloudSubscription({
|
||||||
});
|
targetId: workspace.id,
|
||||||
await models.workspaceFeature.add(workspace.id, feature, name, {
|
plan: 'team',
|
||||||
memberLimit: 10,
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
});
|
status: SubscriptionStatus.Active,
|
||||||
}
|
quantity: 10,
|
||||||
|
provider: 'dev',
|
||||||
|
subscriptionId: `dev:${workspace.id}:team`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './config';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BackendRuntimeModule } from '../backend-runtime';
|
import { BackendRuntimeModule } from '../backend-runtime';
|
||||||
|
import { EntitlementModule } from '../entitlement';
|
||||||
import { FeatureModule } from '../features';
|
import { FeatureModule } from '../features';
|
||||||
import { MailModule } from '../mail';
|
import { MailModule } from '../mail';
|
||||||
import { QuotaModule } from '../quota';
|
import { QuotaModule } from '../quota';
|
||||||
@@ -27,6 +28,7 @@ import { AuthSigningKeyResolver } from './signing-key-resolver';
|
|||||||
imports: [
|
imports: [
|
||||||
BackendRuntimeModule,
|
BackendRuntimeModule,
|
||||||
FeatureModule,
|
FeatureModule,
|
||||||
|
EntitlementModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
QuotaModule,
|
QuotaModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { assign, pick } from 'lodash-es';
|
|||||||
|
|
||||||
import { Config, OnEvent, SignUpForbidden } from '../../base';
|
import { Config, OnEvent, SignUpForbidden } from '../../base';
|
||||||
import { Models, type User, type UserSession } from '../../models';
|
import { Models, type User, type UserSession } from '../../models';
|
||||||
|
import { EntitlementService } from '../entitlement';
|
||||||
import { Mailer } from '../mail/mailer';
|
import { Mailer } from '../mail/mailer';
|
||||||
import type { MailDeliveryMetadata } from '../mail/types';
|
import type { MailDeliveryMetadata } from '../mail/types';
|
||||||
import { AuthSessionService } from './auth-session';
|
import { AuthSessionService } from './auth-session';
|
||||||
@@ -44,7 +45,8 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly mailer: Mailer,
|
private readonly mailer: Mailer,
|
||||||
private readonly authSessions: AuthSessionService
|
private readonly authSessions: AuthSessionService,
|
||||||
|
private readonly entitlement: EntitlementService
|
||||||
) {
|
) {
|
||||||
this.cookieOptions = {
|
this.cookieOptions = {
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
@@ -63,7 +65,7 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
if (env.dev) {
|
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 { Config, hasNewerVersion, URLHelper } from '../../base';
|
||||||
import { Namespace } from '../../env';
|
import { Namespace } from '../../env';
|
||||||
import { Feature, type WorkspaceFeatureName } from '../../models';
|
import { Feature } from '../../models';
|
||||||
import { CurrentUser, Public } from '../auth';
|
import { CurrentUser, Public } from '../auth';
|
||||||
import { Admin } from '../common';
|
import { Admin } from '../common';
|
||||||
import { AvailableUserFeatureConfig } from '../features';
|
import { AvailableUserFeatureConfig } from '../features';
|
||||||
@@ -168,13 +168,6 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
|||||||
override availableUserFeatures() {
|
override availableUserFeatures() {
|
||||||
return super.availableUserFeatures();
|
return super.availableUserFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [Feature], {
|
|
||||||
description: 'Workspace features available for admin configuration',
|
|
||||||
})
|
|
||||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@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 {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
|
||||||
} from '../../../plugins/payment/types';
|
} from '../../../plugins/payment/types';
|
||||||
import { EntitlementModule } from '../index';
|
import { EntitlementModule } from '../index';
|
||||||
import { EntitlementService } from '../service';
|
import { EntitlementService } from '../service';
|
||||||
@@ -375,134 +374,3 @@ test('selfhosted resolution ignores unsigned DB entitlements', async t => {
|
|||||||
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
|
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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { LegacyEntitlementProjectionService } from './projection';
|
|
||||||
import { EntitlementProjectionChecker } from './projection-checker';
|
|
||||||
import { EntitlementService } from './service';
|
import { EntitlementService } from './service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [EntitlementService],
|
||||||
EntitlementService,
|
exports: [EntitlementService],
|
||||||
LegacyEntitlementProjectionService,
|
|
||||||
EntitlementProjectionChecker,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
EntitlementService,
|
|
||||||
LegacyEntitlementProjectionService,
|
|
||||||
EntitlementProjectionChecker,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class EntitlementModule {}
|
export class EntitlementModule {}
|
||||||
|
|
||||||
export { EntitlementService };
|
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()
|
@Injectable()
|
||||||
export class EntitlementService {
|
export class EntitlementService {
|
||||||
private readonly legacyCloudSubscriptionSyncs = new Map<
|
|
||||||
string,
|
|
||||||
Promise<void>
|
|
||||||
>();
|
|
||||||
private readonly remoteSelfhostLicenseVerifications = new Map<
|
private readonly remoteSelfhostLicenseVerifications = new Map<
|
||||||
string,
|
string,
|
||||||
Promise<Entitlement | null>
|
Promise<Entitlement | null>
|
||||||
@@ -102,7 +98,6 @@ export class EntitlementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getBestEntitlement(targetType: TargetType, targetId: string) {
|
async getBestEntitlement(targetType: TargetType, targetId: string) {
|
||||||
await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId);
|
|
||||||
const entitlements = await this.db.entitlement.findMany({
|
const entitlements = await this.db.entitlement.findMany({
|
||||||
where: {
|
where: {
|
||||||
targetType,
|
targetType,
|
||||||
@@ -138,7 +133,6 @@ export class EntitlementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getActiveEntitlements(targetType: TargetType, targetId: string) {
|
async getActiveEntitlements(targetType: TargetType, targetId: string) {
|
||||||
await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId);
|
|
||||||
return this.db.entitlement.findMany({
|
return this.db.entitlement.findMany({
|
||||||
where: {
|
where: {
|
||||||
targetType,
|
targetType,
|
||||||
@@ -154,7 +148,7 @@ export class EntitlementService {
|
|||||||
|
|
||||||
async upsertFromCloudSubscription(
|
async upsertFromCloudSubscription(
|
||||||
input: CloudSubscriptionEntitlementInput,
|
input: CloudSubscriptionEntitlementInput,
|
||||||
options: { emit?: boolean; legacySync?: boolean } = {}
|
options: { emit?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const emit = options.emit ?? true;
|
const emit = options.emit ?? true;
|
||||||
const targetType = this.targetTypeForPlan(input.plan);
|
const targetType = this.targetTypeForPlan(input.plan);
|
||||||
@@ -182,7 +176,6 @@ export class EntitlementService {
|
|||||||
variant: input.variant ?? null,
|
variant: input.variant ?? null,
|
||||||
subscriptionId: input.subscriptionId ?? null,
|
subscriptionId: input.subscriptionId ?? null,
|
||||||
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
|
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
|
||||||
legacySync: options.legacySync ?? false,
|
|
||||||
},
|
},
|
||||||
startsAt: input.start ?? null,
|
startsAt: input.start ?? null,
|
||||||
expiresAt: input.end ?? 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(
|
async upsertFromSelfhostLicense(
|
||||||
input: SelfhostLicenseEntitlementInput,
|
input: SelfhostLicenseEntitlementInput,
|
||||||
options: { emit?: boolean } = {}
|
options: { emit?: boolean } = {}
|
||||||
@@ -505,16 +467,6 @@ export class EntitlementService {
|
|||||||
subscriptionId?: string | number | null;
|
subscriptionId?: string | number | null;
|
||||||
stripeSubscriptionId?: string | 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(
|
await this.revokeBySubject(
|
||||||
'cloud_subscription',
|
'cloud_subscription',
|
||||||
this.cloudSubjectId(input)
|
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(
|
private cloudSubjectId(
|
||||||
input: Pick<
|
input: Pick<
|
||||||
CloudSubscriptionEntitlementInput,
|
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: {
|
private async revokeCloudSubscriptionByLegacyTarget(input: {
|
||||||
targetId: string;
|
targetId: string;
|
||||||
plan: SubscriptionPlan | string;
|
plan: SubscriptionPlan | string;
|
||||||
@@ -887,37 +746,23 @@ export class EntitlementService {
|
|||||||
|
|
||||||
const expiresAt = new Date(license.expiresAt);
|
const expiresAt = new Date(license.expiresAt);
|
||||||
const validateKey = license.validateKey || metadata.validateKey;
|
const validateKey = license.validateKey || metadata.validateKey;
|
||||||
const [updated] = await Promise.all([
|
const updated = await this.db.entitlement.update({
|
||||||
this.db.entitlement.update({
|
where: { id: entitlement.id },
|
||||||
where: { id: entitlement.id },
|
data: {
|
||||||
data: {
|
status: 'active',
|
||||||
status: 'active',
|
quantity: this.normalizedQuantity(license.quantity),
|
||||||
quantity: this.normalizedQuantity(license.quantity),
|
metadata: {
|
||||||
metadata: {
|
...metadata,
|
||||||
...metadata,
|
recurring: license.recurring,
|
||||||
recurring: license.recurring,
|
validateKey,
|
||||||
validateKey,
|
remoteValidated: true,
|
||||||
remoteValidated: true,
|
errorCode: null,
|
||||||
errorCode: null,
|
errorMessage: null,
|
||||||
errorMessage: null,
|
|
||||||
},
|
|
||||||
expiresAt,
|
|
||||||
validatedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
}),
|
expiresAt,
|
||||||
this.db.installedLicense
|
validatedAt: new Date(),
|
||||||
.updateMany({
|
},
|
||||||
where: { key: entitlement.subjectId },
|
});
|
||||||
data: {
|
|
||||||
quantity: this.normalizedQuantity(license.quantity),
|
|
||||||
recurring: license.recurring,
|
|
||||||
validateKey,
|
|
||||||
validatedAt: new Date(),
|
|
||||||
expiredAt: expiresAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => null),
|
|
||||||
]);
|
|
||||||
this.event.emit('entitlement.changed', {
|
this.event.emit('entitlement.changed', {
|
||||||
targetType: 'workspace',
|
targetType: 'workspace',
|
||||||
targetId: entitlement.targetId,
|
targetId: entitlement.targetId,
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
|
|||||||
description: 'Enabled features of a user',
|
description: 'Enabled features of a user',
|
||||||
})
|
})
|
||||||
async userFeatures(@Parent() user: UserType) {
|
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();
|
const availableUserFeatures = this.availableUserFeatures();
|
||||||
return features.filter(feature => availableUserFeatures.has(feature));
|
return features.filter(feature => availableUserFeatures.has(feature));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { Prisma, PrismaClient } from '@prisma/client';
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
|
|
||||||
@@ -7,7 +5,6 @@ import { createModule } from '../../../__tests__/create-module';
|
|||||||
import { Mockers } from '../../../__tests__/mocks';
|
import { Mockers } from '../../../__tests__/mocks';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { AccessControllerBuilder } from '../builder';
|
import { AccessControllerBuilder } from '../builder';
|
||||||
import { PermissionDiagnosticService } from '../diagnostic';
|
|
||||||
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
|
import { DocRole, PermissionModule, WorkspaceRole } from '../index';
|
||||||
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
|
import { PermissionSqlPredicateBuilder } from '../sql-predicate';
|
||||||
import type { DocAction } from '../types';
|
import type { DocAction } from '../types';
|
||||||
@@ -19,7 +16,6 @@ const module = await createModule({
|
|||||||
const builder = module.get(AccessControllerBuilder);
|
const builder = module.get(AccessControllerBuilder);
|
||||||
const models = module.get(Models);
|
const models = module.get(Models);
|
||||||
const db = module.get(PrismaClient);
|
const db = module.get(PrismaClient);
|
||||||
const diagnostic = module.get(PermissionDiagnosticService);
|
|
||||||
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
|
const sqlPredicate = module.get(PermissionSqlPredicateBuilder);
|
||||||
|
|
||||||
test.after.always(async () => {
|
test.after.always(async () => {
|
||||||
@@ -35,7 +31,7 @@ async function sqlReadableDocIds(input: {
|
|||||||
const values = Prisma.join(
|
const values = Prisma.join(
|
||||||
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`)
|
||||||
);
|
);
|
||||||
const predicate = sqlPredicate.docReadableByNewTablesSql({
|
const predicate = sqlPredicate.docReadableSql({
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
action: input.action ?? 'Doc.Read',
|
action: input.action ?? 'Doc.Read',
|
||||||
@@ -73,9 +69,29 @@ async function resetProjection(workspaceId: string) {
|
|||||||
member_default_doc_role = EXCLUDED.member_default_doc_role,
|
member_default_doc_role = EXCLUDED.member_default_doc_role,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
`;
|
`;
|
||||||
await models.workspaceRuntimeState.upsert(workspaceId, {
|
await setWritableRuntime(workspaceId);
|
||||||
readonly: false,
|
}
|
||||||
readonlyReasons: [],
|
|
||||||
|
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);
|
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 owner = await module.create(Mockers.User);
|
||||||
const member = await module.create(Mockers.User);
|
const member = await module.create(Mockers.User);
|
||||||
const workspace = await module.create(Mockers.Workspace, {
|
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,
|
userId: member.id,
|
||||||
docIds,
|
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.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 owner = await module.create(Mockers.User);
|
||||||
const nonMember = await module.create(Mockers.User);
|
const nonMember = await module.create(Mockers.User);
|
||||||
const workspace = await module.create(Mockers.Workspace, {
|
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,
|
userId: nonMember.id,
|
||||||
docIds,
|
docIds,
|
||||||
});
|
});
|
||||||
const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
userId: nonMember.id,
|
|
||||||
docs: docIds.map(docId => ({ docId })),
|
|
||||||
sqlReadableDocIds: sharingEnabledReadable,
|
|
||||||
});
|
|
||||||
const sharingEnabledUpdate = await sqlReadableDocIds({
|
const sharingEnabledUpdate = await sqlReadableDocIds({
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId: nonMember.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,
|
userId: nonMember.id,
|
||||||
docIds,
|
docIds,
|
||||||
});
|
});
|
||||||
const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
userId: nonMember.id,
|
|
||||||
docs: docIds.map(docId => ({ docId })),
|
|
||||||
sqlReadableDocIds: sharingDisabledReadable,
|
|
||||||
});
|
|
||||||
|
|
||||||
t.deepEqual(sharingEnabledReadable, [
|
t.deepEqual(sharingEnabledReadable, [
|
||||||
'public-doc',
|
'public-doc',
|
||||||
'explicit-grant',
|
'explicit-grant',
|
||||||
'explicit-owner-grant',
|
'explicit-owner-grant',
|
||||||
]);
|
]);
|
||||||
t.true(sharingEnabledShadow.matched);
|
|
||||||
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
|
t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']);
|
||||||
t.deepEqual(sharingDisabledReadable, []);
|
t.deepEqual(sharingDisabledReadable, []);
|
||||||
t.true(sharingDisabledShadow.matched);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SQL doc predicate suppresses member default when explicit grant exists', async t => {
|
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']);
|
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 => {
|
test('should filter docs by Doc.Publish', async t => {
|
||||||
const owner = await module.create(Mockers.User);
|
const owner = await module.create(Mockers.User);
|
||||||
const workspace = await module.create(Mockers.Workspace, {
|
const workspace = await module.create(Mockers.Workspace, {
|
||||||
owner,
|
owner,
|
||||||
});
|
});
|
||||||
await models.workspace.update(workspace.id, { enableSharing: true });
|
await models.workspace.update(workspace.id, { enableSharing: true });
|
||||||
await models.workspaceRuntimeState.upsert(workspace.id, {
|
await setWritableRuntime(workspace.id);
|
||||||
readonly: false,
|
|
||||||
readonlyReasons: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const docs1 = await builder
|
const docs1 = await builder
|
||||||
.user(owner.id)
|
.user(owner.id)
|
||||||
@@ -524,73 +424,3 @@ test('should filter docs by Doc.Publish', async t => {
|
|||||||
|
|
||||||
t.is(docs3.length, 0);
|
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({
|
const admin = await t.context.models.user.create({
|
||||||
email: `${randomUUID()}@affine.pro`,
|
email: `${randomUUID()}@affine.pro`,
|
||||||
});
|
});
|
||||||
await t.context.db.$transaction(async db => {
|
await t.context.db.workspaceInvitation.create({
|
||||||
await db.$executeRaw`
|
data: {
|
||||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
workspaceId: workspace.id,
|
||||||
`;
|
inviteeUserId: pending.id,
|
||||||
const pendingPermission = await db.workspaceUserRole.create({
|
requestedRole: 'member',
|
||||||
data: {
|
status: 'pending',
|
||||||
workspaceId: workspace.id,
|
kind: 'email',
|
||||||
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.models.workspaceUser.set(
|
await t.context.models.workspaceUser.set(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
@@ -269,17 +226,10 @@ test('should roll back team cancellation cleanup when cleanup fails', async t =>
|
|||||||
status: WorkspaceMemberStatus.Accepted,
|
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');
|
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(
|
const error = await t.throwsAsync(
|
||||||
t.context.policy.handleTeamPlanCanceled(workspace.id),
|
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,
|
(await t.context.models.workspaceUser.get(workspace.id, admin.id))?.type,
|
||||||
WorkspaceRole.Admin
|
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 { PrismaClient } from '@prisma/client';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
|
||||||
import { DocRole, Models } from '../../models';
|
|
||||||
import type { PermissionEvaluationInputV1 } from '../../native';
|
import type { PermissionEvaluationInputV1 } from '../../native';
|
||||||
import {
|
|
||||||
toNativeDocRole,
|
|
||||||
toNativeExplicitDocGrantRole,
|
|
||||||
toNativeMemberState,
|
|
||||||
toNativeWorkspaceRole,
|
|
||||||
} from './context';
|
|
||||||
import type { DocAction, WorkspaceAction } from './types';
|
import type { DocAction, WorkspaceAction } from './types';
|
||||||
|
|
||||||
type PermissionRequestCache = {
|
type PermissionRequestCache = {
|
||||||
workspaceMember: Map<
|
workspaceQuotaRuntime: Map<string, WorkspaceRuntimeState>;
|
||||||
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']>>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewWorkspaceMemberRow = {
|
type WorkspaceMemberRow = {
|
||||||
role: 'owner' | 'admin' | 'member';
|
role: 'owner' | 'admin' | 'member';
|
||||||
state: 'active' | 'suspended' | 'left';
|
state: 'active' | 'suspended' | 'left';
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewWorkspacePolicyRow = {
|
type WorkspacePolicyRow = {
|
||||||
visibility: 'private' | 'public';
|
visibility: 'private' | 'public';
|
||||||
sharingEnabled: boolean;
|
sharingEnabled: boolean;
|
||||||
urlPreviewEnabled: boolean;
|
urlPreviewEnabled: boolean;
|
||||||
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
|
memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager';
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewDocPolicyRow = {
|
type DocPolicyRow = {
|
||||||
docId: string;
|
docId: string;
|
||||||
visibility: 'private' | 'public';
|
visibility: 'private' | 'public';
|
||||||
publicRole: 'external' | null;
|
publicRole: 'external' | null;
|
||||||
@@ -56,12 +35,12 @@ type NewDocPolicyRow = {
|
|||||||
urlPreviewEnabled: boolean;
|
urlPreviewEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewDocGrantRow = {
|
type DocGrantRow = {
|
||||||
docId: string;
|
docId: string;
|
||||||
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
|
role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
|
||||||
};
|
};
|
||||||
|
|
||||||
type NewWorkspaceRuntimeState = {
|
type WorkspaceRuntimeState = {
|
||||||
known: boolean;
|
known: boolean;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
@@ -73,26 +52,16 @@ const CACHE_KEY = 'permission.context.cache';
|
|||||||
|
|
||||||
function createPermissionRequestCache(): PermissionRequestCache {
|
function createPermissionRequestCache(): PermissionRequestCache {
|
||||||
return {
|
return {
|
||||||
workspaceMember: new Map(),
|
|
||||||
workspacePolicy: new Map(),
|
|
||||||
workspaceRuntime: new Map(),
|
|
||||||
workspaceQuotaRuntime: new Map(),
|
workspaceQuotaRuntime: new Map(),
|
||||||
docPolicies: new Map(),
|
|
||||||
docGrants: new Map(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
|
export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview';
|
||||||
export type PermissionDocAction = DocAction | 'Doc.Preview';
|
export type PermissionDocAction = DocAction | 'Doc.Preview';
|
||||||
|
|
||||||
function cacheKey(parts: readonly unknown[]) {
|
|
||||||
return parts.join('\0');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionContextLoader {
|
export class PermissionContextLoader {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly models: Models,
|
|
||||||
private readonly db: PrismaClient,
|
private readonly db: PrismaClient,
|
||||||
private readonly cls?: ClsService
|
private readonly cls?: ClsService
|
||||||
) {}
|
) {}
|
||||||
@@ -105,94 +74,17 @@ export class PermissionContextLoader {
|
|||||||
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
docs?: Array<{ docId: string; actions: PermissionDocAction[] }>;
|
||||||
}): Promise<PermissionEvaluationInputV1> {
|
}): Promise<PermissionEvaluationInputV1> {
|
||||||
const docs = input.docs ?? [];
|
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([
|
await Promise.all([
|
||||||
input.userId
|
input.userId
|
||||||
? this.workspaceMember(input.workspaceId, input.userId)
|
? this.workspaceMember(input.workspaceId, input.userId)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
this.workspacePolicy(input.workspaceId),
|
this.workspacePolicy(input.workspaceId),
|
||||||
this.workspaceRuntime(input.workspaceId),
|
this.workspaceRuntime(input.workspaceId),
|
||||||
this.docPolicies(
|
this.docPolicies(input.workspaceId, docIds),
|
||||||
input.workspaceId,
|
|
||||||
docs.map(doc => doc.docId)
|
|
||||||
),
|
|
||||||
input.userId
|
input.userId
|
||||||
? this.docGrants(
|
? this.docGrants(input.workspaceId, docIds, input.userId)
|
||||||
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)
|
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
const docPolicyMap = new Map(
|
const docPolicyMap = new Map(
|
||||||
@@ -293,56 +185,16 @@ export class PermissionContextLoader {
|
|||||||
return promise;
|
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) {
|
invalidateWorkspaceQuotaRuntime(workspaceId: string) {
|
||||||
this.cache.workspaceQuotaRuntime.delete(workspaceId);
|
this.cache.workspaceQuotaRuntime.delete(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private newWorkspaceRuntime(workspaceId: string) {
|
private workspaceRuntime(workspaceId: string) {
|
||||||
return this.memo(
|
return this.memo(
|
||||||
this.cache.workspaceQuotaRuntime,
|
this.cache.workspaceQuotaRuntime,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async () => {
|
async () => {
|
||||||
const rows = await this.db.$queryRaw<NewWorkspaceRuntimeState[]>`
|
const rows = await this.db.$queryRaw<WorkspaceRuntimeState[]>`
|
||||||
SELECT
|
SELECT
|
||||||
known,
|
known,
|
||||||
stale,
|
stale,
|
||||||
@@ -374,26 +226,8 @@ export class PermissionContextLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private docPolicies(workspaceId: string, docIds: string[]) {
|
private async workspaceMember(workspaceId: string, userId: string) {
|
||||||
const uniqueDocIds = [...new Set(docIds)];
|
const rows = await this.db.$queryRaw<WorkspaceMemberRow[]>`
|
||||||
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[]>`
|
|
||||||
SELECT role, state
|
SELECT role, state
|
||||||
FROM workspace_members
|
FROM workspace_members
|
||||||
WHERE workspace_id = ${workspaceId}
|
WHERE workspace_id = ${workspaceId}
|
||||||
@@ -404,8 +238,8 @@ export class PermissionContextLoader {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async newWorkspacePolicy(workspaceId: string) {
|
private async workspacePolicy(workspaceId: string) {
|
||||||
const rows = await this.db.$queryRaw<NewWorkspacePolicyRow[]>`
|
const rows = await this.db.$queryRaw<WorkspacePolicyRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
visibility,
|
visibility,
|
||||||
sharing_enabled AS "sharingEnabled",
|
sharing_enabled AS "sharingEnabled",
|
||||||
@@ -426,11 +260,11 @@ export class PermissionContextLoader {
|
|||||||
return !!workspace;
|
return !!workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async newDocPolicies(workspaceId: string, docIds: string[]) {
|
private async docPolicies(workspaceId: string, docIds: string[]) {
|
||||||
if (docIds.length === 0) {
|
if (docIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await this.db.$queryRaw<NewDocPolicyRow[]>`
|
return await this.db.$queryRaw<DocPolicyRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
doc_id AS "docId",
|
doc_id AS "docId",
|
||||||
visibility,
|
visibility,
|
||||||
@@ -443,7 +277,7 @@ export class PermissionContextLoader {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async newDocGrants(
|
private async docGrants(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docIds: string[],
|
docIds: string[],
|
||||||
userId: string
|
userId: string
|
||||||
@@ -451,7 +285,7 @@ export class PermissionContextLoader {
|
|||||||
if (docIds.length === 0) {
|
if (docIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await this.db.$queryRaw<NewDocGrantRow[]>`
|
return await this.db.$queryRaw<DocGrantRow[]>`
|
||||||
SELECT doc_id AS "docId", role
|
SELECT doc_id AS "docId", role
|
||||||
FROM doc_grants
|
FROM doc_grants
|
||||||
WHERE workspace_id = ${workspaceId}
|
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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QuotaServiceModule } from '../quota/service.module';
|
import { QuotaServiceModule } from '../quota/service.module';
|
||||||
import { AccessControllerBuilder } from './builder';
|
import { AccessControllerBuilder } from './builder';
|
||||||
import { PermissionContextLoader } from './context-loader';
|
import { PermissionContextLoader } from './context-loader';
|
||||||
import { PermissionDiagnosticService } from './diagnostic';
|
|
||||||
import { EventsListener } from './event';
|
import { EventsListener } from './event';
|
||||||
import { WorkspacePolicyService } from './policy';
|
import { WorkspacePolicyService } from './policy';
|
||||||
import { PermissionProjectionChecker } from './projection-checker';
|
|
||||||
import { PermissionService } from './service';
|
import { PermissionService } from './service';
|
||||||
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||||
|
|
||||||
@@ -18,18 +14,14 @@ import { PermissionSqlPredicateBuilder } from './sql-predicate';
|
|||||||
AccessControllerBuilder,
|
AccessControllerBuilder,
|
||||||
EventsListener,
|
EventsListener,
|
||||||
WorkspacePolicyService,
|
WorkspacePolicyService,
|
||||||
PermissionProjectionChecker,
|
|
||||||
PermissionSqlPredicateBuilder,
|
PermissionSqlPredicateBuilder,
|
||||||
PermissionContextLoader,
|
PermissionContextLoader,
|
||||||
PermissionDiagnosticService,
|
|
||||||
PermissionService,
|
PermissionService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AccessControllerBuilder,
|
AccessControllerBuilder,
|
||||||
WorkspacePolicyService,
|
WorkspacePolicyService,
|
||||||
PermissionProjectionChecker,
|
|
||||||
PermissionSqlPredicateBuilder,
|
PermissionSqlPredicateBuilder,
|
||||||
PermissionDiagnosticService,
|
|
||||||
PermissionService,
|
PermissionService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -37,16 +29,11 @@ export class PermissionModule {}
|
|||||||
|
|
||||||
export { AccessControllerBuilder as PermissionAccess } from './builder';
|
export { AccessControllerBuilder as PermissionAccess } from './builder';
|
||||||
export { PermissionContextLoader } from './context-loader';
|
export { PermissionContextLoader } from './context-loader';
|
||||||
export {
|
|
||||||
PERMISSION_SHADOW_MISMATCH_CATEGORIES,
|
|
||||||
PermissionDiagnosticService,
|
|
||||||
} from './diagnostic';
|
|
||||||
export {
|
export {
|
||||||
type DotToUnderline,
|
type DotToUnderline,
|
||||||
mapPermissionsToGraphqlPermissions,
|
mapPermissionsToGraphqlPermissions,
|
||||||
} from './permission-map';
|
} from './permission-map';
|
||||||
export { WorkspacePolicyService } from './policy';
|
export { WorkspacePolicyService } from './policy';
|
||||||
export { PermissionProjectionChecker } from './projection-checker';
|
|
||||||
export { PermissionService } from './service';
|
export { PermissionService } from './service';
|
||||||
export { PermissionSqlPredicateBuilder } from './sql-predicate';
|
export { PermissionSqlPredicateBuilder } from './sql-predicate';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export class WorkspacePolicyService {
|
|||||||
private async cleanupTeamPlanCanceled(workspaceId: string) {
|
private async cleanupTeamPlanCanceled(workspaceId: string) {
|
||||||
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
|
||||||
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
|
await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId);
|
||||||
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('workspace.members.updated')
|
@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 { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Config,
|
|
||||||
DocActionDenied,
|
DocActionDenied,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
metrics,
|
|
||||||
SpaceAccessDenied,
|
SpaceAccessDenied,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +11,6 @@ import {
|
|||||||
type PermissionEvaluationInputV1,
|
type PermissionEvaluationInputV1,
|
||||||
type PermissionEvaluationOutputV1,
|
type PermissionEvaluationOutputV1,
|
||||||
} from '../../native';
|
} from '../../native';
|
||||||
import { PermissionReadModel } from './config';
|
|
||||||
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
|
import { docLegacyBoundary, workspaceLegacyBoundary } from './context';
|
||||||
import {
|
import {
|
||||||
PermissionContextLoader,
|
PermissionContextLoader,
|
||||||
@@ -65,42 +62,16 @@ export class PermissionService {
|
|||||||
@Inject(PermissionSqlPredicateBuilder)
|
@Inject(PermissionSqlPredicateBuilder)
|
||||||
private readonly sqlPredicate = new PermissionSqlPredicateBuilder(),
|
private readonly sqlPredicate = new PermissionSqlPredicateBuilder(),
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly workspacePolicy?: WorkspacePolicyService,
|
private readonly workspacePolicy?: WorkspacePolicyService
|
||||||
@Optional()
|
|
||||||
private readonly config?: Config
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
readModel() {
|
|
||||||
return this.config?.permission.readModel ?? PermissionReadModel.Projection;
|
|
||||||
}
|
|
||||||
|
|
||||||
docReadableSqlPredicate(input: {
|
docReadableSqlPredicate(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
action: DocAction;
|
action: DocAction;
|
||||||
docIdColumn?: Prisma.Sql;
|
docIdColumn?: Prisma.Sql;
|
||||||
}) {
|
}) {
|
||||||
if (this.readModel() === PermissionReadModel.Projection) {
|
return this.sqlPredicate.docReadableSql(input);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(input: PermissionEvaluationInputV1) {
|
evaluate(input: PermissionEvaluationInputV1) {
|
||||||
@@ -264,39 +235,28 @@ export class PermissionService {
|
|||||||
private async evaluateLoaded(
|
private async evaluateLoaded(
|
||||||
input: Parameters<PermissionContextLoader['load']>[0]
|
input: Parameters<PermissionContextLoader['load']>[0]
|
||||||
) {
|
) {
|
||||||
if (this.readModel() === PermissionReadModel.Projection) {
|
try {
|
||||||
try {
|
if (
|
||||||
if (
|
this.needsFreshRuntimeState(input) &&
|
||||||
this.needsFreshRuntimeState(input) &&
|
(await this.loader.workspaceExists(input.workspaceId))
|
||||||
(await this.loader.workspaceExists(input.workspaceId))
|
) {
|
||||||
) {
|
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
|
||||||
await this.workspacePolicy?.getWorkspaceState(input.workspaceId);
|
this.loader.invalidateWorkspaceQuotaRuntime(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);
|
|
||||||
}
|
}
|
||||||
|
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(
|
private needsFreshRuntimeState(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { permissionActionRoleMatrixV1 } from '../../native';
|
import { permissionActionRoleMatrixV1 } from '../../native';
|
||||||
import { type DocAction, DocRole, WorkspaceRole } from './types';
|
import type { DocAction } from './types';
|
||||||
|
|
||||||
export type PermissionSqlPredicate = {
|
export type PermissionSqlPredicate = {
|
||||||
sql: string;
|
sql: string;
|
||||||
@@ -18,21 +18,6 @@ export class PermissionSqlPredicateBuilder {
|
|||||||
workspace?: { roles?: Record<string, string[]> };
|
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) {
|
private docRolesForAction(action: DocAction) {
|
||||||
return Object.entries(this.matrix.doc?.roles ?? {})
|
return Object.entries(this.matrix.doc?.roles ?? {})
|
||||||
.filter(([, actions]) => actions.includes(action))
|
.filter(([, actions]) => actions.includes(action))
|
||||||
@@ -60,12 +45,6 @@ export class PermissionSqlPredicateBuilder {
|
|||||||
return [...roles];
|
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') {
|
private rawDocIdColumn(column: RawDocIdColumn = 'doc_id') {
|
||||||
switch (column) {
|
switch (column) {
|
||||||
case 'doc_id':
|
case 'doc_id':
|
||||||
@@ -76,144 +55,7 @@ export class PermissionSqlPredicateBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
docReadableByLegacyTables(input: {
|
docReadable(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: {
|
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
action: DocAction;
|
action: DocAction;
|
||||||
@@ -260,7 +102,7 @@ export class PermissionSqlPredicateBuilder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
docReadableByNewTablesSql(input: {
|
docReadableSql(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
action: DocAction;
|
action: DocAction;
|
||||||
|
|||||||
@@ -60,57 +60,7 @@ test.after.always(async t => {
|
|||||||
await t.context.module.close();
|
await t.context.module.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('quota service ignores dirty legacy commercial features', async t => {
|
test('workspace quota state uses current member rows', 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 => {
|
|
||||||
const { workspace } = await createWorkspace(t);
|
const { workspace } = await createWorkspace(t);
|
||||||
const member = await t.context.models.user.create({
|
const member = await t.context.models.user.create({
|
||||||
email: `${randomUUID()}@affine.pro`,
|
email: `${randomUUID()}@affine.pro`,
|
||||||
@@ -123,16 +73,11 @@ test('workspace quota state ignores dirty legacy permission rows', async t => {
|
|||||||
status: WorkspaceMemberStatus.Accepted,
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
await t.context.db.$transaction(async tx => {
|
await t.context.db.workspaceMember.deleteMany({
|
||||||
await tx.$executeRaw`
|
where: {
|
||||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
workspaceId: workspace.id,
|
||||||
`;
|
userId: member.id,
|
||||||
await tx.workspaceMember.deleteMany({
|
},
|
||||||
where: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
userId: member.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = await t.context.state.reconcileWorkspaceQuotaState(
|
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 => {
|
test('workspace quota state requires owner from new permission table', async t => {
|
||||||
const { owner, workspace } = await createWorkspace(t);
|
const { owner, workspace } = await createWorkspace(t);
|
||||||
await t.context.db.$transaction(async tx => {
|
await t.context.db.workspaceMember.deleteMany({
|
||||||
await tx.$executeRaw`
|
where: {
|
||||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
workspaceId: workspace.id,
|
||||||
`;
|
userId: owner.id,
|
||||||
await tx.workspaceMember.deleteMany({
|
},
|
||||||
where: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
userId: owner.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.throwsAsync(
|
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);
|
await addBlob(t, workspace, 'blob', ONE_GB);
|
||||||
|
|
||||||
const first = await t.context.state.reconcileUserQuotaState(owner.id);
|
const first = await t.context.state.reconcileUserQuotaState(owner.id);
|
||||||
await t.context.db.$transaction(async tx => {
|
await t.context.db.workspaceMember.deleteMany({
|
||||||
await tx.$executeRaw`
|
where: {
|
||||||
SELECT set_config('affine.permission_projection.enabled', 'off', true)
|
workspaceId: workspace.id,
|
||||||
`;
|
userId: owner.id,
|
||||||
await tx.workspaceMember.deleteMany({
|
},
|
||||||
where: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
userId: owner.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const second = await t.context.state.reconcileUserQuotaState(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);
|
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);
|
const { workspace } = await createWorkspace(t);
|
||||||
await t.context.state.reconcileWorkspaceQuotaState(workspace.id);
|
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));
|
t.false(await t.context.models.workspace.isTeamWorkspace(workspace.id));
|
||||||
|
|
||||||
await t.context.entitlement.upsertFromCloudSubscription({
|
await t.context.entitlement.upsertFromCloudSubscription({
|
||||||
|
|||||||
@@ -117,7 +117,13 @@ export class UserRealtimeProvider
|
|||||||
emailVerified: current.emailVerified,
|
emailVerified: current.emailVerified,
|
||||||
hasPassword: current.hasPassword,
|
hasPassword: current.hasPassword,
|
||||||
avatarUrl: current.avatarUrl ?? null,
|
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))
|
.filter(feature => this.availableUserFeatures().has(feature))
|
||||||
.map(feature => this.serializeFeature(feature)),
|
.map(feature => this.serializeFeature(feature)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,12 +25,7 @@ import { SafeIntResolver } from 'graphql-scalars';
|
|||||||
|
|
||||||
import { PaginationInput, URLHelper } from '../../../base';
|
import { PaginationInput, URLHelper } from '../../../base';
|
||||||
import { PageInfo } from '../../../base/graphql/pagination';
|
import { PageInfo } from '../../../base/graphql/pagination';
|
||||||
import {
|
import { Models, WorkspaceMemberStatus } from '../../../models';
|
||||||
Feature,
|
|
||||||
Models,
|
|
||||||
WorkspaceFeatureName,
|
|
||||||
WorkspaceMemberStatus,
|
|
||||||
} from '../../../models';
|
|
||||||
import { Admin } from '../../common';
|
import { Admin } from '../../common';
|
||||||
import { WorkspaceUserType } from '../../user';
|
import { WorkspaceUserType } from '../../user';
|
||||||
import { TimeWindow } from './analytics-types';
|
import { TimeWindow } from './analytics-types';
|
||||||
@@ -118,9 +113,6 @@ class ListWorkspaceInput {
|
|||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
|
||||||
@Field(() => [Feature], { nullable: true })
|
|
||||||
features?: WorkspaceFeatureName[];
|
|
||||||
|
|
||||||
@Field(() => AdminWorkspaceSort, { nullable: true })
|
@Field(() => AdminWorkspaceSort, { nullable: true })
|
||||||
orderBy?: AdminWorkspaceSort;
|
orderBy?: AdminWorkspaceSort;
|
||||||
|
|
||||||
@@ -397,9 +389,6 @@ export class AdminWorkspace {
|
|||||||
@Field()
|
@Field()
|
||||||
enableDocEmbedding!: boolean;
|
enableDocEmbedding!: boolean;
|
||||||
|
|
||||||
@Field(() => [Feature])
|
|
||||||
features!: WorkspaceFeatureName[];
|
|
||||||
|
|
||||||
@Field(() => WorkspaceUserType, { nullable: true })
|
@Field(() => WorkspaceUserType, { nullable: true })
|
||||||
owner?: WorkspaceUserType | null;
|
owner?: WorkspaceUserType | null;
|
||||||
|
|
||||||
@@ -469,7 +458,6 @@ export class AdminWorkspaceResolver {
|
|||||||
first: filter.first,
|
first: filter.first,
|
||||||
skip: filter.skip,
|
skip: filter.skip,
|
||||||
keyword: filter.keyword,
|
keyword: filter.keyword,
|
||||||
features: filter.features,
|
|
||||||
order: this.mapSort(filter.orderBy),
|
order: this.mapSort(filter.orderBy),
|
||||||
flags: {
|
flags: {
|
||||||
public: filter.public ?? undefined,
|
public: filter.public ?? undefined,
|
||||||
@@ -491,7 +479,6 @@ export class AdminWorkspaceResolver {
|
|||||||
this.assertCloudOnly();
|
this.assertCloudOnly();
|
||||||
const total = await this.models.workspace.adminCountWorkspaces({
|
const total = await this.models.workspace.adminCountWorkspaces({
|
||||||
keyword: filter.keyword,
|
keyword: filter.keyword,
|
||||||
features: filter.features,
|
|
||||||
flags: {
|
flags: {
|
||||||
public: filter.public ?? undefined,
|
public: filter.public ?? undefined,
|
||||||
enableAi: filter.enableAi ?? undefined,
|
enableAi: filter.enableAi ?? undefined,
|
||||||
|
|||||||
@@ -415,24 +415,11 @@ export class WorkspaceDocResolver {
|
|||||||
action: 'Doc.Read',
|
action: 'Doc.Read',
|
||||||
docIdColumn: Prisma.raw('"workspace_pages"."page_id"'),
|
docIdColumn: Prisma.raw('"workspace_pages"."page_id"'),
|
||||||
});
|
});
|
||||||
const fallbackPredicate = this.permission.fallbackDocReadableSqlPredicate({
|
const [count, rows] = await this.models.doc.paginateDocInfoByUpdatedAt(
|
||||||
userId: me.id,
|
workspace.id,
|
||||||
workspaceId: workspace.id,
|
pagination,
|
||||||
action: 'Doc.Read',
|
predicate
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return paginate(rows, 'updatedAt', pagination, count);
|
return paginate(rows, 'updatedAt', pagination, count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import {
|
import { WorkspaceMemberSource, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
WorkspaceMemberSource,
|
|
||||||
WorkspaceMemberStatus,
|
|
||||||
WorkspaceUserRole,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +37,7 @@ import {
|
|||||||
UserNotFound,
|
UserNotFound,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import type { GraphqlContext } from '../../../base/graphql';
|
import type { GraphqlContext } from '../../../base/graphql';
|
||||||
import { Models } from '../../../models';
|
import { Models, type WorkspaceUserCompat } from '../../../models';
|
||||||
import { CurrentUser, Public } from '../../auth';
|
import { CurrentUser, Public } from '../../auth';
|
||||||
import { BackendRuntimeProvider } from '../../backend-runtime';
|
import { BackendRuntimeProvider } from '../../backend-runtime';
|
||||||
import { containsUrlOrDomain } from '../../content-policy';
|
import { containsUrlOrDomain } from '../../content-policy';
|
||||||
@@ -788,7 +784,7 @@ export class WorkspaceMemberResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
|
private async acceptInvitationByEmail(role: WorkspaceUserCompat) {
|
||||||
await this.assertWorkspaceAcceptsMemberChange(role.workspaceId);
|
await this.assertWorkspaceAcceptsMemberChange(role.workspaceId);
|
||||||
|
|
||||||
const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true);
|
const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true);
|
||||||
|
|||||||
@@ -283,15 +283,10 @@ export class WorkspaceStatsJob {
|
|||||||
),
|
),
|
||||||
public_page_stats AS (
|
public_page_stats AS (
|
||||||
SELECT workspace_id, COUNT(*) AS public_page_count
|
SELECT workspace_id, COUNT(*) AS public_page_count
|
||||||
FROM workspace_pages
|
FROM doc_access_policies
|
||||||
WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets)
|
WHERE visibility = 'public'
|
||||||
GROUP BY workspace_id
|
AND public_role = 'external'
|
||||||
),
|
AND workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
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)
|
|
||||||
GROUP BY workspace_id
|
GROUP BY workspace_id
|
||||||
),
|
),
|
||||||
aggregated AS (
|
aggregated AS (
|
||||||
@@ -301,14 +296,12 @@ export class WorkspaceStatsJob {
|
|||||||
COALESCE(bs.blob_count, 0) AS blob_count,
|
COALESCE(bs.blob_count, 0) AS blob_count,
|
||||||
COALESCE(bs.blob_size, 0) AS blob_size,
|
COALESCE(bs.blob_size, 0) AS blob_size,
|
||||||
COALESCE(ms.member_count, 0) AS member_count,
|
COALESCE(ms.member_count, 0) AS member_count,
|
||||||
COALESCE(pp.public_page_count, 0) AS public_page_count,
|
COALESCE(pp.public_page_count, 0) AS public_page_count
|
||||||
COALESCE(fs.features, ARRAY[]::text[]) AS features
|
|
||||||
FROM targets t
|
FROM targets t
|
||||||
LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id
|
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 blob_stats bs ON bs.workspace_id = t.workspace_id
|
||||||
LEFT JOIN member_stats ms ON ms.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 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 (
|
INSERT INTO workspace_admin_stats (
|
||||||
workspace_id,
|
workspace_id,
|
||||||
@@ -318,7 +311,6 @@ export class WorkspaceStatsJob {
|
|||||||
blob_size,
|
blob_size,
|
||||||
member_count,
|
member_count,
|
||||||
public_page_count,
|
public_page_count,
|
||||||
features,
|
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -329,7 +321,6 @@ export class WorkspaceStatsJob {
|
|||||||
blob_size,
|
blob_size,
|
||||||
member_count,
|
member_count,
|
||||||
public_page_count,
|
public_page_count,
|
||||||
features,
|
|
||||||
NOW()
|
NOW()
|
||||||
FROM aggregated
|
FROM aggregated
|
||||||
ON CONFLICT (workspace_id) DO UPDATE SET
|
ON CONFLICT (workspace_id) DO UPDATE SET
|
||||||
@@ -339,7 +330,6 @@ export class WorkspaceStatsJob {
|
|||||||
blob_size = EXCLUDED.blob_size,
|
blob_size = EXCLUDED.blob_size,
|
||||||
member_count = EXCLUDED.member_count,
|
member_count = EXCLUDED.member_count,
|
||||||
public_page_count = EXCLUDED.public_page_count,
|
public_page_count = EXCLUDED.public_page_count,
|
||||||
features = EXCLUDED.features,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
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 => {
|
test('permission backfill repairs ownerless workspaces before runtime state projection', async t => {
|
||||||
const emptyWorkspace = await t.context.db.workspace.create({
|
const emptyWorkspace = await t.context.db.workspace.create({
|
||||||
data: { public: false },
|
data: { accessPolicy: { create: {} } },
|
||||||
});
|
});
|
||||||
const member = await t.context.models.user.create({
|
const member = await t.context.models.user.create({
|
||||||
email: 'member@affine.pro',
|
email: 'member@affine.pro',
|
||||||
});
|
});
|
||||||
const memberWorkspace = await t.context.db.workspace.create({
|
const memberWorkspace = await t.context.db.workspace.create({
|
||||||
data: { public: false },
|
data: { accessPolicy: { create: {} } },
|
||||||
});
|
});
|
||||||
await t.context.db.workspaceMember.create({
|
await t.context.db.workspaceMember.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -80,13 +80,4 @@ test('permission backfill repairs ownerless workspaces before runtime state proj
|
|||||||
}),
|
}),
|
||||||
{ role: 'owner' }
|
{ 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 { ModuleRef } from '@nestjs/core';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { Models } from '../../models';
|
|
||||||
|
|
||||||
export class BackfillPermissionProjection1765500000000 {
|
export class BackfillPermissionProjection1765500000000 {
|
||||||
static async up(db: PrismaClient, ref: ModuleRef) {
|
static async up(db: PrismaClient, _ref: ModuleRef) {
|
||||||
const models = ref.get(Models, { strict: false });
|
|
||||||
await models.permissionProjection.backfillLegacyProjection();
|
|
||||||
await ensureWorkspaceAdminStatsDirtyTriggerGuard(db);
|
await ensureWorkspaceAdminStatsDirtyTriggerGuard(db);
|
||||||
await repairOwnerlessWorkspaces(db);
|
await repairOwnerlessWorkspaces(db);
|
||||||
await backfillUnknownQuotaRuntimeStates(db);
|
await backfillUnknownQuotaRuntimeStates(db);
|
||||||
@@ -134,29 +130,6 @@ async function backfillUnknownQuotaRuntimeStates(db: PrismaClient) {
|
|||||||
stale = true,
|
stale = true,
|
||||||
updated_at = now()
|
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) {
|
async function repairOwnerlessWorkspaces(db: PrismaClient) {
|
||||||
|
|||||||
+1
-8
@@ -1,15 +1,8 @@
|
|||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { LegacyEntitlementProjectionService } from '../../core/entitlement';
|
|
||||||
|
|
||||||
export class BackfillEntitlementProjection1765600000000 {
|
export class BackfillEntitlementProjection1765600000000 {
|
||||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
static async up(_db: PrismaClient, _ref: ModuleRef) {}
|
||||||
const projection = ref.get(LegacyEntitlementProjectionService, {
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
await projection.shadowBackfillEntitlementsAndQuotaStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
static async down(_db: PrismaClient) {}
|
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
|
// See https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#using-the-injecttransaction-decorator
|
||||||
return this.txHost.tx;
|
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 { 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({
|
export interface WorkspaceQuota extends UserQuota {
|
||||||
// quota name
|
seatQuota: number;
|
||||||
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 enum FeatureType {
|
export enum FeatureType {
|
||||||
Feature,
|
Feature,
|
||||||
@@ -41,120 +20,25 @@ export enum FeatureType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum Feature {
|
export enum Feature {
|
||||||
// user
|
|
||||||
Admin = 'administrator',
|
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 = {
|
export const FeaturesShapes = {
|
||||||
unlimited_workspace: EMPTY_CONFIG,
|
administrator: z.object({}),
|
||||||
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>>;
|
|
||||||
|
|
||||||
export type UserFeatureName = keyof Pick<
|
export type UserFeatureName = 'administrator';
|
||||||
typeof FeaturesShapes,
|
export type FeatureName = UserFeatureName;
|
||||||
| '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 FeatureConfig<T extends FeatureName> = z.infer<
|
export type FeatureConfig<T extends FeatureName> = z.infer<
|
||||||
(typeof FeaturesShapes)[T]
|
(typeof FeaturesShapes)[T]
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const FreeFeature = {
|
export const FeatureConfigs = {
|
||||||
type: FeatureType.Quota,
|
administrator: {
|
||||||
configs: {
|
type: FeatureType.Feature,
|
||||||
// quota name
|
configs: {},
|
||||||
name: 'Free',
|
|
||||||
blobLimit: 10 * OneMB,
|
|
||||||
businessBlobLimit: 100 * OneMB,
|
|
||||||
storageQuota: 10 * OneGB,
|
|
||||||
historyPeriod: 7 * OneDay,
|
|
||||||
memberLimit: 3,
|
|
||||||
copilotActionLimit: 10,
|
|
||||||
},
|
},
|
||||||
} as const;
|
} satisfies Record<
|
||||||
|
FeatureName,
|
||||||
const ProFeature = {
|
{ type: FeatureType; configs: FeatureConfig<FeatureName> }
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
|
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 { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
@@ -82,20 +82,12 @@ export class DocUserModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async deleteByUserId(userId: string) {
|
async deleteByUserId(userId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
await this.db.docGrant.deleteMany({
|
await this.db.docGrant.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
principalType: 'user',
|
principalType: 'user',
|
||||||
principalId: userId,
|
principalId: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.withPermissionProjectionMetric(
|
|
||||||
this.db.workspaceDocUserRole.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOwner(workspaceId: string, docId: string) {
|
async getOwner(workspaceId: string, docId: string) {
|
||||||
@@ -160,7 +152,7 @@ export class DocUserModel extends BaseModel {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docId: string,
|
docId: string,
|
||||||
pagination: PaginationInput
|
pagination: PaginationInput
|
||||||
): Promise<[WorkspaceDocUserRole[], number]> {
|
): Promise<[DocUserCompat[], number]> {
|
||||||
const [grants, total] = await Promise.all([
|
const [grants, total] = await Promise.all([
|
||||||
this.db.docGrant.findMany({
|
this.db.docGrant.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -184,7 +176,7 @@ export class DocUserModel extends BaseModel {
|
|||||||
return [grants.map(grant => this.docGrantToCompat(grant)), total];
|
return [grants.map(grant => this.docGrantToCompat(grant)), total];
|
||||||
}
|
}
|
||||||
|
|
||||||
private docGrantToCompat(grant: DocGrant): WorkspaceDocUserRole {
|
private docGrantToCompat(grant: DocGrant): DocUserCompat {
|
||||||
return {
|
return {
|
||||||
workspaceId: grant.workspaceId,
|
workspaceId: grant.workspaceId,
|
||||||
docId: grant.docId,
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
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 { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus, PaginationInput } from '../base';
|
import { EventBus, PaginationInput } from '../base';
|
||||||
@@ -33,7 +33,10 @@ declare global {
|
|||||||
export type DocMetaUpsertInput = Omit<
|
export type DocMetaUpsertInput = Omit<
|
||||||
Prisma.WorkspaceDocUncheckedCreateInput,
|
Prisma.WorkspaceDocUncheckedCreateInput,
|
||||||
'workspaceId' | 'docId'
|
'workspaceId' | 'docId'
|
||||||
>;
|
> & {
|
||||||
|
public?: boolean;
|
||||||
|
defaultRole?: DocRole;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workspace Doc Model
|
* Workspace Doc Model
|
||||||
@@ -50,6 +53,24 @@ export class DocModel extends BaseModel {
|
|||||||
super();
|
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
|
// #region Update
|
||||||
|
|
||||||
private updateToDocRecord(row: Update): Doc {
|
private updateToDocRecord(row: Update): Doc {
|
||||||
@@ -365,56 +386,64 @@ export class DocModel extends BaseModel {
|
|||||||
docId: string,
|
docId: string,
|
||||||
data?: DocMetaUpsertInput
|
data?: DocMetaUpsertInput
|
||||||
) {
|
) {
|
||||||
if (
|
const { public: isPublic, defaultRole, ...meta } = data ?? {};
|
||||||
data &&
|
if (data && ('public' in data || 'defaultRole' in data)) {
|
||||||
('public' in data || 'defaultRole' in data || 'publishedAt' in data)
|
|
||||||
) {
|
|
||||||
await this.models.docAccessPolicy.upsert(workspaceId, docId, {
|
await this.models.docAccessPolicy.upsert(workspaceId, docId, {
|
||||||
public: data.public,
|
public: isPublic,
|
||||||
defaultRole: data.defaultRole,
|
defaultRole,
|
||||||
publishedAt:
|
|
||||||
typeof data.publishedAt === 'string'
|
|
||||||
? new Date(data.publishedAt)
|
|
||||||
: data.publishedAt,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await this.withPermissionProjectionMetric(
|
const doc = await this.db.workspaceDoc.upsert({
|
||||||
this.db.workspaceDoc.upsert({
|
where: {
|
||||||
where: {
|
workspaceId_docId: {
|
||||||
workspaceId_docId: {
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
...data,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId,
|
docId,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
);
|
update: {
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...meta,
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
},
|
||||||
|
});
|
||||||
this.event.emit('doc.updated', {
|
this.event.emit('doc.updated', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
docId,
|
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.
|
* Get the doc meta.
|
||||||
*/
|
*/
|
||||||
|
async getMeta(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string
|
||||||
|
): Promise<(WorkspaceDoc & { public: boolean; defaultRole: DocRole }) | null>;
|
||||||
async getMeta<Select extends Prisma.WorkspaceDocSelect>(
|
async getMeta<Select extends Prisma.WorkspaceDocSelect>(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
docId: string,
|
docId: string,
|
||||||
options?: {
|
options: {
|
||||||
select?: Select;
|
select: Select;
|
||||||
}
|
}
|
||||||
) {
|
): Promise<Prisma.WorkspaceDocGetPayload<{ select: Select }> | null>;
|
||||||
return (await this.db.workspaceDoc.findUnique({
|
async getMeta(
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
options?: { select: Prisma.WorkspaceDocSelect }
|
||||||
|
): Promise<unknown> {
|
||||||
|
const doc = await this.db.workspaceDoc.findUnique({
|
||||||
where: {
|
where: {
|
||||||
workspaceId_docId: {
|
workspaceId_docId: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -422,7 +451,18 @@ export class DocModel extends BaseModel {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: options?.select,
|
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) {
|
async setDefaultRole(workspaceId: string, docId: string, role: DocRole) {
|
||||||
@@ -432,22 +472,15 @@ export class DocModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findDefaultRoles(workspaceId: string, docIds: string[]) {
|
async findDefaultRoles(workspaceId: string, docIds: string[]) {
|
||||||
const docs = await this.findMetas(
|
const policies = await this.db.docAccessPolicy.findMany({
|
||||||
docIds.map(docId => ({
|
where: { workspaceId, docId: { in: docIds } },
|
||||||
workspaceId,
|
});
|
||||||
docId,
|
const byDocId = new Map(policies.map(policy => [policy.docId, policy]));
|
||||||
})),
|
|
||||||
{
|
|
||||||
select: {
|
|
||||||
defaultRole: true,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return docs.map(doc => ({
|
return docIds.map(docId => ({
|
||||||
external: doc?.public ? DocRole.External : null,
|
external:
|
||||||
workspace: doc?.defaultRole ?? DocRole.Manager,
|
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.
|
* Find the workspace public doc metas.
|
||||||
*/
|
*/
|
||||||
async findPublics(workspaceId: string, order: 'asc' | 'desc' = 'asc') {
|
async findPublics(workspaceId: string, order: 'asc' | 'desc' = 'asc') {
|
||||||
return await this.db.workspaceDoc.findMany({
|
const policies = await this.db.docAccessPolicy.findMany({
|
||||||
where: { workspaceId, public: true },
|
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 },
|
orderBy: { publishedAt: order },
|
||||||
});
|
});
|
||||||
|
return metas.map(meta => ({
|
||||||
|
...meta,
|
||||||
|
public: true,
|
||||||
|
defaultRole: this.docRoleFromPolicy(
|
||||||
|
byDocId.get(meta.docId)?.memberDefaultRole
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the workspace public docs count.
|
* Get the workspace public docs count.
|
||||||
*/
|
*/
|
||||||
async getPublicsCount(workspaceId: string) {
|
async getPublicsCount(workspaceId: string) {
|
||||||
return await this.db.workspaceDoc.count({
|
return await this.db.docAccessPolicy.count({
|
||||||
where: {
|
where: { workspaceId, visibility: 'public', publicRole: 'external' },
|
||||||
workspaceId,
|
|
||||||
public: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,8 +593,7 @@ export class DocModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async unpublish(workspaceId: string, docId: string) {
|
async unpublish(workspaceId: string, docId: string) {
|
||||||
const docMeta = await this.getMeta(workspaceId, docId);
|
if (!(await this.isPublic(workspaceId, docId))) {
|
||||||
if (!docMeta?.public) {
|
|
||||||
throw new DocIsNotPublic();
|
throw new DocIsNotPublic();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,12 +607,11 @@ export class DocModel extends BaseModel {
|
|||||||
* Check if the doc is public.
|
* Check if the doc is public.
|
||||||
*/
|
*/
|
||||||
async isPublic(workspaceId: string, docId: string) {
|
async isPublic(workspaceId: string, docId: string) {
|
||||||
const docMeta = await this.getMeta(workspaceId, docId, {
|
const policy = await this.db.docAccessPolicy.findUnique({
|
||||||
select: {
|
where: { workspaceId_docId: { workspaceId, docId } },
|
||||||
public: true,
|
select: { visibility: true, publicRole: true },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return docMeta?.public ?? false;
|
return policy?.visibility === 'public' && policy.publicRole === 'external';
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDocInfo(workspaceId: string, docId: string) {
|
async getDocInfo(workspaceId: string, docId: string) {
|
||||||
@@ -582,7 +621,7 @@ export class DocModel extends BaseModel {
|
|||||||
docId: string;
|
docId: string;
|
||||||
mode: PublicDocMode;
|
mode: PublicDocMode;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
defaultRole: DocRole;
|
defaultRolePolicy: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -595,8 +634,8 @@ export class DocModel extends BaseModel {
|
|||||||
"workspace_pages"."workspace_id" as "workspaceId",
|
"workspace_pages"."workspace_id" as "workspaceId",
|
||||||
"workspace_pages"."page_id" as "docId",
|
"workspace_pages"."page_id" as "docId",
|
||||||
"workspace_pages"."mode" as "mode",
|
"workspace_pages"."mode" as "mode",
|
||||||
"workspace_pages"."public" as "public",
|
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
|
||||||
"workspace_pages"."defaultRole" as "defaultRole",
|
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
|
||||||
"workspace_pages"."title" as "title",
|
"workspace_pages"."title" as "title",
|
||||||
"workspace_pages"."summary" as "summary",
|
"workspace_pages"."summary" as "summary",
|
||||||
"snapshots"."created_at" as "createdAt",
|
"snapshots"."created_at" as "createdAt",
|
||||||
@@ -607,13 +646,24 @@ export class DocModel extends BaseModel {
|
|||||||
INNER JOIN "snapshots"
|
INNER JOIN "snapshots"
|
||||||
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
||||||
AND "workspace_pages"."page_id" = "snapshots"."guid"
|
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
|
WHERE
|
||||||
"workspace_pages"."workspace_id" = ${workspaceId}
|
"workspace_pages"."workspace_id" = ${workspaceId}
|
||||||
AND "workspace_pages"."page_id" = ${docId}
|
AND "workspace_pages"."page_id" = ${docId}
|
||||||
LIMIT 1;
|
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) {
|
async paginateDocInfo(workspaceId: string, pagination: PaginationInput) {
|
||||||
@@ -633,7 +683,7 @@ export class DocModel extends BaseModel {
|
|||||||
docId: string;
|
docId: string;
|
||||||
mode: PublicDocMode;
|
mode: PublicDocMode;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
defaultRole: DocRole;
|
defaultRolePolicy: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
creatorId?: string;
|
creatorId?: string;
|
||||||
@@ -644,8 +694,8 @@ export class DocModel extends BaseModel {
|
|||||||
"workspace_pages"."workspace_id" as "workspaceId",
|
"workspace_pages"."workspace_id" as "workspaceId",
|
||||||
"workspace_pages"."page_id" as "docId",
|
"workspace_pages"."page_id" as "docId",
|
||||||
"workspace_pages"."mode" as "mode",
|
"workspace_pages"."mode" as "mode",
|
||||||
"workspace_pages"."public" as "public",
|
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
|
||||||
"workspace_pages"."defaultRole" as "defaultRole",
|
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
|
||||||
"snapshots"."created_at" as "createdAt",
|
"snapshots"."created_at" as "createdAt",
|
||||||
"snapshots"."updated_at" as "updatedAt",
|
"snapshots"."updated_at" as "updatedAt",
|
||||||
"snapshots"."created_by" as "creatorId",
|
"snapshots"."created_by" as "creatorId",
|
||||||
@@ -654,6 +704,9 @@ export class DocModel extends BaseModel {
|
|||||||
INNER JOIN "snapshots"
|
INNER JOIN "snapshots"
|
||||||
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
||||||
AND "workspace_pages"."page_id" = "snapshots"."guid"
|
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
|
WHERE
|
||||||
"workspace_pages"."workspace_id" = ${workspaceId}
|
"workspace_pages"."workspace_id" = ${workspaceId}
|
||||||
${after}
|
${after}
|
||||||
@@ -663,7 +716,13 @@ export class DocModel extends BaseModel {
|
|||||||
OFFSET ${pagination.offset}
|
OFFSET ${pagination.offset}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return [count, rows] as const;
|
return [
|
||||||
|
count,
|
||||||
|
rows.map(({ defaultRolePolicy, ...doc }) => ({
|
||||||
|
...doc,
|
||||||
|
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
|
||||||
|
})),
|
||||||
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async paginateDocInfoByUpdatedAt(
|
async paginateDocInfoByUpdatedAt(
|
||||||
@@ -690,7 +749,7 @@ export class DocModel extends BaseModel {
|
|||||||
docId: string;
|
docId: string;
|
||||||
mode: PublicDocMode;
|
mode: PublicDocMode;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
defaultRole: DocRole;
|
defaultRolePolicy: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -702,8 +761,8 @@ export class DocModel extends BaseModel {
|
|||||||
"workspace_pages"."workspace_id" as "workspaceId",
|
"workspace_pages"."workspace_id" as "workspaceId",
|
||||||
"workspace_pages"."page_id" as "docId",
|
"workspace_pages"."page_id" as "docId",
|
||||||
"workspace_pages"."mode" as "mode",
|
"workspace_pages"."mode" as "mode",
|
||||||
"workspace_pages"."public" as "public",
|
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
|
||||||
"workspace_pages"."defaultRole" as "defaultRole",
|
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
|
||||||
"workspace_pages"."title" as "title",
|
"workspace_pages"."title" as "title",
|
||||||
"snapshots"."created_at" as "createdAt",
|
"snapshots"."created_at" as "createdAt",
|
||||||
"snapshots"."updated_at" as "updatedAt",
|
"snapshots"."updated_at" as "updatedAt",
|
||||||
@@ -713,6 +772,9 @@ export class DocModel extends BaseModel {
|
|||||||
INNER JOIN "snapshots"
|
INNER JOIN "snapshots"
|
||||||
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
|
||||||
AND "workspace_pages"."page_id" = "snapshots"."guid"
|
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
|
WHERE
|
||||||
"workspace_pages"."workspace_id" = ${workspaceId}
|
"workspace_pages"."workspace_id" = ${workspaceId}
|
||||||
AND ${readablePredicate}
|
AND ${readablePredicate}
|
||||||
@@ -723,7 +785,13 @@ export class DocModel extends BaseModel {
|
|||||||
OFFSET ${pagination.offset}
|
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) {
|
async findEmptySummaryDocIds(workspaceId: string) {
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { MagicLinkOtpModel } from './magic-link-otp';
|
|||||||
import { MailDeliveryModel } from './mail-delivery';
|
import { MailDeliveryModel } from './mail-delivery';
|
||||||
import { McpCredentialModel } from './mcp-credential';
|
import { McpCredentialModel } from './mcp-credential';
|
||||||
import { NotificationModel } from './notification';
|
import { NotificationModel } from './notification';
|
||||||
import { PermissionProjectionModel } from './permission-projection';
|
|
||||||
import {
|
import {
|
||||||
DocAccessPolicyModel,
|
DocAccessPolicyModel,
|
||||||
DocGrantModel,
|
DocGrantModel,
|
||||||
@@ -50,8 +49,6 @@ import { VerificationTokenModel } from './verification-token';
|
|||||||
import { WorkspaceModel } from './workspace';
|
import { WorkspaceModel } from './workspace';
|
||||||
import { WorkspaceAnalyticsModel } from './workspace-analytics';
|
import { WorkspaceAnalyticsModel } from './workspace-analytics';
|
||||||
import { WorkspaceCalendarModel } from './workspace-calendar';
|
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
|
||||||
import { WorkspaceRuntimeStateModel } from './workspace-runtime-state';
|
|
||||||
import { WorkspaceUserModel } from './workspace-user';
|
import { WorkspaceUserModel } from './workspace-user';
|
||||||
|
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
@@ -64,15 +61,12 @@ const MODELS = {
|
|||||||
feature: FeatureModel,
|
feature: FeatureModel,
|
||||||
workspace: WorkspaceModel,
|
workspace: WorkspaceModel,
|
||||||
userFeature: UserFeatureModel,
|
userFeature: UserFeatureModel,
|
||||||
workspaceFeature: WorkspaceFeatureModel,
|
|
||||||
workspaceRuntimeState: WorkspaceRuntimeStateModel,
|
|
||||||
doc: DocModel,
|
doc: DocModel,
|
||||||
userDoc: UserDocModel,
|
userDoc: UserDocModel,
|
||||||
workspaceUser: WorkspaceUserModel,
|
workspaceUser: WorkspaceUserModel,
|
||||||
docUser: DocUserModel,
|
docUser: DocUserModel,
|
||||||
history: HistoryModel,
|
history: HistoryModel,
|
||||||
notification: NotificationModel,
|
notification: NotificationModel,
|
||||||
permissionProjection: PermissionProjectionModel,
|
|
||||||
workspaceMember: WorkspaceMemberModel,
|
workspaceMember: WorkspaceMemberModel,
|
||||||
workspaceInvitation: WorkspaceInvitationModel,
|
workspaceInvitation: WorkspaceInvitationModel,
|
||||||
workspaceAccessPolicy: WorkspaceAccessPolicyModel,
|
workspaceAccessPolicy: WorkspaceAccessPolicyModel,
|
||||||
@@ -172,7 +166,6 @@ export * from './history';
|
|||||||
export * from './magic-link-otp';
|
export * from './magic-link-otp';
|
||||||
export * from './mail-delivery';
|
export * from './mail-delivery';
|
||||||
export * from './notification';
|
export * from './notification';
|
||||||
export * from './permission-projection';
|
|
||||||
export * from './permission-write';
|
export * from './permission-write';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
@@ -183,6 +176,5 @@ export * from './verification-token';
|
|||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
export * from './workspace-analytics';
|
export * from './workspace-analytics';
|
||||||
export * from './workspace-calendar';
|
export * from './workspace-calendar';
|
||||||
export * from './workspace-feature';
|
|
||||||
export * from './workspace-runtime-state';
|
|
||||||
export * from './workspace-user';
|
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(
|
export function workspaceSourceFromNew(
|
||||||
source?: PermissionSource | WorkspaceInvitationKind
|
source?: PermissionSource | WorkspaceInvitationKind
|
||||||
): WorkspaceMemberSource {
|
): WorkspaceMemberSource {
|
||||||
@@ -141,10 +128,8 @@ export class WorkspaceMemberModel extends BaseModel {
|
|||||||
userId: string,
|
userId: string,
|
||||||
fallbackRole: WorkspaceRole
|
fallbackRole: WorkspaceRole
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
await this.db
|
||||||
await this.models.permissionProjection.lockWorkspaceOwnerTransfer(
|
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:workspace-owner:${workspaceId}`}, 0))`;
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
const ownerCount = await this.db.workspaceMember.count({
|
const ownerCount = await this.db.workspaceMember.count({
|
||||||
where: { workspaceId, role: 'owner', state: 'active' },
|
where: { workspaceId, role: 'owner', state: 'active' },
|
||||||
});
|
});
|
||||||
@@ -197,13 +182,20 @@ export class WorkspaceMemberModel extends BaseModel {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
role: WorkspaceRole,
|
role: WorkspaceRole,
|
||||||
data: { legacyPermissionId?: string | null; source?: PermissionSource } = {}
|
data: { source?: PermissionSource } = {}
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
if (role === WorkspaceRole.Owner) {
|
if (role === WorkspaceRole.Owner) {
|
||||||
throw new Error('Cannot grant Owner role of a workspace to a user.');
|
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({
|
await this.db.workspaceInvitation.deleteMany({
|
||||||
where: { workspaceId, inviteeUserId: userId },
|
where: { workspaceId, inviteeUserId: userId },
|
||||||
});
|
});
|
||||||
@@ -218,23 +210,21 @@ export class WorkspaceMemberModel extends BaseModel {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
role: workspaceRoleToNew(role),
|
role: workspaceRoleToNew(role),
|
||||||
legacyPermissionId: data.legacyPermissionId,
|
|
||||||
source: data.source,
|
source: data.source,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
|
id: invitation?.id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
role: workspaceRoleToNew(role),
|
role: workspaceRoleToNew(role),
|
||||||
state: 'active',
|
state: 'active',
|
||||||
source: data.source ?? 'legacy',
|
source: data.source ?? invitation?.kind ?? 'legacy',
|
||||||
legacyPermissionId: data.legacyPermissionId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async delete(workspaceId: string, userId: string) {
|
async delete(workspaceId: string, userId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
await this.db.$queryRaw`
|
await this.db.$queryRaw`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM workspace_members
|
FROM workspace_members
|
||||||
@@ -272,8 +262,6 @@ export class WorkspaceMemberModel extends BaseModel {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceInvitationModel extends BaseModel {
|
export class WorkspaceInvitationModel extends BaseModel {
|
||||||
private hasCurrentColumns?: Promise<boolean>;
|
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async set(
|
async set(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -285,7 +273,6 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
inviterId?: string;
|
inviterId?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
if (role === WorkspaceRole.Owner) {
|
if (role === WorkspaceRole.Owner) {
|
||||||
throw new Error('Cannot grant Owner role of a workspace to a user.');
|
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',
|
requestedRole: role === WorkspaceRole.Admin ? 'admin' : 'member',
|
||||||
status: invitationStatus,
|
status: invitationStatus,
|
||||||
kind: workspaceInvitationKindToNew(data.source),
|
kind: workspaceInvitationKindToNew(data.source),
|
||||||
source: workspaceSourceToNew(data.source),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +306,6 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
inviterId?: string;
|
inviterId?: string;
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
const invitationStatus = workspaceStatusToInvitationState(status);
|
const invitationStatus = workspaceStatusToInvitationState(status);
|
||||||
if (!invitationStatus) {
|
if (!invitationStatus) {
|
||||||
const invitation = await this.findInvitation(workspaceId, userId);
|
const invitation = await this.findInvitation(workspaceId, userId);
|
||||||
@@ -335,10 +320,7 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
{
|
{ source: invitation.source }
|
||||||
legacyPermissionId: invitation.legacyPermissionId,
|
|
||||||
source: invitation.source,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +334,6 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async deleteNonAccepted(workspaceId: string) {
|
async deleteNonAccepted(workspaceId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
return await this.db.workspaceInvitation.deleteMany({
|
return await this.db.workspaceInvitation.deleteMany({
|
||||||
where: { workspaceId },
|
where: { workspaceId },
|
||||||
});
|
});
|
||||||
@@ -360,7 +341,6 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async cancelPendingByActor(actorUserId: string) {
|
async cancelPendingByActor(actorUserId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
return await this.db.workspaceInvitation.deleteMany({
|
return await this.db.workspaceInvitation.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
inviterUserId: actorUserId,
|
inviterUserId: actorUserId,
|
||||||
@@ -373,7 +353,6 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async cancelPendingByWorkspace(workspaceId: string) {
|
async cancelPendingByWorkspace(workspaceId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
return await this.db.workspaceInvitation.deleteMany({
|
return await this.db.workspaceInvitation.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
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: {
|
private async upsertInvitation(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -403,99 +370,41 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
requestedRole: 'admin' | 'member';
|
requestedRole: 'admin' | 'member';
|
||||||
status: WorkspaceInvitationStatus;
|
status: WorkspaceInvitationStatus;
|
||||||
kind: WorkspaceInvitationKind;
|
kind: WorkspaceInvitationKind;
|
||||||
source: PermissionSource;
|
|
||||||
}) {
|
}) {
|
||||||
if (await this.supportsCurrentInvitationColumns()) {
|
return await this.db.workspaceInvitation.upsert({
|
||||||
return await this.db.$executeRaw`
|
where: {
|
||||||
INSERT INTO workspace_invitations (
|
workspaceId_inviteeUserId: {
|
||||||
workspace_id,
|
workspaceId: input.workspaceId,
|
||||||
invitee_user_id,
|
inviteeUserId: input.userId,
|
||||||
inviter_user_id,
|
},
|
||||||
requested_role,
|
},
|
||||||
status,
|
update: {
|
||||||
kind,
|
inviterUserId: input.inviterId,
|
||||||
updated_at
|
requestedRole: input.requestedRole,
|
||||||
)
|
status: input.status,
|
||||||
VALUES (
|
kind: input.kind,
|
||||||
${input.workspaceId},
|
},
|
||||||
${input.userId},
|
create: {
|
||||||
${input.inviterId ?? null},
|
workspaceId: input.workspaceId,
|
||||||
${input.requestedRole},
|
inviteeUserId: input.userId,
|
||||||
${input.status},
|
inviterUserId: input.inviterId,
|
||||||
${input.kind},
|
requestedRole: input.requestedRole,
|
||||||
now()
|
status: input.status,
|
||||||
)
|
kind: input.kind,
|
||||||
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()
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findInvitation(workspaceId: string, userId: string) {
|
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<
|
const rows = await this.db.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
requestedRole: 'admin' | 'member';
|
requestedRole: 'admin' | 'member';
|
||||||
legacyPermissionId: string | null;
|
|
||||||
source: PermissionSource;
|
source: PermissionSource;
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
role AS "requestedRole",
|
requested_role AS "requestedRole",
|
||||||
legacy_permission_id AS "legacyPermissionId",
|
kind AS source
|
||||||
source
|
|
||||||
FROM workspace_invitations
|
FROM workspace_invitations
|
||||||
WHERE workspace_id = ${workspaceId}
|
WHERE workspace_id = ${workspaceId}
|
||||||
AND invitee_user_id = ${userId}
|
AND invitee_user_id = ${userId}
|
||||||
@@ -510,27 +419,16 @@ export class WorkspaceInvitationModel extends BaseModel {
|
|||||||
status: WorkspaceInvitationStatus;
|
status: WorkspaceInvitationStatus;
|
||||||
inviterId?: string;
|
inviterId?: string;
|
||||||
}) {
|
}) {
|
||||||
if (await this.supportsCurrentInvitationColumns()) {
|
return await this.db.workspaceInvitation.updateMany({
|
||||||
return await this.db.$executeRaw`
|
where: {
|
||||||
UPDATE workspace_invitations
|
workspaceId: input.workspaceId,
|
||||||
SET
|
inviteeUserId: input.userId,
|
||||||
status = ${input.status},
|
},
|
||||||
inviter_user_id = ${input.inviterId ?? null},
|
data: {
|
||||||
updated_at = now()
|
status: input.status,
|
||||||
WHERE workspace_id = ${input.workspaceId}
|
inviterUserId: input.inviterId,
|
||||||
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}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +443,6 @@ export class WorkspaceAccessPolicyModel extends BaseModel {
|
|||||||
enableUrlPreview?: boolean;
|
enableUrlPreview?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
return await this.db.workspaceAccessPolicy.upsert({
|
return await this.db.workspaceAccessPolicy.upsert({
|
||||||
where: { workspaceId },
|
where: { workspaceId },
|
||||||
update: {
|
update: {
|
||||||
@@ -592,7 +489,6 @@ export class DocAccessPolicyModel extends BaseModel {
|
|||||||
urlPreviewEnabled?: boolean;
|
urlPreviewEnabled?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
const publicRole = policy.public ? 'external' : null;
|
const publicRole = policy.public ? 'external' : null;
|
||||||
return await this.db.docAccessPolicy.upsert({
|
return await this.db.docAccessPolicy.upsert({
|
||||||
where: { workspaceId_docId: { workspaceId, docId } },
|
where: { workspaceId_docId: { workspaceId, docId } },
|
||||||
@@ -635,11 +531,8 @@ export class DocAccessPolicyModel extends BaseModel {
|
|||||||
export class DocGrantModel extends BaseModel {
|
export class DocGrantModel extends BaseModel {
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async setOwner(workspaceId: string, docId: string, userId: string) {
|
async setOwner(workspaceId: string, docId: string, userId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
await this.db
|
||||||
await this.models.permissionProjection.lockDocOwnerTransfer(
|
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:doc-owner:${workspaceId}:${docId}`}, 0))`;
|
||||||
workspaceId,
|
|
||||||
docId
|
|
||||||
);
|
|
||||||
await this.db.docGrant.updateMany({
|
await this.db.docGrant.updateMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -656,7 +549,6 @@ export class DocGrantModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
|
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
assert(role !== DocRole.None && role !== DocRole.External);
|
assert(role !== DocRole.None && role !== DocRole.External);
|
||||||
|
|
||||||
return await this.db.docGrant.upsert({
|
return await this.db.docGrant.upsert({
|
||||||
@@ -688,7 +580,6 @@ export class DocGrantModel extends BaseModel {
|
|||||||
userIds: string[],
|
userIds: string[],
|
||||||
role: DocRole
|
role: DocRole
|
||||||
) {
|
) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
if (role === DocRole.Owner) {
|
if (role === DocRole.Owner) {
|
||||||
throw new CanNotBatchGrantDocOwnerPermissions();
|
throw new CanNotBatchGrantDocOwnerPermissions();
|
||||||
}
|
}
|
||||||
@@ -724,7 +615,6 @@ export class DocGrantModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async delete(workspaceId: string, docId: string, userId: string) {
|
async delete(workspaceId: string, docId: string, userId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
await this.db.$queryRaw`
|
await this.db.$queryRaw`
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM doc_grants
|
FROM doc_grants
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
@@ -23,22 +22,6 @@ export class UserFeatureModel extends BaseModel {
|
|||||||
return await this.models.feature.get(name);
|
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) {
|
async has(userId: string, name: UserFeatureName) {
|
||||||
const count = await this.db.userFeature.count({
|
const count = await this.db.userFeature.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -51,18 +34,13 @@ export class UserFeatureModel extends BaseModel {
|
|||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(userId: string, type?: FeatureType) {
|
async list(userId: string, type?: FeatureType, names?: UserFeatureName[]) {
|
||||||
const filter: Prisma.UserFeatureWhereInput =
|
const filter: Prisma.UserFeatureWhereInput = {
|
||||||
type === undefined
|
userId,
|
||||||
? {
|
activated: true,
|
||||||
userId,
|
type,
|
||||||
activated: true,
|
name: names ? { in: names } : undefined,
|
||||||
}
|
};
|
||||||
: {
|
|
||||||
userId,
|
|
||||||
activated: true,
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
|
|
||||||
const userFeatures = await this.db.userFeature.findMany({
|
const userFeatures = await this.db.userFeature.findMany({
|
||||||
where: filter,
|
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",
|
COALESCE(v.guest_views, 0) AS "guestViews",
|
||||||
v.last_accessed_at AS "lastAccessedAt"
|
v.last_accessed_at AS "lastAccessedAt"
|
||||||
FROM workspace_pages wp
|
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
|
LEFT JOIN snapshots sn
|
||||||
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
||||||
LEFT JOIN view_agg v
|
LEFT JOIN view_agg v
|
||||||
@@ -339,7 +341,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) owner ON TRUE
|
) 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
|
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`
|
`
|
||||||
@@ -642,6 +644,8 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
COALESCE(wp.published_at, to_timestamp(0)) AS "sortValueDatePublishedAt",
|
COALESCE(wp.published_at, to_timestamp(0)) AS "sortValueDatePublishedAt",
|
||||||
COALESCE(v.views, 0) AS "sortValueViews"
|
COALESCE(v.views, 0) AS "sortValueViews"
|
||||||
FROM workspace_pages wp
|
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
|
LEFT JOIN snapshots sn
|
||||||
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
|
||||||
LEFT JOIN view_agg v
|
LEFT JOIN view_agg v
|
||||||
@@ -655,7 +659,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) owner ON TRUE
|
) owner ON TRUE
|
||||||
WHERE wp.public = TRUE
|
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
|
||||||
${keywordCondition}
|
${keywordCondition}
|
||||||
${workspaceCondition}
|
${workspaceCondition}
|
||||||
${updatedAfterCondition}
|
${updatedAfterCondition}
|
||||||
@@ -1101,7 +1105,9 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM workspace_pages wp
|
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}
|
${keywordCondition}
|
||||||
${workspaceCondition}
|
${workspaceCondition}
|
||||||
${updatedAfterCondition}
|
${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,
|
WorkspaceMember,
|
||||||
WorkspaceMemberSource,
|
WorkspaceMemberSource,
|
||||||
WorkspaceMemberStatus,
|
WorkspaceMemberStatus,
|
||||||
WorkspaceUserRole,
|
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { groupBy } from 'lodash-es';
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
@@ -17,7 +16,16 @@ import {
|
|||||||
workspaceStatusFromNew,
|
workspaceStatusFromNew,
|
||||||
} from './permission-write';
|
} 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>;
|
user?: Pick<User, keyof typeof workspaceUserSelect>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +61,7 @@ export function workspaceMemberToCompat(
|
|||||||
member: WorkspaceMemberWithUser
|
member: WorkspaceMemberWithUser
|
||||||
): WorkspaceUserCompat {
|
): WorkspaceUserCompat {
|
||||||
return {
|
return {
|
||||||
id: member.legacyPermissionId ?? member.id,
|
id: member.id,
|
||||||
workspaceId: member.workspaceId,
|
workspaceId: member.workspaceId,
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
type: workspaceRoleFromNew(member.role as never),
|
type: workspaceRoleFromNew(member.role as never),
|
||||||
@@ -70,7 +78,7 @@ export function workspaceInvitationToCompat(
|
|||||||
invitation: WorkspaceInvitationWithUser
|
invitation: WorkspaceInvitationWithUser
|
||||||
): WorkspaceUserCompat {
|
): WorkspaceUserCompat {
|
||||||
return {
|
return {
|
||||||
id: invitation.legacyPermissionId ?? invitation.id,
|
id: invitation.id,
|
||||||
workspaceId: invitation.workspaceId,
|
workspaceId: invitation.workspaceId,
|
||||||
userId: invitation.inviteeUserId ?? '',
|
userId: invitation.inviteeUserId ?? '',
|
||||||
type: workspaceRoleFromNew(invitation.requestedRole as never),
|
type: workspaceRoleFromNew(invitation.requestedRole as never),
|
||||||
@@ -117,7 +125,7 @@ export async function queryCompatRows(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(wm.legacy_permission_id, wm.id) AS id,
|
wm.id AS id,
|
||||||
wm.workspace_id AS "workspaceId",
|
wm.workspace_id AS "workspaceId",
|
||||||
wm.user_id AS "userId",
|
wm.user_id AS "userId",
|
||||||
CASE wm.role
|
CASE wm.role
|
||||||
@@ -143,7 +151,7 @@ export async function queryCompatRows(
|
|||||||
AND wm.state = 'active'
|
AND wm.state = 'active'
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(wi.legacy_permission_id, wi.id) AS id,
|
wi.id AS id,
|
||||||
wi.workspace_id AS "workspaceId",
|
wi.workspace_id AS "workspaceId",
|
||||||
wi.invitee_user_id AS "userId",
|
wi.invitee_user_id AS "userId",
|
||||||
CASE wi.requested_role
|
CASE wi.requested_role
|
||||||
@@ -192,7 +200,7 @@ export async function searchCompatRows(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(wm.legacy_permission_id, wm.id) AS id,
|
wm.id AS id,
|
||||||
wm.workspace_id AS "workspaceId",
|
wm.workspace_id AS "workspaceId",
|
||||||
wm.user_id AS "userId",
|
wm.user_id AS "userId",
|
||||||
CASE wm.role
|
CASE wm.role
|
||||||
@@ -218,7 +226,7 @@ export async function searchCompatRows(
|
|||||||
AND wm.state = 'active'
|
AND wm.state = 'active'
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(wi.legacy_permission_id, wi.id) AS id,
|
wi.id AS id,
|
||||||
wi.workspace_id AS "workspaceId",
|
wi.workspace_id AS "workspaceId",
|
||||||
wi.invitee_user_id AS "userId",
|
wi.invitee_user_id AS "userId",
|
||||||
CASE wi.requested_role
|
CASE wi.requested_role
|
||||||
@@ -329,7 +337,6 @@ export async function hasSharedWorkspace(
|
|||||||
export async function allocateWorkspaceSeats(
|
export async function allocateWorkspaceSeats(
|
||||||
db: WorkspaceUserCompatDb,
|
db: WorkspaceUserCompatDb,
|
||||||
models: {
|
models: {
|
||||||
permissionProjection: { markNewWriteOrigin(): Promise<void> };
|
|
||||||
workspaceMember: {
|
workspaceMember: {
|
||||||
setActive(
|
setActive(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -341,7 +348,6 @@ export async function allocateWorkspaceSeats(
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
limit: number
|
limit: number
|
||||||
) {
|
) {
|
||||||
await models.permissionProjection.markNewWriteOrigin();
|
|
||||||
const [activeCount, pendingCount] = await Promise.all([
|
const [activeCount, pendingCount] = await Promise.all([
|
||||||
db.workspaceMember.count({
|
db.workspaceMember.count({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
WorkspaceMemberSource,
|
WorkspaceMemberSource,
|
||||||
WorkspaceMemberStatus,
|
WorkspaceMemberStatus,
|
||||||
WorkspaceUserRole,
|
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
|
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
searchCompatRows,
|
searchCompatRows,
|
||||||
workspaceInvitationToCompat,
|
workspaceInvitationToCompat,
|
||||||
workspaceMemberToCompat,
|
workspaceMemberToCompat,
|
||||||
|
type WorkspaceUserCompat,
|
||||||
} from './workspace-user-compat';
|
} from './workspace-user-compat';
|
||||||
|
|
||||||
export { WorkspaceMemberStatus };
|
export { WorkspaceMemberStatus };
|
||||||
@@ -190,39 +190,22 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
private async setExternal(
|
private async setExternal(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: 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({
|
await this.db.workspaceMember.deleteMany({
|
||||||
where: { workspaceId, userId, state: 'active' },
|
where: { workspaceId, userId, state: 'active' },
|
||||||
});
|
});
|
||||||
await this.db.workspaceInvitation.deleteMany({
|
await this.db.workspaceInvitation.deleteMany({
|
||||||
where: { workspaceId, inviteeUserId: userId },
|
where: { workspaceId, inviteeUserId: userId },
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
...oldRole,
|
||||||
if (oldRole) {
|
type: WorkspaceRole.External,
|
||||||
return await this.withPermissionProjectionMetric(
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setStatus(
|
async setStatus(
|
||||||
@@ -261,32 +244,16 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
await this.db.workspaceInvitation.deleteMany({
|
await this.db.workspaceInvitation.deleteMany({
|
||||||
where: { workspaceId, inviteeUserId: userId },
|
where: { workspaceId, inviteeUserId: userId },
|
||||||
});
|
});
|
||||||
await this.withPermissionProjectionMetric(
|
|
||||||
this.db.workspaceUserRole.deleteMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async deleteByUserId(userId: string) {
|
async deleteByUserId(userId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
await this.db.workspaceMember.deleteMany({
|
await this.db.workspaceMember.deleteMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
await this.db.workspaceInvitation.deleteMany({
|
await this.db.workspaceInvitation.deleteMany({
|
||||||
where: { inviteeUserId: userId },
|
where: { inviteeUserId: userId },
|
||||||
});
|
});
|
||||||
await this.withPermissionProjectionMetric(
|
|
||||||
this.db.workspaceUserRole.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNonAccepted(workspaceId: string) {
|
async deleteNonAccepted(workspaceId: string) {
|
||||||
@@ -295,7 +262,6 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async demoteAcceptedAdmins(workspaceId: string) {
|
async demoteAcceptedAdmins(workspaceId: string) {
|
||||||
await this.models.permissionProjection.markNewWriteOrigin();
|
|
||||||
return await this.db.workspaceMember.updateMany({
|
return await this.db.workspaceMember.updateMany({
|
||||||
where: { workspaceId, role: 'admin', state: 'active' },
|
where: { workspaceId, role: 'admin', state: 'active' },
|
||||||
data: { role: 'member' },
|
data: { role: 'member' },
|
||||||
@@ -326,20 +292,12 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
return workspaceInvitationToCompat(invitation);
|
return workspaceInvitationToCompat(invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.db.workspaceUserRole.findFirst({
|
return null;
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
type: WorkspaceRole.External,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
const member = await this.db.workspaceMember.findFirst({
|
const member = await this.db.workspaceMember.findFirst({
|
||||||
where: {
|
where: { id },
|
||||||
OR: [{ id }, { legacyPermissionId: id }],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (member) {
|
if (member) {
|
||||||
return workspaceMemberToCompat(member);
|
return workspaceMemberToCompat(member);
|
||||||
@@ -347,7 +305,7 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
|
|
||||||
const invitation = await this.db.workspaceInvitation.findFirst({
|
const invitation = await this.db.workspaceInvitation.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ id }, { legacyPermissionId: id }],
|
id,
|
||||||
inviteeUserId: {
|
inviteeUserId: {
|
||||||
not: null,
|
not: null,
|
||||||
},
|
},
|
||||||
@@ -357,9 +315,7 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
return workspaceInvitationToCompat(invitation);
|
return workspaceInvitationToCompat(invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.db.workspaceUserRole.findUnique({
|
return null;
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
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 { EventBus } from '../base';
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
import type { WorkspaceFeatureName } from './common';
|
|
||||||
|
|
||||||
type RawWorkspaceSummary = {
|
type RawWorkspaceSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,7 +21,6 @@ type RawWorkspaceSummary = {
|
|||||||
snapshotSize: bigint | number | null;
|
snapshotSize: bigint | number | null;
|
||||||
blobCount: bigint | number | null;
|
blobCount: bigint | number | null;
|
||||||
blobSize: bigint | number | null;
|
blobSize: bigint | number | null;
|
||||||
features: WorkspaceFeatureName[] | null;
|
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
ownerEmail: string | null;
|
ownerEmail: string | null;
|
||||||
@@ -45,7 +43,6 @@ export type AdminWorkspaceSummary = {
|
|||||||
snapshotSize: number;
|
snapshotSize: number;
|
||||||
blobCount: number;
|
blobCount: number;
|
||||||
blobSize: number;
|
blobSize: number;
|
||||||
features: WorkspaceFeatureName[];
|
|
||||||
owner: {
|
owner: {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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<
|
export type UpdateWorkspaceInput = Pick<
|
||||||
Partial<Workspace>,
|
Partial<WorkspaceRecord>,
|
||||||
| 'public'
|
|
||||||
| 'enableAi'
|
| 'enableAi'
|
||||||
| 'enableSharing'
|
|
||||||
| 'enableUrlPreview'
|
|
||||||
| 'enableDocEmbedding'
|
| 'enableDocEmbedding'
|
||||||
| 'name'
|
| 'name'
|
||||||
| 'avatarKey'
|
| 'avatarKey'
|
||||||
| 'indexed'
|
| 'indexed'
|
||||||
| 'lastCheckEmbeddings'
|
| 'lastCheckEmbeddings'
|
||||||
>;
|
> & {
|
||||||
|
public?: boolean;
|
||||||
|
enableSharing?: boolean;
|
||||||
|
enableUrlPreview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceModel extends BaseModel {
|
export class WorkspaceModel extends BaseModel {
|
||||||
@@ -89,44 +91,60 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
*/
|
*/
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async create(userId: string) {
|
async create(userId: string) {
|
||||||
const workspace = await this.withPermissionProjectionMetric(
|
const workspace = await this.db.workspace.create({
|
||||||
this.db.workspace.create({
|
data: {
|
||||||
data: { public: false },
|
accessPolicy: {
|
||||||
})
|
create: {
|
||||||
);
|
visibility: 'private',
|
||||||
|
sharingEnabled: true,
|
||||||
|
urlPreviewEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { accessPolicy: true },
|
||||||
|
});
|
||||||
this.logger.log(`Workspace created with id ${workspace.id}`);
|
this.logger.log(`Workspace created with id ${workspace.id}`);
|
||||||
await this.models.workspaceUser.setOwner(workspace.id, userId);
|
await this.models.workspaceUser.setOwner(workspace.id, userId);
|
||||||
return workspace;
|
return this.withAccessPolicy(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the workspace with the given data.
|
* Update the workspace with the given data.
|
||||||
*/
|
*/
|
||||||
|
@Transactional()
|
||||||
async update(
|
async update(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: UpdateWorkspaceInput,
|
data: UpdateWorkspaceInput,
|
||||||
notifyUpdate = true
|
notifyUpdate = true
|
||||||
) {
|
) {
|
||||||
|
const {
|
||||||
|
public: isPublic,
|
||||||
|
enableSharing,
|
||||||
|
enableUrlPreview,
|
||||||
|
...workspaceData
|
||||||
|
} = data;
|
||||||
if (
|
if (
|
||||||
data.public !== undefined ||
|
isPublic !== undefined ||
|
||||||
data.enableSharing !== undefined ||
|
enableSharing !== undefined ||
|
||||||
data.enableUrlPreview !== undefined
|
enableUrlPreview !== undefined
|
||||||
) {
|
) {
|
||||||
await this.models.workspaceAccessPolicy.upsert(workspaceId, {
|
await this.models.workspaceAccessPolicy.upsert(workspaceId, {
|
||||||
public: data.public,
|
public: isPublic,
|
||||||
enableSharing: data.enableSharing,
|
enableSharing,
|
||||||
enableUrlPreview: data.enableUrlPreview,
|
enableUrlPreview,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await this.withPermissionProjectionMetric(
|
await this.db.workspace.update({
|
||||||
this.db.workspace.update({
|
where: {
|
||||||
where: {
|
id: workspaceId,
|
||||||
id: workspaceId,
|
},
|
||||||
},
|
data: workspaceData,
|
||||||
data,
|
});
|
||||||
})
|
const workspace = await this.get(workspaceId);
|
||||||
);
|
if (!workspace) {
|
||||||
|
throw new Error(`Workspace ${workspaceId} not found after update`);
|
||||||
|
}
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
|
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
|
||||||
);
|
);
|
||||||
@@ -139,19 +157,23 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(workspaceId: string) {
|
async get(workspaceId: string) {
|
||||||
return await this.db.workspace.findUnique({
|
const workspace = await this.db.workspace.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
},
|
},
|
||||||
|
include: { accessPolicy: true },
|
||||||
});
|
});
|
||||||
|
return workspace ? this.withAccessPolicy(workspace) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findMany(ids: string[]) {
|
async findMany(ids: string[]) {
|
||||||
return await this.db.workspace.findMany({
|
const workspaces = await this.db.workspace.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
},
|
},
|
||||||
|
include: { accessPolicy: true },
|
||||||
});
|
});
|
||||||
|
return workspaces.map(workspace => this.withAccessPolicy(workspace));
|
||||||
}
|
}
|
||||||
|
|
||||||
async list<S extends Prisma.WorkspaceSelect>(
|
async list<S extends Prisma.WorkspaceSelect>(
|
||||||
@@ -169,14 +191,13 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
|
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional()
|
||||||
async delete(workspaceId: string) {
|
async delete(workspaceId: string) {
|
||||||
const rawResult = await this.withPermissionProjectionMetric(
|
const rawResult = await this.db.workspace.deleteMany({
|
||||||
this.db.workspace.deleteMany({
|
where: {
|
||||||
where: {
|
id: workspaceId,
|
||||||
id: workspaceId,
|
},
|
||||||
},
|
});
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rawResult.count > 0) {
|
if (rawResult.count > 0) {
|
||||||
this.event.emit('workspace.deleted', { id: workspaceId });
|
this.event.emit('workspace.deleted', { id: workspaceId });
|
||||||
@@ -185,13 +206,19 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async allowUrlPreview(workspaceId: string) {
|
async allowUrlPreview(workspaceId: string) {
|
||||||
const workspace = await this.get(workspaceId);
|
const policy = await this.db.workspaceAccessPolicy.findUnique({
|
||||||
return workspace?.enableUrlPreview ?? false;
|
where: { workspaceId },
|
||||||
|
select: { urlPreviewEnabled: true },
|
||||||
|
});
|
||||||
|
return policy?.urlPreviewEnabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async allowSharing(workspaceId: string) {
|
async allowSharing(workspaceId: string) {
|
||||||
const workspace = await this.get(workspaceId);
|
const policy = await this.db.workspaceAccessPolicy.findUnique({
|
||||||
return workspace?.enableSharing ?? true;
|
where: { workspaceId },
|
||||||
|
select: { sharingEnabled: true },
|
||||||
|
});
|
||||||
|
return policy?.sharingEnabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async allowEmbedding(workspaceId: string) {
|
async allowEmbedding(workspaceId: string) {
|
||||||
@@ -218,6 +245,20 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
|
|
||||||
return count > 0;
|
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
|
// #endregion
|
||||||
|
|
||||||
// #region admin
|
// #region admin
|
||||||
@@ -225,7 +266,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
skip: number;
|
skip: number;
|
||||||
first: number;
|
first: number;
|
||||||
keyword?: string | null;
|
keyword?: string | null;
|
||||||
features?: WorkspaceFeatureName[] | null;
|
|
||||||
flags?: {
|
flags?: {
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
enableAi?: boolean;
|
enableAi?: boolean;
|
||||||
@@ -244,89 +284,34 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
includeTotal?: boolean;
|
includeTotal?: boolean;
|
||||||
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
||||||
const keyword = options.keyword?.trim();
|
const keyword = options.keyword?.trim();
|
||||||
const features = options.features ?? [];
|
|
||||||
const flags = options.flags ?? {};
|
const flags = options.flags ?? {};
|
||||||
const includeTotal = options.includeTotal ?? true;
|
const includeTotal = options.includeTotal ?? true;
|
||||||
const total = includeTotal
|
const total = includeTotal
|
||||||
? await this.adminCountWorkspaces({ keyword, features, flags })
|
? await this.adminCountWorkspaces({ keyword, flags })
|
||||||
: 0;
|
: 0;
|
||||||
if (includeTotal && total === 0) {
|
if (includeTotal && total === 0) {
|
||||||
return { rows: [], 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) {
|
if (!keyword) {
|
||||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
WITH filtered AS (
|
WITH filtered AS (
|
||||||
SELECT w.id,
|
SELECT w.id,
|
||||||
w.public,
|
(wap.visibility = 'public') AS public,
|
||||||
w.created_at AS "createdAt",
|
w.created_at AS "createdAt",
|
||||||
w.name,
|
w.name,
|
||||||
w.avatar_key AS "avatarKey",
|
w.avatar_key AS "avatarKey",
|
||||||
w.enable_ai AS "enableAi",
|
w.enable_ai AS "enableAi",
|
||||||
w.enable_sharing AS "enableSharing",
|
wap.sharing_enabled AS "enableSharing",
|
||||||
w.enable_url_preview AS "enableUrlPreview",
|
wap.url_preview_enabled AS "enableUrlPreview",
|
||||||
w.enable_doc_embedding AS "enableDocEmbedding"
|
w.enable_doc_embedding AS "enableDocEmbedding"
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
${featureJoin}
|
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
|
||||||
WHERE ${
|
WHERE ${
|
||||||
this.buildAdminFlagWhere(flags).length
|
this.buildAdminFlagWhere(flags).length
|
||||||
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
||||||
: Prisma.sql`TRUE`
|
: Prisma.sql`TRUE`
|
||||||
}
|
}
|
||||||
${workspaceOnlyGroupAndHaving}
|
|
||||||
),
|
),
|
||||||
page AS (
|
page AS (
|
||||||
SELECT f.*,
|
SELECT f.*,
|
||||||
@@ -335,8 +320,7 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
COALESCE(s.blob_count, 0) AS "blobCount",
|
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||||
COALESCE(s.blob_size, 0) AS "blobSize",
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
COALESCE(s.member_count, 0) AS "memberCount",
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
COALESCE(s.public_page_count, 0) AS "publicPageCount"
|
||||||
COALESCE(s.features, ARRAY[]::text[]) AS features
|
|
||||||
FROM filtered f
|
FROM filtered f
|
||||||
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
||||||
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||||
@@ -371,19 +355,20 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
WITH filtered AS (
|
WITH filtered AS (
|
||||||
SELECT w.id,
|
SELECT w.id,
|
||||||
w.public,
|
(wap.visibility = 'public') AS public,
|
||||||
w.created_at AS "createdAt",
|
w.created_at AS "createdAt",
|
||||||
w.name,
|
w.name,
|
||||||
w.avatar_key AS "avatarKey",
|
w.avatar_key AS "avatarKey",
|
||||||
w.enable_ai AS "enableAi",
|
w.enable_ai AS "enableAi",
|
||||||
w.enable_sharing AS "enableSharing",
|
wap.sharing_enabled AS "enableSharing",
|
||||||
w.enable_url_preview AS "enableUrlPreview",
|
wap.url_preview_enabled AS "enableUrlPreview",
|
||||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||||
o.owner_id AS "ownerId",
|
o.owner_id AS "ownerId",
|
||||||
o.owner_name AS "ownerName",
|
o.owner_name AS "ownerName",
|
||||||
o.owner_email AS "ownerEmail",
|
o.owner_email AS "ownerEmail",
|
||||||
o.owner_avatar_url AS "ownerAvatarUrl"
|
o.owner_avatar_url AS "ownerAvatarUrl"
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
|
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT u.id AS owner_id,
|
SELECT u.id AS owner_id,
|
||||||
u.name AS owner_name,
|
u.name AS owner_name,
|
||||||
@@ -397,7 +382,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
ORDER BY u.created_at ASC, wm.id ASC
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) o ON TRUE
|
) o ON TRUE
|
||||||
${featureJoin}
|
|
||||||
WHERE ${
|
WHERE ${
|
||||||
keyword
|
keyword
|
||||||
? Prisma.sql`
|
? Prisma.sql`
|
||||||
@@ -417,7 +401,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
)}`
|
)}`
|
||||||
: Prisma.empty
|
: Prisma.empty
|
||||||
}
|
}
|
||||||
${groupAndHaving}
|
|
||||||
)
|
)
|
||||||
SELECT f.*,
|
SELECT f.*,
|
||||||
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
|
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_count, 0) AS "blobCount",
|
||||||
COALESCE(s.blob_size, 0) AS "blobSize",
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
COALESCE(s.member_count, 0) AS "memberCount",
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
COALESCE(s.public_page_count, 0) AS "publicPageCount"
|
||||||
COALESCE(s.features, ARRAY[]::text[]) AS features
|
|
||||||
FROM filtered f
|
FROM filtered f
|
||||||
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
||||||
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||||
@@ -454,7 +436,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
snapshotSize: Number(row.snapshotSize ?? 0),
|
snapshotSize: Number(row.snapshotSize ?? 0),
|
||||||
blobCount: Number(row.blobCount ?? 0),
|
blobCount: Number(row.blobCount ?? 0),
|
||||||
blobSize: Number(row.blobSize ?? 0),
|
blobSize: Number(row.blobSize ?? 0),
|
||||||
features: (row.features ?? []) as WorkspaceFeatureName[],
|
|
||||||
owner: row.ownerId
|
owner: row.ownerId
|
||||||
? {
|
? {
|
||||||
id: row.ownerId,
|
id: row.ownerId,
|
||||||
@@ -469,13 +450,13 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
async adminGetWorkspace(id: string) {
|
async adminGetWorkspace(id: string) {
|
||||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
SELECT w.id,
|
SELECT w.id,
|
||||||
w.public,
|
(wap.visibility = 'public') AS public,
|
||||||
w.created_at AS "createdAt",
|
w.created_at AS "createdAt",
|
||||||
w.name,
|
w.name,
|
||||||
w.avatar_key AS "avatarKey",
|
w.avatar_key AS "avatarKey",
|
||||||
w.enable_ai AS "enableAi",
|
w.enable_ai AS "enableAi",
|
||||||
w.enable_sharing AS "enableSharing",
|
wap.sharing_enabled AS "enableSharing",
|
||||||
w.enable_url_preview AS "enableUrlPreview",
|
wap.url_preview_enabled AS "enableUrlPreview",
|
||||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||||
o.owner_id AS "ownerId",
|
o.owner_id AS "ownerId",
|
||||||
o.owner_name AS "ownerName",
|
o.owner_name AS "ownerName",
|
||||||
@@ -486,9 +467,9 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
COALESCE(s.blob_count, 0) AS "blobCount",
|
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||||
COALESCE(s.blob_size, 0) AS "blobSize",
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
COALESCE(s.member_count, 0) AS "memberCount",
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
COALESCE(s.public_page_count, 0) AS "publicPageCount"
|
||||||
COALESCE(s.features, ARRAY[]::text[]) AS features
|
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
|
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT u.id AS owner_id,
|
SELECT u.id AS owner_id,
|
||||||
u.name AS owner_name,
|
u.name AS owner_name,
|
||||||
@@ -512,7 +493,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
|
|
||||||
async adminCountWorkspaces(options: {
|
async adminCountWorkspaces(options: {
|
||||||
keyword?: string | null;
|
keyword?: string | null;
|
||||||
features?: WorkspaceFeatureName[] | null;
|
|
||||||
flags?: {
|
flags?: {
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
enableAi?: boolean;
|
enableAi?: boolean;
|
||||||
@@ -522,13 +502,13 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const keyword = options.keyword?.trim();
|
const keyword = options.keyword?.trim();
|
||||||
const features = options.features ?? [];
|
|
||||||
const flags = options.flags ?? {};
|
const flags = options.flags ?? {};
|
||||||
|
|
||||||
if (!keyword && features.length === 0) {
|
if (!keyword) {
|
||||||
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
|
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
|
||||||
WHERE ${
|
WHERE ${
|
||||||
this.buildAdminFlagWhere(flags).length
|
this.buildAdminFlagWhere(flags).length
|
||||||
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
||||||
@@ -539,58 +519,13 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
return row?.total ? Number(row.total) : 0;
|
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 }[]>`
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
WITH filtered AS (
|
WITH filtered AS (
|
||||||
SELECT w.id,
|
SELECT w.id,
|
||||||
o.owner_id AS "ownerId",
|
o.owner_id AS "ownerId",
|
||||||
o.owner_email AS "ownerEmail"
|
o.owner_email AS "ownerEmail"
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
|
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT wm.workspace_id,
|
SELECT wm.workspace_id,
|
||||||
u.id AS owner_id,
|
u.id AS owner_id,
|
||||||
@@ -603,7 +538,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
ORDER BY u.created_at ASC, wm.id ASC
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) o ON TRUE
|
) o ON TRUE
|
||||||
${featureJoin}
|
|
||||||
WHERE ${
|
WHERE ${
|
||||||
keyword
|
keyword
|
||||||
? Prisma.sql`
|
? Prisma.sql`
|
||||||
@@ -623,7 +557,6 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
)}`
|
)}`
|
||||||
: Prisma.empty
|
: Prisma.empty
|
||||||
}
|
}
|
||||||
${groupAndHaving}
|
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) AS total FROM filtered
|
SELECT COUNT(*) AS total FROM filtered
|
||||||
`;
|
`;
|
||||||
@@ -640,17 +573,19 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
}) {
|
}) {
|
||||||
const conditions: Prisma.Sql[] = [];
|
const conditions: Prisma.Sql[] = [];
|
||||||
if (flags.public !== undefined) {
|
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) {
|
if (flags.enableAi !== undefined) {
|
||||||
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
|
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
|
||||||
}
|
}
|
||||||
if (flags.enableSharing !== undefined) {
|
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) {
|
if (flags.enableUrlPreview !== undefined) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
Prisma.sql`w.enable_url_preview = ${flags.enableUrlPreview}`
|
Prisma.sql`wap.url_preview_enabled = ${flags.enableUrlPreview}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (flags.enableDocEmbedding !== undefined) {
|
if (flags.enableDocEmbedding !== undefined) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
BadRequest,
|
BadRequest,
|
||||||
Cache,
|
Cache,
|
||||||
|
Config,
|
||||||
CryptoHelper,
|
CryptoHelper,
|
||||||
metrics,
|
metrics,
|
||||||
safeFetch,
|
safeFetch,
|
||||||
@@ -115,11 +116,12 @@ export class ByokService {
|
|||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly crypto: CryptoHelper,
|
private readonly crypto: CryptoHelper,
|
||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly entitlement: ByokEntitlementPolicy
|
private readonly entitlement: ByokEntitlementPolicy,
|
||||||
|
private readonly config: Config
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get customEndpointSupported() {
|
get customEndpointSupported() {
|
||||||
return env.selfhosted;
|
return env.selfhosted && this.config.copilot.byok.allowCustomEndpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSettings(
|
async getSettings(
|
||||||
|
|||||||
@@ -188,10 +188,7 @@ export class IndexerResolver {
|
|||||||
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
|
docIdColumn: Prisma.raw('candidate_docs.doc_id'),
|
||||||
} as const;
|
} as const;
|
||||||
const predicate = this.permission.docReadableSqlPredicate(input);
|
const predicate = this.permission.docReadableSqlPredicate(input);
|
||||||
const fallbackPredicate =
|
const rows = await this.db.$queryRaw<{ docId: string }[]>`
|
||||||
this.permission.fallbackDocReadableSqlPredicate(input);
|
|
||||||
const query = (predicate: Prisma.Sql) =>
|
|
||||||
this.db.$queryRaw<{ docId: string }[]>`
|
|
||||||
WITH candidate_docs AS (
|
WITH candidate_docs AS (
|
||||||
SELECT "workspace_pages"."page_id" AS doc_id
|
SELECT "workspace_pages"."page_id" AS doc_id
|
||||||
FROM "workspace_pages"
|
FROM "workspace_pages"
|
||||||
@@ -205,12 +202,6 @@ export class IndexerResolver {
|
|||||||
FROM candidate_docs
|
FROM candidate_docs
|
||||||
WHERE ${predicate}
|
WHERE ${predicate}
|
||||||
`;
|
`;
|
||||||
const rows = await query(predicate).catch(error => {
|
|
||||||
if (!fallbackPredicate) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return query(fallbackPredicate);
|
|
||||||
});
|
|
||||||
return rows.map(row => row.docId);
|
return rows.map(row => row.docId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
import { InstalledLicense, PrismaClient } from '@prisma/client';
|
import { InstalledLicense, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -104,6 +105,7 @@ export class LicenseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional()
|
||||||
async installLicense(workspaceId: string, license: Buffer) {
|
async installLicense(workspaceId: string, license: Buffer) {
|
||||||
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
|
const resolved = this.resolveWorkspaceTeamLicense(workspaceId, license);
|
||||||
if (!resolved.valid) {
|
if (!resolved.valid) {
|
||||||
@@ -111,13 +113,37 @@ export class LicenseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validatedAt = new Date();
|
const validatedAt = new Date();
|
||||||
|
const previous = await this.db.installedLicense.findUnique({
|
||||||
await this.event.emitAsync('workspace.subscription.activated', {
|
where: { workspaceId },
|
||||||
workspaceId,
|
|
||||||
plan: SubscriptionPlan.SelfHostedTeam,
|
|
||||||
recurring: this.licenseRecurring(resolved),
|
|
||||||
quantity: this.licenseQuantity(resolved),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
await this.entitlement.upsertFromSelfhostLicense({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
recurring: this.licenseRecurring(resolved),
|
recurring: this.licenseRecurring(resolved),
|
||||||
@@ -127,10 +153,14 @@ export class LicenseService {
|
|||||||
variant: SubscriptionVariant.Onetime,
|
variant: SubscriptionVariant.Onetime,
|
||||||
license,
|
license,
|
||||||
});
|
});
|
||||||
|
await this.event.emitAsync('workspace.subscription.activated', {
|
||||||
return this.db.installedLicense.findUniqueOrThrow({
|
workspaceId,
|
||||||
where: { workspaceId },
|
plan: SubscriptionPlan.SelfHostedTeam,
|
||||||
|
recurring: this.licenseRecurring(resolved),
|
||||||
|
quantity: this.licenseQuantity(resolved),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return installed;
|
||||||
}
|
}
|
||||||
|
|
||||||
previewLicense(license: Buffer): LicensePreview {
|
previewLicense(license: Buffer): LicensePreview {
|
||||||
@@ -157,6 +187,7 @@ export class LicenseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional()
|
||||||
async activateTeamLicense(workspaceId: string, licenseKey: string) {
|
async activateTeamLicense(workspaceId: string, licenseKey: string) {
|
||||||
const installedLicense = await this.getLicense(workspaceId);
|
const installedLicense = await this.getLicense(workspaceId);
|
||||||
|
|
||||||
@@ -177,35 +208,8 @@ export class LicenseService {
|
|||||||
const validatedAt = new Date();
|
const validatedAt = new Date();
|
||||||
const expiresAt = new Date(data.expiresAt);
|
const expiresAt = new Date(data.expiresAt);
|
||||||
|
|
||||||
this.event.emit('workspace.subscription.activated', {
|
const installed = await this.db.installedLicense.create({
|
||||||
workspaceId,
|
data: {
|
||||||
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: {
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
key: licenseKey,
|
key: licenseKey,
|
||||||
quantity: data.quantity,
|
quantity: data.quantity,
|
||||||
@@ -217,6 +221,22 @@ export class LicenseService {
|
|||||||
license: null,
|
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) {
|
async removeTeamLicense(workspaceId: string) {
|
||||||
@@ -487,18 +507,33 @@ export class LicenseService {
|
|||||||
if (!resolved.valid) {
|
if (!resolved.valid) {
|
||||||
valid = false;
|
valid = false;
|
||||||
} else {
|
} 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', {
|
this.event.emit('workspace.subscription.activated', {
|
||||||
workspaceId: license.workspaceId,
|
workspaceId: license.workspaceId,
|
||||||
plan: SubscriptionPlan.SelfHostedTeam,
|
plan: SubscriptionPlan.SelfHostedTeam,
|
||||||
recurring: this.licenseRecurring(resolved),
|
recurring,
|
||||||
quantity: this.licenseQuantity(resolved),
|
quantity,
|
||||||
});
|
});
|
||||||
await this.entitlement.upsertFromSelfhostLicense({
|
await this.entitlement.upsertFromSelfhostLicense({
|
||||||
workspaceId: license.workspaceId,
|
workspaceId: license.workspaceId,
|
||||||
recurring: this.licenseRecurring(resolved),
|
recurring,
|
||||||
quantity: this.licenseQuantity(resolved),
|
quantity,
|
||||||
expiresAt: this.licenseExpiresAt(resolved),
|
expiresAt,
|
||||||
validatedAt: new Date(),
|
validatedAt,
|
||||||
|
variant: SubscriptionVariant.Onetime,
|
||||||
license: Buffer.from(buf),
|
license: Buffer.from(buf),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,21 +105,22 @@ export class SubscriptionCronJobs {
|
|||||||
const { start: before180DaysStart, end: before180DaysEnd } =
|
const { start: before180DaysStart, end: before180DaysEnd } =
|
||||||
this.getDateRange(-180);
|
this.getDateRange(-180);
|
||||||
|
|
||||||
const subscriptions = await this.db.subscription.findMany({
|
const subscriptions = await this.db.providerSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'workspace',
|
||||||
plan: SubscriptionPlan.Team,
|
plan: SubscriptionPlan.Team,
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
// subscription will cancel after 30 days
|
// subscription will cancel after 30 days
|
||||||
status: 'active',
|
status: 'active',
|
||||||
canceledAt: { not: null },
|
canceledAt: { not: null },
|
||||||
end: { gte: after30DayStart, lte: after30DayEnd },
|
periodEnd: { gte: after30DayStart, lte: after30DayEnd },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// subscription will cancel today
|
// subscription will cancel today
|
||||||
status: 'active',
|
status: 'active',
|
||||||
canceledAt: { not: null },
|
canceledAt: { not: null },
|
||||||
end: { gte: todayStart, lte: todayEnd },
|
periodEnd: { gte: todayStart, lte: todayEnd },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// subscription has been canceled for 150 days
|
// subscription has been canceled for 150 days
|
||||||
@@ -138,13 +139,13 @@ export class SubscriptionCronJobs {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
const end = subscription.end;
|
const end = subscription.periodEnd;
|
||||||
if (!end) {
|
if (!end) {
|
||||||
// should not reach here
|
// should not reach here
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subscription.nextBillAt) {
|
if (subscription.canceledAt) {
|
||||||
this.event.emit('workspace.subscription.notify', {
|
this.event.emit('workspace.subscription.notify', {
|
||||||
workspaceId: subscription.targetId,
|
workspaceId: subscription.targetId,
|
||||||
expirationDate: end,
|
expirationDate: end,
|
||||||
@@ -156,10 +157,14 @@ export class SubscriptionCronJobs {
|
|||||||
|
|
||||||
@OnJob('nightly.cleanExpiredOnetimeSubscriptions')
|
@OnJob('nightly.cleanExpiredOnetimeSubscriptions')
|
||||||
async cleanExpiredOnetimeSubscriptions() {
|
async cleanExpiredOnetimeSubscriptions() {
|
||||||
const subscriptions = await this.db.subscription.findMany({
|
const subscriptions = await this.db.providerSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
variant: SubscriptionVariant.Onetime,
|
targetType: 'user',
|
||||||
end: {
|
metadata: {
|
||||||
|
path: ['variant'],
|
||||||
|
equals: SubscriptionVariant.Onetime,
|
||||||
|
},
|
||||||
|
periodEnd: {
|
||||||
lte: new Date(),
|
lte: new Date(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -170,21 +175,16 @@ export class SubscriptionCronJobs {
|
|||||||
targetId: subscription.targetId,
|
targetId: subscription.targetId,
|
||||||
plan: subscription.plan,
|
plan: subscription.plan,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
stripeSubscriptionId: subscription.externalSubscriptionId,
|
||||||
});
|
});
|
||||||
await this.db.subscription.delete({
|
await this.db.providerSubscription.delete({
|
||||||
where: {
|
where: { id: subscription.id },
|
||||||
targetId_plan: {
|
|
||||||
targetId: subscription.targetId,
|
|
||||||
plan: subscription.plan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.event.emit('user.subscription.canceled', {
|
this.event.emit('user.subscription.canceled', {
|
||||||
userId: subscription.targetId,
|
userId: subscription.targetId,
|
||||||
plan: subscription.plan as SubscriptionPlan,
|
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')
|
@OnJob('nightly.reconcileRevenueCatSubscriptions')
|
||||||
async reconcileRevenueCatSubscriptions() {
|
async reconcileRevenueCatSubscriptions() {
|
||||||
// Find active/trialing/past_due RC subscriptions and resync via RC REST
|
// 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: {
|
where: {
|
||||||
provider: Provider.revenuecat,
|
provider: Provider.revenuecat,
|
||||||
|
targetType: 'user',
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: [
|
||||||
SubscriptionStatus.Active,
|
SubscriptionStatus.Active,
|
||||||
@@ -287,10 +288,11 @@ export class SubscriptionCronJobs {
|
|||||||
@OnJob('nightly.reconcileStripeSubscriptions')
|
@OnJob('nightly.reconcileStripeSubscriptions')
|
||||||
async reconcileStripeSubscriptions() {
|
async reconcileStripeSubscriptions() {
|
||||||
const stripe = this.stripeFactory.stripe;
|
const stripe = this.stripeFactory.stripe;
|
||||||
const subs = await this.db.subscription.findMany({
|
const subs = await this.db.providerSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
stripeSubscriptionId: { not: null },
|
externalSubscriptionId: { not: null },
|
||||||
|
recurring: { not: SubscriptionRecurring.Lifetime },
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: [
|
||||||
SubscriptionStatus.Active,
|
SubscriptionStatus.Active,
|
||||||
@@ -299,13 +301,13 @@ export class SubscriptionCronJobs {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { stripeSubscriptionId: true },
|
select: { externalSubscriptionId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscriptionIds = Array.from(
|
const subscriptionIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
subs
|
subs
|
||||||
.map(sub => sub.stripeSubscriptionId)
|
.map(sub => sub.externalSubscriptionId)
|
||||||
.filter((id): id is string => !!id)
|
.filter((id): id is string => !!id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaClient, Subscription } from '@prisma/client';
|
import { PrismaClient, Provider } from '@prisma/client';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Mutex,
|
Mutex,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
import { Public } from '../../../core/auth';
|
import { Public } from '../../../core/auth';
|
||||||
|
import type { Subscription } from '../manager';
|
||||||
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
|
import { SelfhostTeamSubscriptionManager } from '../manager/selfhost';
|
||||||
import { SubscriptionService } from '../service';
|
import { SubscriptionService } from '../service';
|
||||||
import { StripeFactory } from '../stripe';
|
import { StripeFactory } from '../stripe';
|
||||||
@@ -237,19 +238,23 @@ export class LicenseController {
|
|||||||
|
|
||||||
@Post('/:license/create-customer-portal')
|
@Post('/:license/create-customer-portal')
|
||||||
async createCustomerPortal(@Param('license') key: string) {
|
async createCustomerPortal(@Param('license') key: string) {
|
||||||
const subscription = await this.db.subscription.findFirst({
|
const subscription = await this.db.providerSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
provider: Provider.stripe,
|
||||||
|
targetType: 'instance',
|
||||||
targetId: key,
|
targetId: key,
|
||||||
|
plan: SubscriptionPlan.SelfHostedTeam,
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!subscription || !subscription.stripeSubscriptionId) {
|
if (!subscription?.externalSubscriptionId) {
|
||||||
throw new LicenseNotFound();
|
throw new LicenseNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionData =
|
const subscriptionData =
|
||||||
await this.stripeProvider.stripe.subscriptions.retrieve(
|
await this.stripeProvider.stripe.subscriptions.retrieve(
|
||||||
subscription.stripeSubscriptionId,
|
subscription.externalSubscriptionId,
|
||||||
{
|
{
|
||||||
expand: ['customer'],
|
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 Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -19,13 +24,13 @@ import {
|
|||||||
|
|
||||||
export function validSubscriptionPeriodWhere(
|
export function validSubscriptionPeriodWhere(
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Prisma.SubscriptionWhereInput {
|
): Prisma.ProviderSubscriptionWhereInput {
|
||||||
return { OR: [{ end: null }, { end: { gt: now } }] };
|
return { OR: [{ periodEnd: null }, { periodEnd: { gt: now } }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activeSubscriptionWhere(
|
export function activeSubscriptionWhere(
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Prisma.SubscriptionWhereInput {
|
): Prisma.ProviderSubscriptionWhereInput {
|
||||||
return {
|
return {
|
||||||
status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing] },
|
status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing] },
|
||||||
...validSubscriptionPeriodWhere(now),
|
...validSubscriptionPeriodWhere(now),
|
||||||
@@ -34,7 +39,7 @@ export function activeSubscriptionWhere(
|
|||||||
|
|
||||||
export function visibleSubscriptionWhere(
|
export function visibleSubscriptionWhere(
|
||||||
now = new Date()
|
now = new Date()
|
||||||
): Prisma.SubscriptionWhereInput {
|
): Prisma.ProviderSubscriptionWhereInput {
|
||||||
return {
|
return {
|
||||||
status: {
|
status: {
|
||||||
in: [
|
in: [
|
||||||
@@ -98,10 +103,30 @@ export abstract class SubscriptionManager {
|
|||||||
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
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() {
|
get stripe() {
|
||||||
return this.stripeProvider.stripe;
|
return this.stripeProvider.stripe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected requireStripeSubscriptionId(id: string | null) {
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('Stripe subscription identity is missing.');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
abstract filterPrices(
|
abstract filterPrices(
|
||||||
prices: KnownStripePrice[],
|
prices: KnownStripePrice[],
|
||||||
customer?: UserStripeCustomer
|
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({
|
async transformInvoice({
|
||||||
stripeInvoice,
|
stripeInvoice,
|
||||||
}: KnownStripeInvoice): Promise<Invoice> {
|
}: KnownStripeInvoice): Promise<Invoice> {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
import {
|
||||||
import { omit } from 'lodash-es';
|
Prisma,
|
||||||
|
PrismaClient,
|
||||||
|
Provider,
|
||||||
|
UserStripeCustomer,
|
||||||
|
} from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
||||||
@@ -119,70 +123,44 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
|
|
||||||
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
||||||
const { stripeSubscription, userEmail } = subscription;
|
const { stripeSubscription, userEmail } = subscription;
|
||||||
|
|
||||||
const subscriptionData = this.transformSubscription(subscription);
|
const subscriptionData = this.transformSubscription(subscription);
|
||||||
|
const existingSubscription = await this.db.providerSubscription.findUnique({
|
||||||
const existingSubscription = await this.db.subscription.findFirst({
|
|
||||||
where: {
|
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) {
|
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({
|
await this.mailer.send({
|
||||||
name: 'TeamLicense',
|
name: 'TeamLicense',
|
||||||
to: userEmail,
|
to: userEmail,
|
||||||
props: { license: key },
|
props: { license: saved.targetId },
|
||||||
metadata: {
|
metadata: {
|
||||||
dedupeKey: `selfhost-license:${key}`,
|
dedupeKey: `selfhost-license:${saved.targetId}`,
|
||||||
source: { trusted: false },
|
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({
|
async deleteStripeSubscription({
|
||||||
@@ -199,80 +177,111 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
periodEnd: new Date(),
|
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>) {
|
async getSubscription(
|
||||||
return this.db.subscription.findFirst({
|
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
|
||||||
where: { targetId: identity.key },
|
) {
|
||||||
|
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(
|
getActiveSubscription(
|
||||||
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
|
identity: z.infer<typeof SelfhostTeamSubscriptionIdentity>
|
||||||
) {
|
) {
|
||||||
return this.db.subscription.findFirst({
|
return this.db.providerSubscription
|
||||||
where: {
|
.findFirst({
|
||||||
targetId: identity.key,
|
where: {
|
||||||
plan: identity.plan,
|
provider: Provider.stripe,
|
||||||
...activeSubscriptionWhere(),
|
targetType: 'instance',
|
||||||
},
|
targetId: identity.key,
|
||||||
});
|
plan: identity.plan,
|
||||||
|
...activeSubscriptionWhere(),
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
.then(subscription =>
|
||||||
|
subscription ? this.transformProviderSubscription(subscription) : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelSubscription(subscription: Subscription) {
|
async cancelSubscription(subscription: Subscription) {
|
||||||
return await this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
await this.db.providerSubscription.update({
|
||||||
|
where: { id: current.id },
|
||||||
data: {
|
data: {
|
||||||
canceledAt: new Date(),
|
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> {
|
async resumeSubscription(subscription: Subscription) {
|
||||||
return this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
},
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
data: {
|
subscription.stripeSubscriptionId
|
||||||
canceledAt: null,
|
),
|
||||||
nextBillAt: subscription.end,
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
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(
|
updateSubscriptionRecurring(
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
recurring: SubscriptionRecurring
|
recurring: SubscriptionRecurring
|
||||||
): Promise<Subscription> {
|
) {
|
||||||
return this.db.subscription.update({
|
return this.db.providerSubscription
|
||||||
where: {
|
.update({
|
||||||
// @ts-expect-error checked outside
|
where: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider_externalSubscriptionId: {
|
||||||
},
|
provider: Provider.stripe,
|
||||||
data: { recurring },
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
});
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { recurring },
|
||||||
|
})
|
||||||
|
.then(subscription => this.transformProviderSubscription(subscription));
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
|
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
|
||||||
@@ -284,12 +293,13 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
private async upsertStripeProviderSubscription(
|
private async upsertStripeProviderSubscription(
|
||||||
targetId: string,
|
targetId: string,
|
||||||
known: KnownStripeSubscription,
|
known: KnownStripeSubscription,
|
||||||
subscriptionData: Subscription
|
subscriptionData: Subscription,
|
||||||
|
db: Prisma.TransactionClient = this.db
|
||||||
) {
|
) {
|
||||||
const { lookupKey, stripeSubscription } = known;
|
const { lookupKey, stripeSubscription } = known;
|
||||||
const price = stripeSubscription.items.data[0]?.price;
|
const price = stripeSubscription.items.data[0]?.price;
|
||||||
|
|
||||||
await this.db.providerSubscription.upsert({
|
return db.providerSubscription.upsert({
|
||||||
where: {
|
where: {
|
||||||
provider_externalSubscriptionId: {
|
provider_externalSubscriptionId: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -297,8 +307,6 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
targetType: 'instance',
|
|
||||||
targetId,
|
|
||||||
plan: lookupKey.plan,
|
plan: lookupKey.plan,
|
||||||
recurring: lookupKey.recurring,
|
recurring: lookupKey.recurring,
|
||||||
status: stripeSubscription.status,
|
status: stripeSubscription.status,
|
||||||
@@ -319,7 +327,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
canceledAt: subscriptionData.canceledAt,
|
||||||
metadata: known.metadata,
|
metadata: {
|
||||||
|
...known.metadata,
|
||||||
|
variant: subscriptionData.variant,
|
||||||
|
stripeScheduleId: subscriptionData.stripeScheduleId,
|
||||||
|
nextBillAt: subscriptionData.nextBillAt?.toISOString() ?? null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -346,7 +359,12 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
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>) {
|
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||||
return this.db.subscription.findFirst({
|
const subscription = await this.db.providerSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: args.userId,
|
targetId: args.userId,
|
||||||
plan: args.plan,
|
plan: args.plan,
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return subscription
|
||||||
|
? this.transformProviderSubscription(subscription)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
async getActiveSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||||
return this.db.subscription.findFirst({
|
const subscription = await this.db.providerSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: args.userId,
|
targetId: args.userId,
|
||||||
plan: args.plan,
|
plan: args.plan,
|
||||||
...activeSubscriptionWhere(),
|
...activeSubscriptionWhere(),
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return subscription
|
||||||
|
? this.transformProviderSubscription(subscription)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
async getVisibleSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
|
||||||
return this.db.subscription.findFirst({
|
const subscription = await this.db.providerSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: args.userId,
|
targetId: args.userId,
|
||||||
plan: args.plan,
|
plan: args.plan,
|
||||||
...visibleSubscriptionWhere(),
|
...visibleSubscriptionWhere(),
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
return subscription
|
||||||
|
? this.transformProviderSubscription(subscription)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
async saveStripeSubscription(subscription: KnownStripeSubscription) {
|
||||||
@@ -249,7 +264,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subscriptionData = this.transformSubscription(subscription);
|
const subscriptionData = this.transformSubscription(subscription);
|
||||||
await this.upsertStripeProviderSubscription(subscription, subscriptionData);
|
const saved = await this.upsertStripeProviderSubscription(
|
||||||
|
subscription,
|
||||||
|
subscriptionData
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
lookupKey.plan === SubscriptionPlan.AI &&
|
lookupKey.plan === SubscriptionPlan.AI &&
|
||||||
@@ -265,41 +283,13 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingByStripeId = await this.db.subscription.findUnique({
|
const result = this.transformProviderSubscription(saved);
|
||||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
await this.entitlement.upsertFromCloudSubscription({
|
||||||
|
...result,
|
||||||
|
targetId: saved.targetId,
|
||||||
|
subscriptionId: saved.id,
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteStripeSubscription({
|
async deleteStripeSubscription({
|
||||||
@@ -308,7 +298,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
stripeSubscription,
|
stripeSubscription,
|
||||||
}: KnownStripeSubscription) {
|
}: KnownStripeSubscription) {
|
||||||
this.assertUserIdExists(userId);
|
this.assertUserIdExists(userId);
|
||||||
await this.db.providerSubscription.updateMany({
|
const result = await this.db.providerSubscription.updateMany({
|
||||||
where: {
|
where: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
externalSubscriptionId: stripeSubscription.id,
|
externalSubscriptionId: stripeSubscription.id,
|
||||||
@@ -319,11 +309,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
periodEnd: new Date(),
|
periodEnd: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await this.db.subscription.deleteMany({
|
|
||||||
where: {
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.count > 0) {
|
if (result.count > 0) {
|
||||||
await this.entitlement.revokeCloudSubscription({
|
await this.entitlement.revokeCloudSubscription({
|
||||||
@@ -340,42 +325,85 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async cancelSubscription(subscription: Subscription) {
|
async cancelSubscription(subscription: Subscription) {
|
||||||
return this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
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: {
|
data: {
|
||||||
canceledAt: new Date(),
|
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) {
|
async resumeSubscription(subscription: Subscription) {
|
||||||
return this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
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: {
|
data: {
|
||||||
canceledAt: null,
|
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(
|
async updateSubscriptionRecurring(
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
recurring: SubscriptionRecurring
|
recurring: SubscriptionRecurring
|
||||||
) {
|
) {
|
||||||
return this.db.subscription.update({
|
const saved = await this.db.providerSubscription.update({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: { recurring },
|
data: { recurring },
|
||||||
});
|
});
|
||||||
|
return this.transformProviderSubscription(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInvoice(knownInvoice: KnownStripeInvoice) {
|
async saveInvoice(knownInvoice: KnownStripeInvoice) {
|
||||||
@@ -418,60 +446,54 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) {
|
async saveLifetimeSubscription(knownInvoice: KnownStripeInvoice) {
|
||||||
this.assertUserIdExists(knownInvoice.userId);
|
this.assertUserIdExists(knownInvoice.userId);
|
||||||
|
|
||||||
// cancel previous non-lifetime subscription
|
const prevSubscription = await this.db.providerSubscription.findFirst({
|
||||||
const prevSubscription = await this.db.subscription.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
targetId_plan: {
|
provider: Provider.stripe,
|
||||||
targetId: knownInvoice.userId,
|
targetType: 'user',
|
||||||
plan: SubscriptionPlan.Pro,
|
targetId: knownInvoice.userId,
|
||||||
},
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: { not: SubscriptionRecurring.Lifetime },
|
||||||
|
...activeSubscriptionWhere(),
|
||||||
},
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prevSubscription) {
|
if (prevSubscription?.externalSubscriptionId) {
|
||||||
if (prevSubscription.stripeSubscriptionId) {
|
await this.stripe.subscriptions.cancel(
|
||||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
prevSubscription.externalSubscriptionId,
|
||||||
const subscription = await this.db.subscription.update({
|
{ prorate: true },
|
||||||
where: {
|
{
|
||||||
id: prevSubscription.id,
|
idempotencyKey: `lifetime:${knownInvoice.stripeInvoice.id}:cancel:${prevSubscription.id}`,
|
||||||
},
|
}
|
||||||
data: {
|
);
|
||||||
stripeScheduleId: null,
|
const now = new Date();
|
||||||
stripeSubscriptionId: null,
|
await this.db.providerSubscription.update({
|
||||||
plan: knownInvoice.lookupKey.plan,
|
where: { id: prevSubscription.id },
|
||||||
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({
|
|
||||||
data: {
|
data: {
|
||||||
targetId: knownInvoice.userId,
|
status: SubscriptionStatus.Canceled,
|
||||||
stripeSubscriptionId: null,
|
canceledAt: now,
|
||||||
plan: knownInvoice.lookupKey.plan,
|
periodEnd: now,
|
||||||
recurring: SubscriptionRecurring.Lifetime,
|
|
||||||
start: new Date(),
|
|
||||||
end: null,
|
|
||||||
status: SubscriptionStatus.Active,
|
|
||||||
nextBillAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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', {
|
this.event.emit('user.subscription.activated', {
|
||||||
userId: knownInvoice.userId,
|
userId: knownInvoice.userId,
|
||||||
plan: knownInvoice.lookupKey.plan,
|
plan: knownInvoice.lookupKey.plan,
|
||||||
@@ -483,11 +505,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
this.assertUserIdExists(knownInvoice.userId);
|
this.assertUserIdExists(knownInvoice.userId);
|
||||||
const { userId, lookupKey } = knownInvoice;
|
const { userId, lookupKey } = knownInvoice;
|
||||||
|
|
||||||
const subscription = await this.db.subscription.findFirst({
|
const subscription = await this.db.providerSubscription.findUnique({
|
||||||
where: {
|
where: {
|
||||||
targetId: userId,
|
provider_externalSubscriptionId: {
|
||||||
plan: lookupKey.plan,
|
provider: Provider.stripe,
|
||||||
provider: Provider.stripe,
|
externalSubscriptionId: this.lifetimeExternalId(knownInvoice),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,22 +518,24 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
await this.db.providerSubscription.update({
|
||||||
await this.db.subscription.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: SubscriptionStatus.Canceled,
|
status: SubscriptionStatus.Canceled,
|
||||||
nextBillAt: null,
|
|
||||||
canceledAt: new Date(),
|
canceledAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const saved = await this.patchProviderSubscriptionMetadata(
|
||||||
|
subscription.id,
|
||||||
|
{ nextBillAt: null }
|
||||||
|
);
|
||||||
await this.entitlement.revokeCloudSubscription({
|
await this.entitlement.revokeCloudSubscription({
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
plan: lookupKey.plan,
|
plan: lookupKey.plan,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: saved.id,
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
stripeSubscriptionId: saved.externalSubscriptionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.event.emit('user.subscription.canceled', {
|
this.event.emit('user.subscription.canceled', {
|
||||||
@@ -524,48 +549,22 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
this.assertUserIdExists(knownInvoice.userId);
|
this.assertUserIdExists(knownInvoice.userId);
|
||||||
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
||||||
|
|
||||||
const subscription = await this.db.subscription.findFirst({
|
|
||||||
where: {
|
|
||||||
targetId: userId,
|
|
||||||
plan: lookupKey.plan,
|
|
||||||
provider: Provider.stripe,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const start =
|
const start =
|
||||||
stripeInvoice.lines.data[0]?.period?.start ??
|
stripeInvoice.lines.data[0]?.period?.start ??
|
||||||
(typeof stripeInvoice.created === 'number'
|
(typeof stripeInvoice.created === 'number'
|
||||||
? stripeInvoice.created
|
? stripeInvoice.created
|
||||||
: Date.now() / 1000);
|
: Date.now() / 1000);
|
||||||
|
|
||||||
if (subscription) {
|
const saved = await this.upsertLifetimeProviderSubscription(
|
||||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
knownInvoice,
|
||||||
const saved = await this.db.subscription.update({
|
new Date(start * 1000)
|
||||||
where: { id: subscription.id },
|
);
|
||||||
data: {
|
await this.entitlement.upsertFromCloudSubscription({
|
||||||
status: SubscriptionStatus.Active,
|
...this.transformProviderSubscription(saved),
|
||||||
canceledAt: null,
|
targetId: saved.targetId,
|
||||||
nextBillAt: null,
|
subscriptionId: saved.id,
|
||||||
start: subscription.start ?? new Date(start * 1000),
|
stripeSubscriptionId: saved.externalSubscriptionId,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.event.emit('user.subscription.activated', {
|
this.event.emit('user.subscription.activated', {
|
||||||
userId,
|
userId,
|
||||||
@@ -696,8 +695,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
const { userId, lookupKey, stripeSubscription } = known;
|
const { userId, lookupKey, stripeSubscription } = known;
|
||||||
this.assertUserIdExists(userId);
|
this.assertUserIdExists(userId);
|
||||||
const price = stripeSubscription.items.data[0]?.price;
|
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: {
|
where: {
|
||||||
provider_externalSubscriptionId: {
|
provider_externalSubscriptionId: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -727,7 +732,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
canceledAt: subscriptionData.canceledAt,
|
||||||
metadata: known.metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -754,7 +759,61 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
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')
|
@OnEvent('user.deleted')
|
||||||
async onUserDeleted({ id }: Events['user.deleted']) {
|
async onUserDeleted({ id }: Events['user.deleted']) {
|
||||||
const subscription = await this.db.subscription.findFirst({
|
const subscriptions = await this.db.providerSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
provider: Provider.stripe,
|
||||||
|
targetType: 'user',
|
||||||
targetId: id,
|
targetId: id,
|
||||||
|
recurring: { not: SubscriptionRecurring.Lifetime },
|
||||||
|
externalSubscriptionId: { not: null },
|
||||||
|
...activeSubscriptionWhere(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subscription?.stripeSubscriptionId) {
|
await Promise.all(
|
||||||
await this.stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
|
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);
|
const subscriptionData = this.transformSubscription(subscription);
|
||||||
await this.upsertStripeProviderSubscription(
|
const saved = await this.upsertStripeProviderSubscription(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
subscription,
|
subscription,
|
||||||
subscriptionData
|
subscriptionData
|
||||||
@@ -163,46 +163,13 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingByStripeId = await this.db.subscription.findUnique({
|
const result = this.transformProviderSubscription(saved);
|
||||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
await this.entitlement.upsertFromCloudSubscription({
|
||||||
|
...result,
|
||||||
|
targetId: saved.targetId,
|
||||||
|
subscriptionId: saved.id,
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteStripeSubscription({
|
async deleteStripeSubscription({
|
||||||
@@ -217,7 +184,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.providerSubscription.updateMany({
|
const result = await this.db.providerSubscription.updateMany({
|
||||||
where: {
|
where: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
externalSubscriptionId: stripeSubscription.id,
|
externalSubscriptionId: stripeSubscription.id,
|
||||||
@@ -228,10 +195,6 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
periodEnd: new Date(),
|
periodEnd: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await this.db.subscription.deleteMany({
|
|
||||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.count > 0) {
|
if (result.count > 0) {
|
||||||
await this.entitlement.revokeCloudSubscription({
|
await this.entitlement.revokeCloudSubscription({
|
||||||
targetId: workspaceId,
|
targetId: workspaceId,
|
||||||
@@ -247,61 +210,104 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSubscription(identity: z.infer<typeof WorkspaceSubscriptionIdentity>) {
|
getSubscription(identity: z.infer<typeof WorkspaceSubscriptionIdentity>) {
|
||||||
return this.db.subscription.findFirst({
|
return this.db.providerSubscription
|
||||||
where: {
|
.findFirst({
|
||||||
targetId: identity.workspaceId,
|
where: {
|
||||||
},
|
targetType: 'workspace',
|
||||||
});
|
targetId: identity.workspaceId,
|
||||||
|
plan: identity.plan,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
.then(subscription =>
|
||||||
|
subscription ? this.transformProviderSubscription(subscription) : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveSubscription(
|
getActiveSubscription(
|
||||||
identity: z.infer<typeof WorkspaceSubscriptionIdentity>
|
identity: z.infer<typeof WorkspaceSubscriptionIdentity>
|
||||||
) {
|
) {
|
||||||
return this.db.subscription.findFirst({
|
return this.db.providerSubscription
|
||||||
where: {
|
.findFirst({
|
||||||
targetId: identity.workspaceId,
|
where: {
|
||||||
...activeSubscriptionWhere(),
|
targetType: 'workspace',
|
||||||
},
|
targetId: identity.workspaceId,
|
||||||
});
|
plan: identity.plan,
|
||||||
|
...activeSubscriptionWhere(),
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
.then(subscription =>
|
||||||
|
subscription ? this.transformProviderSubscription(subscription) : null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelSubscription(subscription: Subscription) {
|
async cancelSubscription(subscription: Subscription) {
|
||||||
return await this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
await this.db.providerSubscription.update({
|
||||||
|
where: { id: current.id },
|
||||||
data: {
|
data: {
|
||||||
canceledAt: new Date(),
|
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> {
|
async resumeSubscription(subscription: Subscription) {
|
||||||
return this.db.subscription.update({
|
const current = await this.db.providerSubscription.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
await this.db.providerSubscription.update({
|
||||||
|
where: { id: current.id },
|
||||||
data: {
|
data: {
|
||||||
canceledAt: null,
|
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,
|
subscription: Subscription,
|
||||||
recurring: SubscriptionRecurring
|
recurring: SubscriptionRecurring
|
||||||
): Promise<Subscription> {
|
) {
|
||||||
return this.db.subscription.update({
|
const saved = await this.db.providerSubscription.update({
|
||||||
where: {
|
where: {
|
||||||
// @ts-expect-error checked outside
|
provider_externalSubscriptionId: {
|
||||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
provider: Provider.stripe,
|
||||||
|
externalSubscriptionId: this.requireStripeSubscriptionId(
|
||||||
|
subscription.stripeSubscriptionId
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: { recurring },
|
data: { recurring },
|
||||||
});
|
});
|
||||||
|
return this.transformProviderSubscription(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
|
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
|
||||||
@@ -379,8 +385,14 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
) {
|
) {
|
||||||
const { lookupKey, stripeSubscription } = known;
|
const { lookupKey, stripeSubscription } = known;
|
||||||
const price = stripeSubscription.items.data[0]?.price;
|
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: {
|
where: {
|
||||||
provider_externalSubscriptionId: {
|
provider_externalSubscriptionId: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -410,7 +422,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
canceledAt: subscriptionData.canceledAt,
|
||||||
metadata: known.metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
provider: Provider.stripe,
|
provider: Provider.stripe,
|
||||||
@@ -437,7 +449,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
trialStart: subscriptionData.trialStart,
|
trialStart: subscriptionData.trialStart,
|
||||||
trialEnd: subscriptionData.trialEnd,
|
trialEnd: subscriptionData.trialEnd,
|
||||||
canceledAt: subscriptionData.canceledAt,
|
canceledAt: subscriptionData.canceledAt,
|
||||||
metadata: known.metadata,
|
metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,13 +511,18 @@ export class UserSubscriptionResolver {
|
|||||||
variant?: string | null;
|
variant?: string | null;
|
||||||
stripeSubscriptionId?: string | null;
|
stripeSubscriptionId?: string | null;
|
||||||
};
|
};
|
||||||
|
const providerMetadata = providerFact?.metadata as {
|
||||||
|
variant?: string | null;
|
||||||
|
stripeScheduleId?: string | null;
|
||||||
|
nextBillAt?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
return this.normalizeSubscription({
|
return this.normalizeSubscription({
|
||||||
stripeSubscriptionId:
|
stripeSubscriptionId:
|
||||||
providerFact?.externalSubscriptionId ??
|
providerFact?.externalSubscriptionId ??
|
||||||
metadata.stripeSubscriptionId ??
|
metadata.stripeSubscriptionId ??
|
||||||
null,
|
null,
|
||||||
stripeScheduleId: null,
|
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
|
||||||
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
|
status: providerFact?.status ?? this.subscriptionStatus(entitlement),
|
||||||
plan,
|
plan,
|
||||||
recurring:
|
recurring:
|
||||||
@@ -527,16 +532,24 @@ export class UserSubscriptionResolver {
|
|||||||
? SubscriptionRecurring.Lifetime
|
? SubscriptionRecurring.Lifetime
|
||||||
: SubscriptionRecurring.Monthly),
|
: SubscriptionRecurring.Monthly),
|
||||||
variant:
|
variant:
|
||||||
|
providerMetadata?.variant ??
|
||||||
metadata.variant ??
|
metadata.variant ??
|
||||||
(entitlement.plan === 'lifetime_pro'
|
(entitlement.plan === 'lifetime_pro'
|
||||||
? SubscriptionVariant.Onetime
|
? SubscriptionVariant.Onetime
|
||||||
: null),
|
: null),
|
||||||
quantity: entitlement.quantity ?? 1,
|
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
|
||||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
start:
|
||||||
end: entitlement.expiresAt,
|
providerFact?.periodStart ??
|
||||||
|
entitlement.startsAt ??
|
||||||
|
entitlement.createdAt,
|
||||||
|
end: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||||
trialStart: providerFact?.trialStart ?? null,
|
trialStart: providerFact?.trialStart ?? null,
|
||||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
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,
|
canceledAt: providerFact?.canceledAt ?? null,
|
||||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||||
iapStore: providerFact?.iapStore ?? null,
|
iapStore: providerFact?.iapStore ?? null,
|
||||||
@@ -613,8 +626,11 @@ export class UserSubscriptionResolver {
|
|||||||
throw new AuthenticationRequired();
|
throw new AuthenticationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
let existsSubscription = await this.db.subscription.findFirst({
|
const existsSubscription = await this.db.providerSubscription.findFirst({
|
||||||
where: { rcExternalRef: transactionId },
|
where: {
|
||||||
|
provider: Provider.revenuecat,
|
||||||
|
externalRef: transactionId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// subscription with the transactionId already exists
|
// subscription with the transactionId already exists
|
||||||
@@ -622,8 +638,7 @@ export class UserSubscriptionResolver {
|
|||||||
if (existsSubscription.targetId !== user.id) {
|
if (existsSubscription.targetId !== user.id) {
|
||||||
throw new InvalidSubscriptionParameters();
|
throw new InvalidSubscriptionParameters();
|
||||||
} else {
|
} else {
|
||||||
this.normalizeSubscription(existsSubscription);
|
return this.currentUserSubscriptions(user.id);
|
||||||
return [existsSubscription];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,8 +664,9 @@ export class UserSubscriptionResolver {
|
|||||||
throw new AuthenticationRequired();
|
throw new AuthenticationRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = await this.db.subscription.findMany({
|
const current = await this.db.providerSubscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
...visibleSubscriptionWhere(),
|
...visibleSubscriptionWhere(),
|
||||||
},
|
},
|
||||||
@@ -727,13 +743,18 @@ export class WorkspaceSubscriptionResolver {
|
|||||||
variant?: string | null;
|
variant?: string | null;
|
||||||
stripeSubscriptionId?: string | null;
|
stripeSubscriptionId?: string | null;
|
||||||
};
|
};
|
||||||
|
const providerMetadata = providerFact?.metadata as {
|
||||||
|
variant?: string | null;
|
||||||
|
stripeScheduleId?: string | null;
|
||||||
|
nextBillAt?: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stripeSubscriptionId:
|
stripeSubscriptionId:
|
||||||
providerFact?.externalSubscriptionId ??
|
providerFact?.externalSubscriptionId ??
|
||||||
metadata.stripeSubscriptionId ??
|
metadata.stripeSubscriptionId ??
|
||||||
null,
|
null,
|
||||||
stripeScheduleId: null,
|
stripeScheduleId: providerMetadata?.stripeScheduleId ?? null,
|
||||||
status:
|
status:
|
||||||
providerFact?.status ??
|
providerFact?.status ??
|
||||||
(entitlement.status === 'grace'
|
(entitlement.status === 'grace'
|
||||||
@@ -744,13 +765,20 @@ export class WorkspaceSubscriptionResolver {
|
|||||||
providerFact?.recurring ??
|
providerFact?.recurring ??
|
||||||
metadata.recurring ??
|
metadata.recurring ??
|
||||||
SubscriptionRecurring.Monthly,
|
SubscriptionRecurring.Monthly,
|
||||||
variant: metadata.variant ?? null,
|
variant: providerMetadata?.variant ?? metadata.variant ?? null,
|
||||||
quantity: entitlement.quantity ?? 1,
|
quantity: providerFact?.quantity ?? entitlement.quantity ?? 1,
|
||||||
start: entitlement.startsAt ?? entitlement.createdAt,
|
start:
|
||||||
end: entitlement.expiresAt,
|
providerFact?.periodStart ??
|
||||||
|
entitlement.startsAt ??
|
||||||
|
entitlement.createdAt,
|
||||||
|
end: providerFact?.periodEnd ?? entitlement.expiresAt,
|
||||||
trialStart: providerFact?.trialStart ?? null,
|
trialStart: providerFact?.trialStart ?? null,
|
||||||
trialEnd: providerFact?.trialEnd ?? entitlement.graceUntil,
|
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,
|
canceledAt: providerFact?.canceledAt ?? null,
|
||||||
provider: providerFact?.provider ?? metadata.provider ?? null,
|
provider: providerFact?.provider ?? metadata.provider ?? null,
|
||||||
iapStore: providerFact?.iapStore ?? null,
|
iapStore: providerFact?.iapStore ?? null,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ const zRcV2RawCustomerAliasEnvelope = z
|
|||||||
|
|
||||||
// v2 minimal, simplified structure exposed to callers
|
// v2 minimal, simplified structure exposed to callers
|
||||||
export const Subscription = z.object({
|
export const Subscription = z.object({
|
||||||
|
externalRef: z.string().optional(),
|
||||||
identifier: z.string(),
|
identifier: z.string(),
|
||||||
isTrial: z.boolean(),
|
isTrial: z.boolean(),
|
||||||
isActive: z.boolean(),
|
isActive: z.boolean(),
|
||||||
@@ -351,6 +352,7 @@ export class RevenueCatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalRef: sub.id,
|
||||||
identifier: product.display_name,
|
identifier: product.display_name,
|
||||||
isTrial: sub.status === 'trialing',
|
isTrial: sub.status === 'trialing',
|
||||||
isActive:
|
isActive:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,12 +19,21 @@ import {
|
|||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { RcEvent } from './controller';
|
import { RcEvent } from './controller';
|
||||||
import { ProductMapping, resolveProductMapping } from './map';
|
import { resolveProductMapping } from './map';
|
||||||
import { RevenueCatService, Subscription } from './service';
|
import { RevenueCatService, Subscription } from './service';
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 1000; // 5 seconds
|
const REFRESH_INTERVAL = 5 * 1000; // 5 seconds
|
||||||
const REFRESH_MAX_TIMES = 10 * OneMinute;
|
const REFRESH_MAX_TIMES = 10 * OneMinute;
|
||||||
|
|
||||||
|
function isLegacyIdentityPlaceholder(metadata: Prisma.JsonValue) {
|
||||||
|
return (
|
||||||
|
typeof metadata === 'object' &&
|
||||||
|
metadata !== null &&
|
||||||
|
!Array.isArray(metadata) &&
|
||||||
|
metadata.legacyRevenueCatIdentityIncomplete === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RevenueCatWebhookHandler {
|
export class RevenueCatWebhookHandler {
|
||||||
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
|
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
|
||||||
@@ -114,20 +124,18 @@ export class RevenueCatWebhookHandler {
|
|||||||
externalRef?: string,
|
externalRef?: string,
|
||||||
overrideExpirationDate?: Date
|
overrideExpirationDate?: Date
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const cond = { targetId: appUserId, provider: Provider.revenuecat };
|
const toBeCleanup = await this.db.providerSubscription.findMany({
|
||||||
const toBeCleanup = await this.db.subscription.findMany({
|
where: {
|
||||||
where: cond,
|
provider: Provider.revenuecat,
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: appUserId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const productOverride = this.config.payment.revenuecat?.productMap;
|
const productOverride = this.config.payment.revenuecat?.productMap;
|
||||||
const removeExists = (mapping: ProductMapping, sub: Subscription) => {
|
const removeExists = (id: string) => {
|
||||||
// Remove from cleanup list
|
const index = toBeCleanup.findIndex(
|
||||||
const index = toBeCleanup.findIndex(s => {
|
subscription => subscription.id === id
|
||||||
return (
|
);
|
||||||
s.targetId === appUserId &&
|
|
||||||
s.rcProductId === sub.productId &&
|
|
||||||
s.plan === mapping.plan
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
toBeCleanup.splice(index, 1);
|
toBeCleanup.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -159,65 +167,77 @@ export class RevenueCatWebhookHandler {
|
|||||||
overrideExpirationDate
|
overrideExpirationDate
|
||||||
);
|
);
|
||||||
|
|
||||||
const rcExternalRef = externalRef || this.pickExternalRef(event);
|
const rcExternalRef =
|
||||||
// Upsert by unique (targetId, plan) for idempotency
|
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 start = sub.latestPurchaseDate || new Date();
|
||||||
const end = overrideExpirationDate || sub.expirationDate || null;
|
const end = overrideExpirationDate || sub.expirationDate || null;
|
||||||
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
const matched = await this.db.providerSubscription.findFirst({
|
||||||
|
where: {
|
||||||
if (rcExternalRef && iapStore) {
|
provider: Provider.revenuecat,
|
||||||
await this.db.providerSubscription.upsert({
|
iapStore,
|
||||||
where: {
|
externalRef: rcExternalRef,
|
||||||
provider_iapStore_externalRef_externalProductId_externalCustomerId:
|
externalProductId: sub.productId,
|
||||||
{
|
externalCustomerId: sub.customerId,
|
||||||
provider: Provider.revenuecat,
|
},
|
||||||
iapStore,
|
});
|
||||||
externalRef: rcExternalRef,
|
const existing =
|
||||||
externalProductId: sub.productId,
|
matched ??
|
||||||
externalCustomerId: sub.customerId,
|
toBeCleanup.find(
|
||||||
},
|
subscription =>
|
||||||
},
|
subscription.plan === mapping.plan &&
|
||||||
update: {
|
isLegacyIdentityPlaceholder(subscription.metadata)
|
||||||
targetType: 'user',
|
);
|
||||||
targetId: appUserId,
|
const data = {
|
||||||
plan: mapping.plan,
|
targetType: 'user',
|
||||||
recurring: mapping.recurring,
|
targetId: appUserId,
|
||||||
status,
|
plan: mapping.plan,
|
||||||
externalCustomerId: sub.customerId,
|
recurring: mapping.recurring,
|
||||||
externalProductId: sub.productId,
|
status,
|
||||||
iapStore,
|
quantity: 1,
|
||||||
externalRef: rcExternalRef,
|
externalCustomerId: sub.customerId,
|
||||||
periodStart: start,
|
externalProductId: sub.productId,
|
||||||
periodEnd: end,
|
iapStore,
|
||||||
canceledAt,
|
externalRef: rcExternalRef,
|
||||||
metadata: {
|
periodStart: start,
|
||||||
entitlement: sub.identifier,
|
periodEnd: end,
|
||||||
isTrial: sub.isTrial,
|
trialStart: sub.isTrial ? start : null,
|
||||||
willRenew: sub.willRenew,
|
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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
update: data,
|
||||||
create: {
|
create: {
|
||||||
provider: Provider.revenuecat,
|
provider: Provider.revenuecat,
|
||||||
targetType: 'user',
|
...data,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
removeExists(saved.id);
|
||||||
}
|
|
||||||
|
|
||||||
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
|
if (mapping.plan === SubscriptionPlan.AI && sub.isTrial) {
|
||||||
await this.db.subscriptionTrialUsage.upsert({
|
await this.db.subscriptionTrialUsage.upsert({
|
||||||
@@ -245,8 +265,10 @@ export class RevenueCatWebhookHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mutual exclusion: skip if Stripe already active for the same plan
|
// 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: {
|
where: {
|
||||||
|
id: { not: saved.id },
|
||||||
|
targetType: 'user',
|
||||||
targetId: appUserId,
|
targetId: appUserId,
|
||||||
plan: mapping.plan,
|
plan: mapping.plan,
|
||||||
status: {
|
status: {
|
||||||
@@ -254,13 +276,21 @@ export class RevenueCatWebhookHandler {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const conflict = conflicts.find(
|
||||||
|
subscription => !isLegacyIdentityPlaceholder(subscription.metadata)
|
||||||
|
);
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
|
await this.entitlement.revokeCloudSubscription({
|
||||||
|
targetId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
subscriptionId: saved.id,
|
||||||
|
});
|
||||||
if (conflict.provider === Provider.stripe) {
|
if (conflict.provider === Provider.stripe) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
} else if (conflict.end && end && conflict.end > end) {
|
} else if (conflict.periodEnd && end && conflict.periodEnd > end) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
|
`Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}`
|
||||||
);
|
);
|
||||||
@@ -269,89 +299,66 @@ export class RevenueCatWebhookHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (deleteInstead) {
|
if (deleteInstead) {
|
||||||
// delete record and emit cancellation if any record removed
|
await this.entitlement.revokeCloudSubscription({
|
||||||
const result = await this.db.subscription.deleteMany({
|
targetId: appUserId,
|
||||||
where: {
|
plan: mapping.plan,
|
||||||
targetId: appUserId,
|
subscriptionId: saved.id,
|
||||||
plan: mapping.plan,
|
|
||||||
provider: Provider.revenuecat,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (result.count > 0) {
|
if (existing && existing.status !== SubscriptionStatus.Canceled) {
|
||||||
await this.entitlement.revokeCloudSubscription({
|
|
||||||
targetId: appUserId,
|
|
||||||
plan: mapping.plan,
|
|
||||||
});
|
|
||||||
this.event.emit('user.subscription.canceled', {
|
this.event.emit('user.subscription.canceled', {
|
||||||
userId: appUserId,
|
userId: appUserId,
|
||||||
plan: mapping.plan,
|
plan: mapping.plan,
|
||||||
recurring: mapping.recurring,
|
recurring: mapping.recurring,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
removeExists(mapping, sub);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await this.db.subscription.upsert({
|
await this.entitlement.upsertFromCloudSubscription({
|
||||||
// TODO(stable-upgrade): remove legacy subscriptions dual-write after stable supports provider facts.
|
targetId: saved.targetId,
|
||||||
// TODO(stable-upgrade): remove reliance on target_id_plan unique slot after contract cleanup.
|
plan: saved.plan,
|
||||||
where: {
|
recurring: saved.recurring ?? mapping.recurring,
|
||||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
status: saved.status,
|
||||||
},
|
quantity: saved.quantity,
|
||||||
update: {
|
provider: saved.provider,
|
||||||
recurring: mapping.recurring,
|
subscriptionId: saved.id,
|
||||||
variant: null,
|
start: saved.periodStart,
|
||||||
quantity: 1,
|
end: saved.periodEnd,
|
||||||
stripeSubscriptionId: null,
|
trialStart: saved.trialStart,
|
||||||
stripeScheduleId: null,
|
trialEnd: saved.trialEnd,
|
||||||
provider: Provider.revenuecat,
|
canceledAt: saved.canceledAt,
|
||||||
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(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 (
|
if (
|
||||||
status === SubscriptionStatus.Active ||
|
status === SubscriptionStatus.Active ||
|
||||||
status === SubscriptionStatus.Trialing
|
status === SubscriptionStatus.Trialing
|
||||||
) {
|
) {
|
||||||
this.event.emit('user.subscription.activated', {
|
if (
|
||||||
userId: appUserId,
|
existing?.status !== SubscriptionStatus.Active &&
|
||||||
plan: mapping.plan,
|
existing?.status !== SubscriptionStatus.Trialing
|
||||||
recurring: mapping.recurring,
|
) {
|
||||||
});
|
this.event.emit('user.subscription.activated', {
|
||||||
|
userId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
});
|
||||||
|
}
|
||||||
success += 1;
|
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)
|
// Do not emit canceled for PastDue (still within retry/grace window)
|
||||||
this.event.emit('user.subscription.canceled', {
|
this.event.emit('user.subscription.canceled', {
|
||||||
userId: appUserId,
|
userId: appUserId,
|
||||||
@@ -359,24 +366,23 @@ export class RevenueCatWebhookHandler {
|
|||||||
recurring: mapping.recurring,
|
recurring: mapping.recurring,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
removeExists(mapping, sub);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toBeCleanup.length) {
|
if (toBeCleanup.length) {
|
||||||
for (const sub of toBeCleanup) {
|
for (const sub of toBeCleanup) {
|
||||||
await this.db.subscription.deleteMany({ where: { id: sub.id } });
|
|
||||||
await this.entitlement.revokeCloudSubscription({
|
await this.entitlement.revokeCloudSubscription({
|
||||||
targetId: appUserId,
|
targetId: appUserId,
|
||||||
plan: sub.plan as SubscriptionPlan,
|
plan: sub.plan as SubscriptionPlan,
|
||||||
subscriptionId: sub.id,
|
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(
|
this.logger.log(
|
||||||
`Cleanup ${toBeCleanup.length} subscriptions for ${appUserId}`,
|
`Cleanup ${toBeCleanup.length} subscriptions for ${appUserId}`,
|
||||||
@@ -385,7 +391,7 @@ export class RevenueCatWebhookHandler {
|
|||||||
subscriptions: toBeCleanup.map(s => ({
|
subscriptions: toBeCleanup.map(s => ({
|
||||||
plan: s.plan,
|
plan: s.plan,
|
||||||
recurring: s.recurring,
|
recurring: s.recurring,
|
||||||
end: s.end,
|
end: s.periodEnd,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -401,16 +401,30 @@ export class SubscriptionService {
|
|||||||
throw new InvalidLicenseSessionId();
|
throw new InvalidLicenseSessionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
let subInDB = await this.db.subscription.findUnique({
|
let subInDB = await this.db.providerSubscription.findUnique({
|
||||||
where: {
|
where: {
|
||||||
stripeSubscriptionId: subscription.id,
|
provider_externalSubscriptionId: {
|
||||||
|
provider: 'stripe',
|
||||||
|
externalSubscriptionId: subscription.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// subscription not found in db
|
// subscription not found in db
|
||||||
if (!subInDB) {
|
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({
|
const license = await this.db.license.findUnique({
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ type AdminWorkspace {
|
|||||||
enableDocEmbedding: Boolean!
|
enableDocEmbedding: Boolean!
|
||||||
enableSharing: Boolean!
|
enableSharing: Boolean!
|
||||||
enableUrlPreview: Boolean!
|
enableUrlPreview: Boolean!
|
||||||
features: [FeatureType!]!
|
|
||||||
id: String!
|
id: String!
|
||||||
memberCount: Int!
|
memberCount: Int!
|
||||||
|
|
||||||
@@ -1176,13 +1175,6 @@ type ExpectToUpdateDocUserRoleDataType {
|
|||||||
|
|
||||||
enum FeatureType {
|
enum FeatureType {
|
||||||
Admin
|
Admin
|
||||||
FreePlan
|
|
||||||
LifetimeProPlan
|
|
||||||
ProPlan
|
|
||||||
QuotaExceededReadonlyWorkspace
|
|
||||||
TeamPlan
|
|
||||||
UnlimitedCopilot
|
|
||||||
UnlimitedWorkspace
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input ForkChatSessionInput {
|
input ForkChatSessionInput {
|
||||||
@@ -1505,7 +1497,6 @@ input ListWorkspaceInput {
|
|||||||
enableDocEmbedding: Boolean
|
enableDocEmbedding: Boolean
|
||||||
enableSharing: Boolean
|
enableSharing: Boolean
|
||||||
enableUrlPreview: Boolean
|
enableUrlPreview: Boolean
|
||||||
features: [FeatureType!]
|
|
||||||
first: Int! = 20
|
first: Int! = 20
|
||||||
keyword: String
|
keyword: String
|
||||||
orderBy: AdminWorkspaceSort
|
orderBy: AdminWorkspaceSort
|
||||||
@@ -2321,9 +2312,6 @@ type ServerConfigType {
|
|||||||
"""Features for user that can be configured"""
|
"""Features for user that can be configured"""
|
||||||
availableUserFeatures: [FeatureType!]!
|
availableUserFeatures: [FeatureType!]!
|
||||||
|
|
||||||
"""Workspace features available for admin configuration"""
|
|
||||||
availableWorkspaceFeatures: [FeatureType!]!
|
|
||||||
|
|
||||||
"""server base url"""
|
"""server base url"""
|
||||||
baseUrl: String!
|
baseUrl: String!
|
||||||
calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]!
|
calendarCalDAVProviders: [CalendarCalDAVProviderPresetObjectType!]!
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ examples:
|
|||||||
|
|
||||||
$ seed User Create an User
|
$ seed User Create an User
|
||||||
$ seed User 3 Create 3 Users
|
$ seed User 3 Create 3 Users
|
||||||
$ seed User feature=pro_plan_v1 Create an User with Pro feature
|
$ seed User feature=administrator Create an administrator
|
||||||
$ seed TeamWorkspace id=xxx Seed a workspace with Team feature
|
$ seed TeamWorkspace id=xxx Seed a workspace with Team entitlement
|
||||||
$ seed Workspace id=xxx public=true Seed with boolean property
|
$ seed Workspace id=xxx public=true Seed with boolean property
|
||||||
$ seed TeamWorkspace id=xxx quantity=10n Seed with numberic property, use \`={num}n\` suffix
|
$ seed TeamWorkspace id=xxx quantity=10n Seed with numberic property, use \`={num}n\` suffix
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ query adminServerConfig {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
availableUserFeatures
|
availableUserFeatures
|
||||||
availableWorkspaceFeatures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ mutation adminUpdateWorkspace($input: AdminUpdateWorkspaceInput!) {
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ query adminWorkspace(
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ query adminWorkspaces($filter: ListWorkspaceInput!) {
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -253,7 +253,6 @@ export const adminServerConfigQuery = {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
availableUserFeatures
|
availableUserFeatures
|
||||||
availableWorkspaceFeatures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${passwordLimitsFragment}
|
${passwordLimitsFragment}
|
||||||
@@ -274,7 +273,6 @@ export const adminUpdateWorkspaceMutation = {
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -305,7 +303,6 @@ export const adminWorkspaceQuery = {
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -349,7 +346,6 @@ export const adminWorkspacesQuery = {
|
|||||||
enableSharing
|
enableSharing
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
enableDocEmbedding
|
enableDocEmbedding
|
||||||
features
|
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ export interface AdminWorkspace {
|
|||||||
enableDocEmbedding: Scalars['Boolean']['output'];
|
enableDocEmbedding: Scalars['Boolean']['output'];
|
||||||
enableSharing: Scalars['Boolean']['output'];
|
enableSharing: Scalars['Boolean']['output'];
|
||||||
enableUrlPreview: Scalars['Boolean']['output'];
|
enableUrlPreview: Scalars['Boolean']['output'];
|
||||||
features: Array<FeatureType>;
|
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
memberCount: Scalars['Int']['output'];
|
memberCount: Scalars['Int']['output'];
|
||||||
/** Members of workspace */
|
/** Members of workspace */
|
||||||
@@ -1406,13 +1405,6 @@ export interface ExpectToUpdateDocUserRoleDataType {
|
|||||||
|
|
||||||
export enum FeatureType {
|
export enum FeatureType {
|
||||||
Admin = 'Admin',
|
Admin = 'Admin',
|
||||||
FreePlan = 'FreePlan',
|
|
||||||
LifetimeProPlan = 'LifetimeProPlan',
|
|
||||||
ProPlan = 'ProPlan',
|
|
||||||
QuotaExceededReadonlyWorkspace = 'QuotaExceededReadonlyWorkspace',
|
|
||||||
TeamPlan = 'TeamPlan',
|
|
||||||
UnlimitedCopilot = 'UnlimitedCopilot',
|
|
||||||
UnlimitedWorkspace = 'UnlimitedWorkspace',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForkChatSessionInput {
|
export interface ForkChatSessionInput {
|
||||||
@@ -1725,7 +1717,6 @@ export interface ListWorkspaceInput {
|
|||||||
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
|
enableDocEmbedding?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
|
enableSharing?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
|
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
features?: InputMaybe<Array<FeatureType>>;
|
|
||||||
first?: Scalars['Int']['input'];
|
first?: Scalars['Int']['input'];
|
||||||
keyword?: InputMaybe<Scalars['String']['input']>;
|
keyword?: InputMaybe<Scalars['String']['input']>;
|
||||||
orderBy?: InputMaybe<AdminWorkspaceSort>;
|
orderBy?: InputMaybe<AdminWorkspaceSort>;
|
||||||
@@ -3071,8 +3062,6 @@ export interface ServerConfigType {
|
|||||||
availableUpgrade: Maybe<ReleaseVersionType>;
|
availableUpgrade: Maybe<ReleaseVersionType>;
|
||||||
/** Features for user that can be configured */
|
/** Features for user that can be configured */
|
||||||
availableUserFeatures: Array<FeatureType>;
|
availableUserFeatures: Array<FeatureType>;
|
||||||
/** Workspace features available for admin configuration */
|
|
||||||
availableWorkspaceFeatures: Array<FeatureType>;
|
|
||||||
/** server base url */
|
/** server base url */
|
||||||
baseUrl: Scalars['String']['output'];
|
baseUrl: Scalars['String']['output'];
|
||||||
calendarCalDAVProviders: Array<CalendarCalDavProviderPresetObjectType>;
|
calendarCalDAVProviders: Array<CalendarCalDavProviderPresetObjectType>;
|
||||||
@@ -4076,7 +4065,6 @@ export type AdminServerConfigQuery = {
|
|||||||
type: ServerDeploymentType;
|
type: ServerDeploymentType;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
availableUserFeatures: Array<FeatureType>;
|
availableUserFeatures: Array<FeatureType>;
|
||||||
availableWorkspaceFeatures: Array<FeatureType>;
|
|
||||||
credentialsRequirement: {
|
credentialsRequirement: {
|
||||||
__typename?: 'CredentialsRequirementType';
|
__typename?: 'CredentialsRequirementType';
|
||||||
password: {
|
password: {
|
||||||
@@ -4112,7 +4100,6 @@ export type AdminUpdateWorkspaceMutation = {
|
|||||||
enableSharing: boolean;
|
enableSharing: boolean;
|
||||||
enableUrlPreview: boolean;
|
enableUrlPreview: boolean;
|
||||||
enableDocEmbedding: boolean;
|
enableDocEmbedding: boolean;
|
||||||
features: Array<FeatureType>;
|
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
publicPageCount: number;
|
publicPageCount: number;
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
@@ -4149,7 +4136,6 @@ export type AdminWorkspaceQuery = {
|
|||||||
enableSharing: boolean;
|
enableSharing: boolean;
|
||||||
enableUrlPreview: boolean;
|
enableUrlPreview: boolean;
|
||||||
enableDocEmbedding: boolean;
|
enableDocEmbedding: boolean;
|
||||||
features: Array<FeatureType>;
|
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
publicPageCount: number;
|
publicPageCount: number;
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
@@ -4198,7 +4184,6 @@ export type AdminWorkspacesQuery = {
|
|||||||
enableSharing: boolean;
|
enableSharing: boolean;
|
||||||
enableUrlPreview: boolean;
|
enableUrlPreview: boolean;
|
||||||
enableDocEmbedding: boolean;
|
enableDocEmbedding: boolean;
|
||||||
features: Array<FeatureType>;
|
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
publicPageCount: number;
|
publicPageCount: number;
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
|
|||||||
@@ -227,18 +227,6 @@
|
|||||||
"desc": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated."
|
"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": {
|
"storages": {
|
||||||
"avatar.publicPath": {
|
"avatar.publicPath": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
|
|||||||
@@ -35,20 +35,6 @@ export const useColumns = () => {
|
|||||||
<div className="w-full truncate font-mono text-xs text-muted-foreground">
|
<div className="w-full truncate font-mono text-xs text-muted-foreground">
|
||||||
{workspace.id}
|
{workspace.id}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from '@affine/admin/components/ui/button';
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
import { Input } from '@affine/admin/components/ui/input';
|
import { Input } from '@affine/admin/components/ui/input';
|
||||||
import type { FeatureType } from '@affine/graphql';
|
|
||||||
import { AdminWorkspaceSort } from '@affine/graphql';
|
import { AdminWorkspaceSort } from '@affine/graphql';
|
||||||
import type { Table } from '@tanstack/react-table';
|
import type { Table } from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
@@ -11,22 +10,18 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '../../../components/ui/popover';
|
} from '../../../components/ui/popover';
|
||||||
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
|
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
|
||||||
import { useServerConfig } from '../../common';
|
|
||||||
import type { WorkspaceFlagFilter } from '../schema';
|
import type { WorkspaceFlagFilter } from '../schema';
|
||||||
|
|
||||||
interface DataTableToolbarProps<TData> {
|
interface DataTableToolbarProps<TData> {
|
||||||
table?: Table<TData>;
|
table?: Table<TData>;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
onKeywordChange: (keyword: string) => void;
|
onKeywordChange: (keyword: string) => void;
|
||||||
selectedFeatures: FeatureType[];
|
|
||||||
onFeaturesChange: (features: FeatureType[]) => void;
|
|
||||||
flags: WorkspaceFlagFilter;
|
flags: WorkspaceFlagFilter;
|
||||||
onFlagsChange: (flags: WorkspaceFlagFilter) => void;
|
onFlagsChange: (flags: WorkspaceFlagFilter) => void;
|
||||||
sort: AdminWorkspaceSort | undefined;
|
sort: AdminWorkspaceSort | undefined;
|
||||||
@@ -47,8 +42,6 @@ const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
|
|||||||
export function DataTableToolbar<TData>({
|
export function DataTableToolbar<TData>({
|
||||||
keyword,
|
keyword,
|
||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
selectedFeatures,
|
|
||||||
onFeaturesChange,
|
|
||||||
flags,
|
flags,
|
||||||
onFlagsChange,
|
onFlagsChange,
|
||||||
sort,
|
sort,
|
||||||
@@ -57,8 +50,6 @@ export function DataTableToolbar<TData>({
|
|||||||
}: DataTableToolbarProps<TData>) {
|
}: DataTableToolbarProps<TData>) {
|
||||||
const [value, setValue] = useState(keyword);
|
const [value, setValue] = useState(keyword);
|
||||||
const debouncedValue = useDebouncedValue(value, 400);
|
const debouncedValue = useDebouncedValue(value, 400);
|
||||||
const serverConfig = useServerConfig();
|
|
||||||
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(keyword);
|
setValue(keyword);
|
||||||
@@ -116,15 +107,7 @@ export function DataTableToolbar<TData>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
|
<div className="flex items-center justify-end gap-y-2 gap-x-4 flex-wrap">
|
||||||
<FeatureFilterPopover
|
|
||||||
selectedFeatures={selectedFeatures}
|
|
||||||
availableFeatures={availableFeatures}
|
|
||||||
onChange={onFeaturesChange}
|
|
||||||
align="start"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||||
<Popover open={disabled ? false : undefined}>
|
<Popover open={disabled ? false : undefined}>
|
||||||
<PopoverTrigger asChild>
|
<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 { ColumnDef, PaginationState } from '@tanstack/react-table';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
@@ -13,8 +13,6 @@ interface DataTableProps<TData, TValue> {
|
|||||||
workspacesCount: number;
|
workspacesCount: number;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
onKeywordChange: (value: string) => void;
|
onKeywordChange: (value: string) => void;
|
||||||
selectedFeatures: FeatureType[];
|
|
||||||
onFeaturesChange: (features: FeatureType[]) => void;
|
|
||||||
flags: WorkspaceFlagFilter;
|
flags: WorkspaceFlagFilter;
|
||||||
onFlagsChange: Dispatch<SetStateAction<WorkspaceFlagFilter>>;
|
onFlagsChange: Dispatch<SetStateAction<WorkspaceFlagFilter>>;
|
||||||
sort: AdminWorkspaceSort | undefined;
|
sort: AdminWorkspaceSort | undefined;
|
||||||
@@ -35,8 +33,6 @@ export function DataTable<TData extends { id: string }, TValue>({
|
|||||||
workspacesCount,
|
workspacesCount,
|
||||||
keyword,
|
keyword,
|
||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
selectedFeatures,
|
|
||||||
onFeaturesChange,
|
|
||||||
flags,
|
flags,
|
||||||
onFlagsChange,
|
onFlagsChange,
|
||||||
sort,
|
sort,
|
||||||
@@ -51,14 +47,12 @@ export function DataTable<TData extends { id: string }, TValue>({
|
|||||||
totalCount={workspacesCount}
|
totalCount={workspacesCount}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
resetFiltersDeps={[keyword, selectedFeatures, sort, flags]}
|
resetFiltersDeps={[keyword, sort, flags]}
|
||||||
renderToolbar={table => (
|
renderToolbar={table => (
|
||||||
<DataTableToolbar
|
<DataTableToolbar
|
||||||
table={table}
|
table={table}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
onKeywordChange={onKeywordChange}
|
onKeywordChange={onKeywordChange}
|
||||||
selectedFeatures={selectedFeatures}
|
|
||||||
onFeaturesChange={onFeaturesChange}
|
|
||||||
flags={flags}
|
flags={flags}
|
||||||
onFlagsChange={onFlagsChange}
|
onFlagsChange={onFlagsChange}
|
||||||
sort={sort}
|
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