cloud: enable workspace slots for partners

This commit is contained in:
Henry
2026-05-29 22:58:09 +01:00
parent d9cf363302
commit 833c69a73e
12 changed files with 4345 additions and 196 deletions

View File

@@ -1,6 +1,6 @@
import { useRouter } from "next/navigation";
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
@@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
@@ -20,6 +21,8 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const { data: hasPartnerSlot } =
api.workspace.hasAvailablePartnerSlot.useQuery();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
@@ -150,11 +153,19 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
onClick={() => {
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
openModal("NEW_WORKSPACE");
} else if (hasPartnerSlot) {
router.push(
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
} else {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
}
}}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -40,7 +40,8 @@ export default function WorkspaceNameView() {
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const isLicenseFlow =
!!licenseKeyParam || searchParams.get("partner") === "1";
const { showPopup } = usePopup();
useEffect(() => {
@@ -103,6 +104,7 @@ export default function WorkspaceNameView() {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
void utils.workspace.hasAvailablePartnerSlot.invalidate();
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");