feat(api): add is_client check to middleware and billing

This commit is contained in:
Arthur Belleville 2026-04-15 09:03:00 +02:00
parent 9e75f9b78d
commit ccb66f99d8
No known key found for this signature in database
4 changed files with 38 additions and 5 deletions

View file

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

View file

@ -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", () => {

View file

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

View file

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