From 03e426dd23cbd57718ad8cf0a5b53772fafae071 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 8 Mar 2026 21:11:42 +0100 Subject: [PATCH] Implement new billing model --- apps/api/src/helpers/helpers.ts | 16 +++++++++++++++- apps/api/src/routers/invite.ts | 3 ++- apps/api/src/routers/tablo.ts | 18 +++++++++--------- apps/api/src/routers/user.ts | 27 +++++++++++++++++++++++---- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 0f9f636..4b0833b 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -291,8 +291,12 @@ export const createInvitedUser = async ( streamServerClient: StreamChat, transporter: Transporter, recipientEmail: string, - senderEmail: string + senderEmail: string, + options?: { + isTemporary?: boolean; + } ): Promise<{ success: boolean; error?: string; userId?: string }> => { + const isTemporary = options?.isTemporary ?? true; const xtabloUrl = process.env.XTABLO_URL || "https://app.xtablo.com"; // Create a new user account for the invited email @@ -318,6 +322,16 @@ export const createInvitedUser = async ( return { success: false, error: createUserError.message }; } + const { error: updateProfileError } = await supabase + .from("profiles") + .update({ is_temporary: isTemporary }) + .eq("id", newUser.user.id); + + if (updateProfileError) { + console.error("Error setting invited user temporary status:", updateProfileError); + return { success: false, error: updateProfileError.message }; + } + await streamServerClient.upsertUser({ id: newUser.user.id, name: recipientEmail.split("@")[0], diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index 28c8a37..b48c440 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -58,7 +58,8 @@ const bookSlot = factory.createHandlers(async (c) => { streamServerClient, transporter, data.user_details.email, - ownerData.email + ownerData.email, + { isTemporary: true } ); if (!result.success) { diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 90b9f52..f466c85 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -33,7 +33,11 @@ const upsertStreamUserFromProfile = async ( streamServerClient: AuthEnv["Variables"]["streamServerClient"], userId: string ) => { - const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle(); + const { data: profile } = await supabase + .from("profiles") + .select("name") + .eq("id", userId) + .maybeSingle(); await streamServerClient.upsertUser({ id: userId, @@ -255,10 +259,7 @@ const deleteTablo = factory.createHandlers(async (c) => { } } - const { error } = await supabase - .from("tablos") - .update({ deleted_at: deletedAt }) - .eq("id", id); + const { error } = await supabase.from("tablos").update({ deleted_at: deletedAt }).eq("id", id); if (error) { return c.json({ error: error.message }, 500); @@ -352,7 +353,8 @@ const inviteToTablo = ( streamServerClient, transporter, recipientEmail, - sender.email + sender.email, + { isTemporary: true } ); if (!result.success) { @@ -427,9 +429,7 @@ ${introEmail ? `

${introEmail}

` : ""} }); }); -const cancelPendingInvite = ( - middlewareManager: ReturnType -) => +const cancelPendingInvite = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 34f1d2e..0d8d0a0 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -35,7 +35,10 @@ const getMe = factory.createHandlers(async (c) => { const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); - const userData = data as Tables<"profiles">; + const userData = data as Tables<"profiles"> & { + organization_id: number | null; + plan: string | null; + }; if (!userData) { return c.json({ error: "User not found" }, 404); @@ -45,11 +48,21 @@ const getMe = factory.createHandlers(async (c) => { return c.json({ error: error.message }, 500); } + let effectivePlan: string | null = userData.plan; + if (userData.organization_id) { + const { plan: organizationPlan } = await getOrganizationPlan( + supabase, + userData.organization_id + ); + effectivePlan = organizationPlan; + } + const user_id = data.id; const token = streamServerClient.createToken(user_id); return c.json({ ...userData, + plan: effectivePlan, streamToken: token, }); }); @@ -313,6 +326,11 @@ const getOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Failed to resolve organization plan" }, 500); } + const membersWithEffectivePlan = (members || []).map((member) => ({ + ...member, + plan, + })); + const { data: billingState, error: billingError } = await getOrganizationBillingState( supabase, organizationId @@ -329,7 +347,7 @@ const getOrganization = factory.createHandlers(async (c) => { member_count: members?.length || 0, tablo_count: tabloCount || 0, }, - members: members || [], + members: membersWithEffectivePlan, trial_starts_at: billingState.trial_starts_at, trial_ends_at: billingState.trial_ends_at, is_trial_expired: billingState.is_trial_expired, @@ -469,7 +487,8 @@ const inviteToOrganization = factory.createHandlers(async (c) => { streamServerClient, transporter, recipientEmail, - senderProfile.email + senderProfile.email, + { isTemporary: false } ); if (!invitedUser.success || !invitedUser.userId) { @@ -490,7 +509,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const { error: assignOrganizationError } = await supabase .from("profiles") - .update({ organization_id: organizationId }) + .update({ organization_id: organizationId, is_temporary: false }) .eq("id", invitedUser.userId); if (assignOrganizationError) {