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 { createNextApiContext } from "@kan/api/trpc";
|
||||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
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 subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
@@ -126,7 +127,7 @@ export default withApiLogging(
|
|||||||
const [, allSubs] = await Promise.all([
|
const [, allSubs] = await Promise.all([
|
||||||
subscriptionRepo.updateById(db, sub.id, {
|
subscriptionRepo.updateById(db, sub.id, {
|
||||||
plan: "free",
|
plan: "free",
|
||||||
status: "inactive",
|
status: "canceled",
|
||||||
}),
|
}),
|
||||||
sub.referenceId
|
sub.referenceId
|
||||||
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
|
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
|
||||||
@@ -137,7 +138,7 @@ export default withApiLogging(
|
|||||||
(s) => s.id !== sub.id,
|
(s) => s.id !== sub.id,
|
||||||
);
|
);
|
||||||
if (!hasActiveSub) {
|
if (!hasActiveSub) {
|
||||||
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
|
await cancelWorkspaceAccess(db, sub.referenceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ export function InviteMemberForm({
|
|||||||
}
|
}
|
||||||
}, [activeInviteLink]);
|
}, [activeInviteLink]);
|
||||||
|
|
||||||
const isAtSeatLimit = seatLimit !== null && memberCount >= seatLimit;
|
|
||||||
|
|
||||||
const inviteMember = api.member.invite.useMutation({
|
const inviteMember = api.member.invite.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export default function MembersPage() {
|
|||||||
|
|
||||||
const activeMembers = data?.members.length ?? 0;
|
const activeMembers = data?.members.length ?? 0;
|
||||||
const seatLimit = getSeatLimit(subscriptions);
|
const seatLimit = getSeatLimit(subscriptions);
|
||||||
|
const memberCount =
|
||||||
|
data?.members.filter((m) => m.status === "active" || m.status === "invited")
|
||||||
|
.length ?? 0;
|
||||||
const totalSeats =
|
const totalSeats =
|
||||||
teamSubscription?.seats ??
|
teamSubscription?.seats ??
|
||||||
proSubscription?.seats ??
|
proSubscription?.seats ??
|
||||||
@@ -407,7 +410,7 @@ export default function MembersPage() {
|
|||||||
<InviteMemberForm
|
<InviteMemberForm
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptions}
|
||||||
unlimitedSeats={unlimitedSeats}
|
unlimitedSeats={unlimitedSeats}
|
||||||
memberCount={activeMembers}
|
memberCount={memberCount}
|
||||||
seatLimit={seatLimit}
|
seatLimit={seatLimit}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"./utils/permissions": {
|
"./utils/permissions": {
|
||||||
"types": "./src/utils/permissions.ts",
|
"types": "./src/utils/permissions.ts",
|
||||||
"default": "./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",
|
"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 { sendEmail } from "@kan/email";
|
||||||
import { createLogger } from "@kan/logger";
|
import { createLogger } from "@kan/logger";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
const log = createLogger("auth");
|
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
import { socialProvidersPlugin } from "./providers";
|
import { socialProvidersPlugin } from "./providers";
|
||||||
import { triggerWorkflow } from "./utils";
|
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) {
|
export function createPlugins(db: dbClient) {
|
||||||
return [
|
return [
|
||||||
socialProvidersPlugin(),
|
socialProvidersPlugin(),
|
||||||
@@ -104,7 +140,10 @@ export function createPlugins(db: dbClient) {
|
|||||||
unlimitedSeats: true,
|
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(
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
db,
|
db,
|
||||||
@@ -126,35 +165,9 @@ export function createPlugins(db: dbClient) {
|
|||||||
subscription,
|
subscription,
|
||||||
cancellationDetails,
|
cancellationDetails,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
// for cancelled subscriptions, we need to pause all members and set their workspace plan to free
|
onSubscriptionDeleted: async ({ subscription }) => {
|
||||||
const workspace = await workspaceRepo.getByPublicId(
|
await cancelWorkspaceAccess(db, subscription.referenceId);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSubscriptionUpdate: async ({ subscription }) => {
|
onSubscriptionUpdate: async ({ subscription }) => {
|
||||||
await triggerWorkflow(db, "subscription-updated", subscription);
|
await triggerWorkflow(db, "subscription-updated", subscription);
|
||||||
@@ -183,7 +196,10 @@ export function createPlugins(db: dbClient) {
|
|||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
try {
|
try {
|
||||||
const decodedUrl = decodeURIComponent(url);
|
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")) {
|
if (decodedUrl.includes("type=invite")) {
|
||||||
let inviterName = "";
|
let inviterName = "";
|
||||||
let workspaceName = "";
|
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 { dbClient } from "@kan/db/client";
|
||||||
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
import type { MemberRole, MemberStatus } from "@kan/db/schema";
|
||||||
@@ -92,14 +92,14 @@ export const getByPublicIdsWithUsers = async (
|
|||||||
return db.query.workspaceMembers.findMany({
|
return db.query.workspaceMembers.findMany({
|
||||||
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
||||||
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
||||||
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
conditions.push(eq(members.workspaceId, workspaceId));
|
conditions.push(eq(members.workspaceId, workspaceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
conditions.push(eq(members.status, "active"));
|
conditions.push(eq(members.status, "active"));
|
||||||
conditions.push(isNullFn(members.deletedAt));
|
conditions.push(isNullFn(members.deletedAt));
|
||||||
|
|
||||||
return and(...conditions);
|
return and(...conditions);
|
||||||
},
|
},
|
||||||
with: {
|
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 (
|
export const updateRole = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
|||||||
name: true,
|
name: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
|
createdBy: true,
|
||||||
},
|
},
|
||||||
where: eq(workspaces.publicId, workspacePublicId),
|
where: eq(workspaces.publicId, workspacePublicId),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user