feat(cloud): pause members when subscription is cancelled
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,6 @@ export function InviteMemberForm({
|
||||
}
|
||||
}, [activeInviteLink]);
|
||||
|
||||
const isAtSeatLimit = seatLimit !== null && memberCount >= seatLimit;
|
||||
|
||||
const inviteMember = api.member.invite.useMutation({
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
|
||||
@@ -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() {
|
||||
<InviteMemberForm
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
memberCount={activeMembers}
|
||||
memberCount={memberCount}
|
||||
seatLimit={seatLimit}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
packages/api/src/utils/workspace.ts
Normal file
40
packages/api/src/utils/workspace.ts
Normal file
@@ -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<void> => {
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -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<void> {
|
||||
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 = "";
|
||||
|
||||
@@ -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<string | null> => {
|
||||
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: {
|
||||
|
||||
@@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
createdBy: true,
|
||||
},
|
||||
where: eq(workspaces.publicId, workspacePublicId),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user