cloud: enable workspace slots for partners
This commit is contained in:
@@ -4,12 +4,13 @@ export interface TierConfig {
|
||||
plan: WorkspacePlan;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
workspaceSlots: number;
|
||||
}
|
||||
|
||||
const TIER_MAP: Record<number, TierConfig> = {
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true },
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
@@ -110,11 +109,15 @@ export default withRateLimit(
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const isActive = license.status === "active";
|
||||
const status = isActive ? "active" : "inactive";
|
||||
const status = license.status === "active" ? "active" : "inactive";
|
||||
|
||||
if (!user) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
// Ensure subscription slots exist — webhook may have already created them
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
@@ -124,45 +127,18 @@ export default withRateLimit(
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
|
||||
if (!workspace) {
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
);
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
|
||||
}
|
||||
|
||||
return res.redirect(`/?partner_activated=1`);
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -27,38 +27,72 @@ export default withRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
if (!sub) {
|
||||
if (!allSlots.length) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
}
|
||||
|
||||
if (sub.status !== "active") {
|
||||
const activeSlots = allSlots.filter((s) =>
|
||||
["active", "trialing"].includes(s.status),
|
||||
);
|
||||
|
||||
if (!activeSlots.length) {
|
||||
return res.redirect("/boards?partner_error=license_inactive");
|
||||
}
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const workspace = memberships?.[0]?.workspace;
|
||||
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||
|
||||
if (!workspace) {
|
||||
if (!unlinkedSlot) {
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}
|
||||
|
||||
const linkedIds = new Set(
|
||||
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||
);
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const availableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||
|
||||
if (!availableWorkspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: sub.plan,
|
||||
status: sub.status,
|
||||
partnerTier: sub.partnerTier ?? 1,
|
||||
seats: sub.seats ?? null,
|
||||
unlimitedSeats: sub.unlimitedSeats,
|
||||
referenceId: workspace.publicId,
|
||||
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||
referenceId: availableWorkspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
|
||||
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||
});
|
||||
|
||||
const remainingUnlinked = activeSlots.filter(
|
||||
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||
);
|
||||
|
||||
if (remainingUnlinked.length > 0) {
|
||||
const updatedLinkedIds = new Set([
|
||||
...linkedIds,
|
||||
availableWorkspace.publicId,
|
||||
]);
|
||||
const hasMoreAvailableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||
|
||||
if (hasMoreAvailableWorkspace) {
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,6 +43,12 @@ function verifySignature(
|
||||
}
|
||||
}
|
||||
|
||||
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||
s: T,
|
||||
): s is T & { referenceId: string } {
|
||||
return !!s.referenceId;
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
@@ -94,107 +100,169 @@ export default withApiLogging(
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event) {
|
||||
case "purchase": {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: license_status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "purchase":
|
||||
case "activate": {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
const status = event === "activate" ? "active" : license_status;
|
||||
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
} else {
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
if (sub) {
|
||||
const [, allSubs] = await Promise.all([
|
||||
subscriptionRepo.updateById(db, sub.id, {
|
||||
plan: "free",
|
||||
status: "canceled",
|
||||
}),
|
||||
sub.referenceId
|
||||
? subscriptionRepo.getByReferenceId(db, sub.referenceId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
if (sub.referenceId) {
|
||||
const hasActiveSub = getActiveSubscriptions(allSubs).some(
|
||||
(s) => s.id !== sub.id,
|
||||
|
||||
await Promise.all(
|
||||
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||
db,
|
||||
slot.referenceId,
|
||||
);
|
||||
if (!hasActiveSub) {
|
||||
await cancelWorkspaceAccess(db, sub.referenceId);
|
||||
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||
(s) => s.id !== slot.id,
|
||||
);
|
||||
if (!hasOtherActiveSub) {
|
||||
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: "free",
|
||||
status: "canceled",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
);
|
||||
if (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: sub.referenceId ?? undefined,
|
||||
});
|
||||
if (prev_license_key) {
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
status: "inactive",
|
||||
});
|
||||
}
|
||||
if (sub.referenceId) {
|
||||
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (prev_license_key) {
|
||||
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
plan: cfg.plan,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
partnerTier: tier,
|
||||
status: "active",
|
||||
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (sub) {
|
||||
const cfg = tierConfig(tier);
|
||||
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||
license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
referenceId: sub.referenceId ?? undefined,
|
||||
});
|
||||
await subscriptionRepo.updateById(db, sub.id, {
|
||||
status: "inactive",
|
||||
});
|
||||
}
|
||||
},
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (!prev_license_key) break;
|
||||
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
|
||||
await Promise.all(
|
||||
existing.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
partnerLicenseKey: license_key,
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user