diff --git a/apps/web/src/pages/api/partner/webhook.ts b/apps/web/src/pages/api/partner/webhook.ts index 0e35dd18..6b6960c0 100644 --- a/apps/web/src/pages/api/partner/webhook.ts +++ b/apps/web/src/pages/api/partner/webhook.ts @@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import type { Readable } from "node:stream"; +import type { dbClient } from "@kan/db/client"; import { createNextApiContext } from "@kan/api/trpc"; import { withApiLogging } from "@kan/api/utils/apiLogging"; import { cancelWorkspaceAccess } from "@kan/api/utils/workspace"; @@ -10,10 +11,103 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { createLogger } from "@kan/logger"; import { getActiveSubscriptions } from "@kan/shared/utils"; +import type { TierConfig } from "./_utils"; import { tierConfig } from "./_utils"; const log = createLogger("api"); +async function createAndLinkSlots( + db: dbClient, + licenseKey: string, + cfg: TierConfig, + tier: number, + alreadyLinkedReferenceIds: Set, + count: number, +) { + const linkedId = [...alreadyLinkedReferenceIds][0]; + let autoLinked = 0; + + if (linkedId) { + const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId); + + if (linkedWorkspace?.createdBy) { + const owned = await workspaceRepo.getAllOwnedByUserId( + db, + linkedWorkspace.createdBy, + ); + + const candidates = owned.filter( + (w) => !alreadyLinkedReferenceIds.has(w.publicId), + ); + + if (candidates.length > 0) { + const existingSubs = + await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds( + db, + candidates.map((w) => w.publicId), + ); + const alreadySubscribed = new Set( + existingSubs + .map((s) => s.referenceId) + .filter((id): id is string => !!id), + ); + + const available = candidates + .filter((w) => !alreadySubscribed.has(w.publicId)) + .slice(0, count); + + if (available.length > 0) { + const newSlots = await subscriptionRepo.createPartnerLicenseSlots( + db, + licenseKey, + { + plan: cfg.plan, + status: "active", + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }, + available.length, + ); + + await Promise.all( + available.map((workspace, i) => { + const slot = newSlots[i]; + if (!slot) return; + return Promise.all([ + subscriptionRepo.updateById(db, slot.id, { + referenceId: workspace.publicId, + }), + workspaceRepo.update(db, workspace.publicId, { + plan: cfg.plan, + }), + ]); + }), + ); + + autoLinked = available.length; + } + } + } + } + + const remaining = count - autoLinked; + if (remaining > 0) { + await subscriptionRepo.createPartnerLicenseSlots( + db, + licenseKey, + { + plan: cfg.plan, + status: "active", + partnerTier: tier, + seats: cfg.seats, + unlimitedSeats: cfg.unlimitedSeats, + }, + remaining, + ); + } +} + async function buffer(readable: Readable) { const chunks: Buffer[] = []; for await (const chunk of readable) { @@ -206,16 +300,15 @@ export default withApiLogging( ]); if (newCount > existing.length) { - await subscriptionRepo.createPartnerLicenseSlots( + const linkedIds = new Set( + slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId), + ); + await createAndLinkSlots( db, license_key, - { - plan: cfg.plan, - status: "active", - partnerTier: tier, - seats: cfg.seats, - unlimitedSeats: cfg.unlimitedSeats, - }, + cfg, + tier, + linkedIds, newCount - existing.length, ); } @@ -278,16 +371,15 @@ export default withApiLogging( ]); if (newCount > existing.length) { - await subscriptionRepo.createPartnerLicenseSlots( + const linkedIds = new Set( + slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId), + ); + await createAndLinkSlots( db, license_key, - { - plan: cfg.plan, - status: "active", - partnerTier: tier, - seats: cfg.seats, - unlimitedSeats: cfg.unlimitedSeats, - }, + cfg, + tier, + linkedIds, newCount - existing.length, ); } diff --git a/packages/db/src/repository/subscription.repo.ts b/packages/db/src/repository/subscription.repo.ts index 02d5ddd0..f1ac4198 100644 --- a/packages/db/src/repository/subscription.repo.ts +++ b/packages/db/src/repository/subscription.repo.ts @@ -147,6 +147,20 @@ export const getFirstUnlinkedSlotByLicenseKey = async ( }); }; +export const getAllActivePartnerSubsByWorkspaceIds = async ( + db: dbClient, + workspacePublicIds: string[], +) => { + if (workspacePublicIds.length === 0) return []; + return await db.query.subscription.findMany({ + where: and( + inArray(subscription.referenceId, workspacePublicIds), + isNotNull(subscription.partnerLicenseKey), + inArray(subscription.status, ["active", "trialing"]), + ), + }); +}; + export const getFirstActivePartnerSubByWorkspaceIds = async ( db: dbClient, workspacePublicIds: string[], diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 6369203a..6d5892fc 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -315,6 +315,16 @@ export const getAllByUserId = async (db: dbClient, userId: string) => { return result.filter((member) => !member.workspace.deletedAt); }; +export const getAllOwnedByUserId = async (db: dbClient, userId: string) => { + return await db.query.workspaces.findMany({ + columns: { + publicId: true, + plan: true, + }, + where: and(eq(workspaces.createdBy, userId), isNull(workspaces.deletedAt)), + }); +}; + export const getMemberByPublicId = (db: dbClient, memberPublicId: string) => { return db.query.workspaceMembers.findFirst({ columns: {