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),
});