From 8d8cfa3baee087a9b7a01f9627a035ee14ac5803 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 21 May 2026 22:52:14 +0100 Subject: [PATCH] feat(cloud): pause members when subscription is cancelled --- apps/web/src/pages/api/partner/webhook.ts | 5 +- .../members/components/InviteMemberForm.tsx | 2 - apps/web/src/views/members/index.tsx | 5 +- packages/api/package.json | 4 + packages/api/src/utils/workspace.ts | 40 +++++++++ packages/auth/src/plugins.ts | 82 +++++++++++-------- packages/db/src/repository/member.repo.ts | 70 +++++++++++++++- packages/db/src/repository/workspace.repo.ts | 1 + 8 files changed, 167 insertions(+), 42 deletions(-) create mode 100644 packages/api/src/utils/workspace.ts diff --git a/apps/web/src/pages/api/partner/webhook.ts b/apps/web/src/pages/api/partner/webhook.ts index d6a6ae98..52ec0a44 100644 --- a/apps/web/src/pages/api/partner/webhook.ts +++ b/apps/web/src/pages/api/partner/webhook.ts @@ -4,6 +4,7 @@ import type { Readable } from "node:stream"; import { createNextApiContext } from "@kan/api/trpc"; import { withApiLogging } from "@kan/api/utils/apiLogging"; +import { cancelWorkspaceAccess } from "@kan/api/utils/workspace"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createLogger } from "@kan/logger"; @@ -126,7 +127,7 @@ export default withApiLogging( const [, allSubs] = await Promise.all([ subscriptionRepo.updateById(db, sub.id, { plan: "free", - status: "inactive", + status: "canceled", }), sub.referenceId ? subscriptionRepo.getByReferenceId(db, sub.referenceId) @@ -137,7 +138,7 @@ export default withApiLogging( (s) => s.id !== sub.id, ); if (!hasActiveSub) { - await workspaceRepo.update(db, sub.referenceId, { plan: "free" }); + await cancelWorkspaceAccess(db, sub.referenceId); } } } diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx index 6919c6af..d21262d0 100644 --- a/apps/web/src/views/members/components/InviteMemberForm.tsx +++ b/apps/web/src/views/members/components/InviteMemberForm.tsx @@ -81,8 +81,6 @@ export function InviteMemberForm({ } }, [activeInviteLink]); - const isAtSeatLimit = seatLimit !== null && memberCount >= seatLimit; - const inviteMember = api.member.invite.useMutation({ onSuccess: async () => { closeModal(); diff --git a/apps/web/src/views/members/index.tsx b/apps/web/src/views/members/index.tsx index a73527b1..2f7a62bb 100644 --- a/apps/web/src/views/members/index.tsx +++ b/apps/web/src/views/members/index.tsx @@ -89,6 +89,9 @@ export default function MembersPage() { const activeMembers = data?.members.length ?? 0; const seatLimit = getSeatLimit(subscriptions); + const memberCount = + data?.members.filter((m) => m.status === "active" || m.status === "invited") + .length ?? 0; const totalSeats = teamSubscription?.seats ?? proSubscription?.seats ?? @@ -407,7 +410,7 @@ export default function MembersPage() { diff --git a/packages/api/package.json b/packages/api/package.json index 6b8a9985..468c94be 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -35,6 +35,10 @@ "./utils/permissions": { "types": "./src/utils/permissions.ts", "default": "./src/utils/permissions.ts" + }, + "./utils/workspace": { + "types": "./src/utils/workspace.ts", + "default": "./src/utils/workspace.ts" } }, "license": "GPL-3.0", diff --git a/packages/api/src/utils/workspace.ts b/packages/api/src/utils/workspace.ts new file mode 100644 index 00000000..31db3c1c --- /dev/null +++ b/packages/api/src/utils/workspace.ts @@ -0,0 +1,40 @@ +import type { dbClient } from "@kan/db/client"; +import * as memberRepo from "@kan/db/repository/member.repo"; +import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { generateUID } from "@kan/shared/utils"; + +export const cancelWorkspaceAccess = async ( + db: dbClient, + workspacePublicId: string, +): Promise => { + const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId); + + if (!workspace) return; + + const preserveUserId = await memberRepo.getPreservableMemberId( + db, + workspace.id, + workspace.createdBy ?? null, + ); + + let newSlug = workspace.publicId; + if (workspace.slug !== workspace.publicId) { + const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable( + db, + workspace.publicId, + ); + if (!isPublicIdAvailable) { + newSlug = generateUID(); + } + } + + await Promise.all([ + preserveUserId + ? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId) + : memberRepo.pauseAllMembers(db, workspace.id), + workspaceRepo.update(db, workspacePublicId, { + plan: "free", + slug: newSlug, + }), + ]); +}; diff --git a/packages/auth/src/plugins.ts b/packages/auth/src/plugins.ts index 2b0ab120..d52bb90d 100644 --- a/packages/auth/src/plugins.ts +++ b/packages/auth/src/plugins.ts @@ -10,13 +10,49 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { sendEmail } from "@kan/email"; import { createLogger } from "@kan/logger"; import { generateUID } from "@kan/shared/utils"; - -const log = createLogger("auth"); import { createStripeClient } from "@kan/stripe"; import { socialProvidersPlugin } from "./providers"; import { triggerWorkflow } from "./utils"; +const log = createLogger("auth"); + +async function cancelWorkspaceAccess( + db: dbClient, + workspacePublicId: string, +): Promise { + const workspace = await workspaceRepo.getByPublicId(db, workspacePublicId); + + if (!workspace) return; + + const preserveUserId = await memberRepo.getPreservableMemberId( + db, + workspace.id, + workspace.createdBy ?? null, + ); + + let newSlug = workspace.publicId; + if (workspace.slug !== workspace.publicId) { + const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable( + db, + workspace.publicId, + ); + if (!isPublicIdAvailable) { + newSlug = generateUID(); + } + } + + await Promise.all([ + preserveUserId + ? memberRepo.pauseMembersExcept(db, workspace.id, preserveUserId) + : memberRepo.pauseAllMembers(db, workspace.id), + workspaceRepo.update(db, workspacePublicId, { + plan: "free", + slug: newSlug, + }), + ]); +} + export function createPlugins(db: dbClient) { return [ socialProvidersPlugin(), @@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) { unlimitedSeats: true, }, ); - log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats"); + log.info( + { subscriptionId: stripeSubscription.id }, + "Pro subscription activated with unlimited seats", + ); const workspace = await workspaceRepo.getByPublicId( db, @@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) { subscription, cancellationDetails, ); - - // for cancelled subscriptions, we need to pause all members and set their workspace plan to free - const workspace = await workspaceRepo.getByPublicId( - db, - subscription.referenceId, - ); - - if (workspace?.id) { - await memberRepo.pauseAllMembers(db, workspace.id); - - // Reset slug to publicId, or generate a UID if publicId is taken - let newSlug = workspace.publicId; - - if (workspace.slug !== workspace.publicId) { - const isPublicIdAvailable = - await workspaceRepo.isWorkspaceSlugAvailable( - db, - workspace.publicId, - ); - if (!isPublicIdAvailable) { - newSlug = generateUID(); - } - } - - await workspaceRepo.update(db, subscription.referenceId, { - plan: "free", - slug: newSlug, - }); - } + }, + onSubscriptionDeleted: async ({ subscription }) => { + await cancelWorkspaceAccess(db, subscription.referenceId); }, onSubscriptionUpdate: async ({ subscription }) => { await triggerWorkflow(db, "subscription-updated", subscription); @@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) { sendMagicLink: async ({ email, url }) => { try { const decodedUrl = decodeURIComponent(url); - log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link"); + log.info( + { email, isInvite: decodedUrl.includes("type=invite") }, + "Sending magic link", + ); if (decodedUrl.includes("type=invite")) { let inviterName = ""; let workspaceName = ""; diff --git a/packages/db/src/repository/member.repo.ts b/packages/db/src/repository/member.repo.ts index 5436ef74..fe4fab08 100644 --- a/packages/db/src/repository/member.repo.ts +++ b/packages/db/src/repository/member.repo.ts @@ -1,4 +1,4 @@ -import { and, count, eq, isNull, or } from "drizzle-orm"; +import { and, count, eq, isNull, ne, or } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import type { MemberRole, MemberStatus } from "@kan/db/schema"; @@ -92,14 +92,14 @@ export const getByPublicIdsWithUsers = async ( return db.query.workspaceMembers.findMany({ where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => { const conditions = [inArrayFn(members.publicId, memberPublicIds)]; - + if (workspaceId) { conditions.push(eq(members.workspaceId, workspaceId)); } - + conditions.push(eq(members.status, "active")); conditions.push(isNullFn(members.deletedAt)); - + return and(...conditions); }, with: { @@ -193,6 +193,68 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => { ); }; +export const getPreservableMemberId = async ( + db: dbClient, + workspaceId: number, + ownerUserId: string | null, +): Promise => { + if (ownerUserId) { + const owner = await db.query.workspaceMembers.findFirst({ + columns: { userId: true }, + where: and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.userId, ownerUserId), + eq(workspaceMembers.status, "active"), + isNull(workspaceMembers.deletedAt), + ), + }); + if (owner?.userId) return owner.userId; + } + + const admin = await db.query.workspaceMembers.findFirst({ + columns: { userId: true }, + where: and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.role, "admin"), + eq(workspaceMembers.status, "active"), + isNull(workspaceMembers.deletedAt), + ), + orderBy: (m, { asc }) => [asc(m.createdAt)], + }); + if (admin?.userId) return admin.userId; + + const anyMember = await db.query.workspaceMembers.findFirst({ + columns: { userId: true }, + where: and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.status, "active"), + isNull(workspaceMembers.deletedAt), + ), + orderBy: (m, { asc }) => [asc(m.createdAt)], + }); + return anyMember?.userId ?? null; +}; + +export const pauseMembersExcept = async ( + db: dbClient, + workspaceId: number, + preserveUserId: string, +) => { + await db + .update(workspaceMembers) + .set({ status: "paused" }) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.status, "active"), + or( + isNull(workspaceMembers.userId), + ne(workspaceMembers.userId, preserveUserId), + ), + ), + ); +}; + export const updateRole = async ( db: dbClient, args: { diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index c8e894bd..6369203a 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => { name: true, plan: true, slug: true, + createdBy: true, }, where: eq(workspaces.publicId, workspacePublicId), });