From ccb66f99d8adb11d8385b97096ed784c2356c1a6 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:03:00 +0200 Subject: [PATCH] feat(api): add is_client check to middleware and billing --- .../api/src/__tests__/helpers/billing.test.ts | 5 ++++ .../__tests__/middlewares/middlewares.test.ts | 29 ++++++++++++++++++- apps/api/src/helpers/billing.ts | 5 ++-- apps/api/src/middlewares/middleware.ts | 4 +-- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts index c43a874..62c0d11 100644 --- a/apps/api/src/__tests__/helpers/billing.test.ts +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -28,12 +28,14 @@ describe("billing helpers", () => { id: "owner-user", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "annual", }, { id: "late-user", created_at: "2026-01-02T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, ]); @@ -47,18 +49,21 @@ describe("billing helpers", () => { id: "user-1", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, { id: "temp-1", created_at: "2026-01-02T10:00:00.000Z", is_temporary: true, + is_client: false, plan: "solo", }, { id: "user-2", created_at: "2026-01-03T10:00:00.000Z", is_temporary: null, + is_client: false, plan: "team", }, ]); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index 489506f..52a9b20 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -12,7 +12,7 @@ describe("Middleware Tests", () => { const middlewareManager = MiddlewareManager.getInstance(); const createProfilesSupabaseMock = (result: { - data: { is_temporary: boolean } | null; + data: { is_temporary?: boolean; is_client?: boolean } | null; error: { message: string } | null; }) => ({ from: vi.fn().mockReturnValue({ @@ -342,6 +342,33 @@ describe("Middleware Tests", () => { expect(res.status).toBe(401); expect(data.error).toBe("User is read only"); }); + + it("should return 401 for client users", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createProfilesSupabaseMock({ + data: { is_temporary: false, is_client: true }, + error: null, + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "client-user" } as any); + await next(); + }); + app.use(middlewareManager.regularUserCheck); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toBe("User is read only"); + }); }); describe("Billing Checkout Access Middleware", () => { diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts index 0e043d4..e0e10da 100644 --- a/apps/api/src/helpers/billing.ts +++ b/apps/api/src/helpers/billing.ts @@ -7,6 +7,7 @@ type BillingProfileRow = { id: string; created_at: string | null; is_temporary: boolean | null; + is_client: boolean | null; plan: string | null; }; @@ -87,7 +88,7 @@ export const parseTrialRolloutDate = ( export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null; export const getBillableMemberCount = (profiles: BillingProfileRow[]) => - profiles.filter((profile) => profile.is_temporary !== true).length; + profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length; export const getTrialWindow = (input: { ownerCreatedAt: Date; @@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { const { data, error } = await supabase .from("profiles") - .select("id, created_at, is_temporary, plan") + .select("id, created_at, is_temporary, is_client, plan") .eq("organization_id", organizationId) .order("created_at", { ascending: true }); diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 773a20b..7c153e6 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -84,7 +84,7 @@ export class MiddlewareManager { const { data: profile, error } = await supabase .from("profiles") - .select("is_temporary") + .select("is_temporary, is_client") .eq("id", user.id) .single(); @@ -92,7 +92,7 @@ export class MiddlewareManager { return c.json({ error: error?.message ?? "Profile not found" }, 500); } - if (!allowTemporaryUsers && profile.is_temporary) { + if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) { return c.json({ error: "User is read only" }, 401); }