Block app access until organizations add a paid plan

This commit is contained in:
Arthur Belleville 2026-03-24 21:41:38 +01:00
parent 50ee39104b
commit 0b0d7b6cf0
No known key found for this signature in database
12 changed files with 437 additions and 67 deletions

View file

@ -24,6 +24,42 @@ describe("Middleware Tests", () => {
}),
});
const createBillingStateSupabaseMock = (input: {
ownerPlan?: string | null;
}) => {
const ownerPlan = input.ownerPlan ?? "none";
return {
from: vi.fn((table: string) => {
if (table === "profiles") {
return {
select: vi.fn((selectArg: string) => {
if (selectArg.includes("organization_id")) {
return {
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { organization_id: 1 },
error: null,
}),
}),
};
}
return {
eq: vi.fn().mockResolvedValue({
data: [{ plan: ownerPlan }],
error: null,
}),
};
}),
};
}
throw new Error(`Unexpected table ${table}`);
}),
};
};
describe("Supabase Middleware", () => {
it("should inject supabase client into context", async () => {
const app = new Hono();
@ -337,6 +373,60 @@ describe("Middleware Tests", () => {
});
});
describe("Active Plan Access Middleware", () => {
it("should reject requests when the organization has no active plan", async () => {
const app = new Hono();
app.use(async (c, next) => {
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set(
"supabase",
createBillingStateSupabaseMock({
ownerPlan: "none",
}) as any
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set("user", { id: "owner-user" } as any);
await next();
});
app.use(middlewareManager.activePlanAccess);
app.post("/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.$post();
const data = await res.json();
expect(res.status).toBe(402);
expect(data.error).toBe("An active subscription is required");
});
it("should allow requests when the organization has an active plan", async () => {
const app = new Hono();
app.use(async (c, next) => {
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set(
"supabase",
createBillingStateSupabaseMock({
ownerPlan: "solo",
}) as any
);
// biome-ignore lint/suspicious/noExplicitAny: Test-only context injection
(c as any).set("user", { id: "owner-user" } as any);
await next();
});
app.use(middlewareManager.activePlanAccess);
app.post("/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.$post();
const data = await res.json();
expect(res.status).toBe(200);
expect(data.success).toBe(true);
});
});
describe("StreamChat Middleware", () => {
it("should inject StreamChat client into context", async () => {
const app = new Hono();

View file

@ -223,6 +223,20 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(401);
});
it("should deny owner from creating a tablo when the organization has no active plan", async () => {
await supabaseAdmin.from("profiles").update({ plan: "none" }).eq("id", ownerUser.userId);
const res = await createTabloRequest(ownerUser, client, {
name: "Blocked Tablo",
status: "todo",
color: "#FF0000",
});
expect(res.status).toBe(402);
const data = await res.json();
expect(data.error).toBe("An active subscription is required");
});
it("should deny unauthenticated tablo creation", async () => {
const res = await client.tablos.create.$post({
json: {

View file

@ -39,6 +39,10 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
activePlanAccessMiddleware: MiddlewareHandler<{
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
transporterMiddleware: MiddlewareHandler<{
Variables: { transporter: Transporter };
}>;
@ -188,6 +192,43 @@ export class MiddlewareManager {
const regularUserCheckMiddleware = createProfileAccessMiddleware(false);
const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(true);
const activePlanAccessMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>(async (c, next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
const { data: organizationProfiles, error: organizationProfilesError } = await supabase
.from("profiles")
.select("plan")
.eq("organization_id", profile.organization_id);
if (organizationProfilesError || !organizationProfiles) {
return c.json({ error: "Failed to resolve organization plans" }, 500);
}
const hasAnyPlan = organizationProfiles.some(
(organizationProfile) =>
organizationProfile.plan && String(organizationProfile.plan).toLowerCase() !== "none"
);
if (!hasAnyPlan) {
return c.json({ error: "An active subscription is required" }, 402);
}
await next();
});
const transporterMiddleware = createMiddleware(async (c: Context, next: Next) => {
const transporter = createTransporter(config);
@ -218,6 +259,7 @@ export class MiddlewareManager {
r2Middleware,
regularUserCheckMiddleware,
billingCheckoutAccessMiddleware,
activePlanAccessMiddleware,
transporterMiddleware,
stripeSyncMiddleware,
stripeMiddleware,
@ -256,6 +298,10 @@ export class MiddlewareManager {
return this.middlewares.billingCheckoutAccessMiddleware;
}
get activePlanAccess() {
return this.middlewares.activePlanAccessMiddleware;
}
get transporter() {
return this.middlewares.transporterMiddleware;
}

View file

@ -96,10 +96,14 @@ const ensureTabloChannelMember = async (
};
const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
factory.createHandlers(
middlewareManager.regularUserCheck,
middlewareManager.activePlanAccess,
verifyTabloLimitForUser,
async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const data = await c.req.json();
const typedPayload = data as PostTablo;
@ -161,8 +165,9 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
await supabase.from("events").insert(eventsToInsert);
}
return c.json({ message: "Tablo created successfully", tablo: tabloData });
});
return c.json({ message: "Tablo created successfully", tablo: tabloData });
}
);
const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
@ -852,9 +857,14 @@ const generateWebcalUrl = (middlewareManager: ReturnType<typeof MiddlewareManage
});
const canCreateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => {
return c.json({ canCreate: true });
});
factory.createHandlers(
middlewareManager.regularUserCheck,
middlewareManager.activePlanAccess,
verifyTabloLimitForUser,
async (c) => {
return c.json({ canCreate: true });
}
);
export const getTabloRouter = (config: AppConfig) => {
const tabloRouter = new Hono();

View file

@ -145,7 +145,8 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
}
});
const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
const postTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const user = c.get("user");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
@ -187,7 +188,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
});
const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(checkTabloMember, async (c) => {
@ -335,7 +336,10 @@ const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => {
// POST /tablo-data/:tabloId/folders - Create a new folder (admin only)
const createTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(
middlewareManager.regularUserCheck,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
const user = c.get("user");
@ -376,11 +380,15 @@ const createTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManage
console.error("Error creating folder:", error);
return c.json({ error: "Failed to create folder" }, 500);
}
});
}
);
// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only)
const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(
middlewareManager.regularUserCheck,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
@ -425,11 +433,15 @@ const updateTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManage
console.error("Error updating folder:", error);
return c.json({ error: "Failed to update folder" }, 500);
}
});
}
);
// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only)
const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
factory.createHandlers(
middlewareManager.regularUserCheck,
checkTabloAdmin,
async (c) => {
const tabloId = c.req.param("tabloId");
const folderId = c.req.param("folderId");
const s3_client = c.get("s3_client");
@ -457,7 +469,8 @@ const deleteTabloFolder = (middlewareManager: ReturnType<typeof MiddlewareManage
console.error("Error deleting folder:", error);
return c.json({ error: "Failed to delete folder" }, 500);
}
});
}
);
// ============================================
// ROUTER SETUP
@ -485,12 +498,12 @@ export const getTabloDataRouter = () => {
// File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf")
// These must be defined after the specific routes above
tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile);
tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile);
tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile(middlewareManager));
tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager));
// Legacy routes for backward compatibility (single-level file names only)
tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile);
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile);
tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile(middlewareManager));
tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager));
return tabloDataRouter;

View file

@ -9,6 +9,7 @@ import {
} from "@xtablo/ui/components/dialog";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { useMaybeUpgradeBlock } from "../contexts/UpgradeBlockContext";
import { useOrganization } from "../hooks/organization";
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { useMaybeUser } from "../providers/UserStoreProvider";
@ -44,6 +45,7 @@ export function TrialUpsellModal() {
const { daysRemaining } = useTrialExpiration();
const { data: organizationData } = useOrganization();
const user = useMaybeUser();
const upgradeBlock = useMaybeUpgradeBlock();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const shouldShowModal = Boolean(
@ -78,7 +80,7 @@ export function TrialUpsellModal() {
return () => clearInterval(interval);
}, [shouldShowModal]);
if (!shouldShowModal || daysRemaining === null) {
if (!shouldShowModal || daysRemaining === null || upgradeBlock?.isBlocked) {
return null;
}

View file

@ -18,7 +18,7 @@ import { useCreateCheckoutSession } from "../hooks/stripe";
* Prevents access to the app until they upgrade to a paid plan
*/
export function UpgradePanel() {
const { isBlocked } = useUpgradeBlock();
const { isBlocked, reason } = useUpgradeBlock();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const { mutate: signOut } = useLogout();
const { data: organizationData } = useOrganization();
@ -31,11 +31,42 @@ export function UpgradePanel() {
const isBillingOwner = organizationData?.is_billing_owner ?? false;
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
const checkoutPlan = requiredPlan === "team" ? "team" : "solo";
const checkoutLabel =
checkoutPlan === "team"
? `Passer au plan Teams (${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""})`
: "Passer au plan Solo";
const title =
reason === "no_plan"
? "Choisissez un abonnement pour continuer"
: "Votre période d'essai est terminée";
const description =
reason === "no_plan"
? "Aucune formule active n'est associée à votre organisation. Sélectionnez Solo, Teams ou Founder pour continuer."
: `Pour continuer à utiliser XTablo, activez votre abonnement ${requiredPlan === "team" ? "Teams" : "Solo"}.`;
const featureTitle =
reason === "no_plan"
? "Choisissez la formule adaptée à votre organisation :"
: requiredPlan === "team"
? "Ce que vous obtenez avec Teams :"
: "Ce que vous obtenez avec Solo :";
const features =
reason === "no_plan"
? [
"Solo : 1 utilisateur et 10 tablos actifs",
`Teams : ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} ou plus selon votre équipe`,
"Founder : fonctionnalités illimitées",
]
: requiredPlan === "team"
? [
"Utilisateurs multiples (facturation par siège)",
"Tablos illimités",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
]
: [
"1 utilisateur",
"10 tablos actifs",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
@ -45,36 +76,14 @@ export function UpgradePanel() {
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center mb-2">
<Sparkles className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
<CardDescription className="text-base">
Pour continuer à utiliser XTablo, activez votre abonnement{" "}
{requiredPlan === "team" ? "Teams" : "Solo"}.
</CardDescription>
<CardTitle className="text-2xl">{title}</CardTitle>
<CardDescription className="text-base">{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Text className="text-sm font-medium">
{requiredPlan === "team"
? "Ce que vous obtenez avec Teams :"
: "Ce que vous obtenez avec Solo :"}
</Text>
<Text className="text-sm font-medium">{featureTitle}</Text>
<ul className="space-y-2">
{(requiredPlan === "team"
? [
"Utilisateurs multiples (facturation par siège)",
"Tablos illimités",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
]
: [
"1 utilisateur",
"10 tablos actifs",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
]
).map((feature) => (
{features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{feature}</span>
@ -87,7 +96,7 @@ export function UpgradePanel() {
<Button
onClick={() =>
createCheckout({
plan: checkoutPlan,
plan: "solo",
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
@ -103,11 +112,26 @@ export function UpgradePanel() {
) : (
<>
<CreditCard className="w-5 h-5" />
{checkoutLabel}
Passer au plan Solo
</>
)}
</Button>
<Button
variant="outline"
onClick={() =>
createCheckout({
plan: checkoutPlan === "team" ? "team" : "team",
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !isBillingOwner}
className="w-full"
>
Passer au plan Teams ({requiredTeamQuantity} siège{requiredTeamQuantity > 1 ? "s" : ""})
</Button>
<Button
variant="outline"
onClick={() =>

View file

@ -1,8 +1,10 @@
import React, { createContext, useContext } from "react";
import { useIsPastTrial } from "../hooks/stripe";
import { getOrganizationUpgradeBlockReason, type UpgradeBlockReason } from "../hooks/stripe";
import { useOrganization } from "../hooks/organization";
interface UpgradeBlockContextValue {
isBlocked: boolean;
reason: UpgradeBlockReason | null;
}
const UpgradeBlockContext = createContext<UpgradeBlockContextValue | null>(null);
@ -15,14 +17,30 @@ export const useUpgradeBlock = () => {
return context;
};
export const useMaybeUpgradeBlock = () => useContext(UpgradeBlockContext);
interface UpgradeBlockProviderProps {
children: React.ReactNode;
}
export const UpgradeBlockProvider: React.FC<UpgradeBlockProviderProps> = ({ children }) => {
const { isPastTrial: isBlocked } = useIsPastTrial();
const { data: organizationData } = useOrganization();
const reason = organizationData
? getOrganizationUpgradeBlockReason({
is_trial_expired: organizationData.is_trial_expired,
required_plan: organizationData.required_plan,
required_team_quantity: organizationData.required_team_quantity,
active_subscription_plan: organizationData.active_subscription_plan,
active_subscription_quantity: organizationData.active_subscription_quantity,
})
: null;
const isBlocked = reason !== null;
return (
<UpgradeBlockContext.Provider value={{ isBlocked }}>{children}</UpgradeBlockContext.Provider>
<UpgradeBlockContext.Provider value={{ isBlocked, reason }}>
{children}
</UpgradeBlockContext.Provider>
);
};

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getOrganizationUpgradeBlockReason } from "./stripe";
describe("getOrganizationUpgradeBlockReason", () => {
it("returns no_plan when the organization has no active subscription", () => {
expect(
getOrganizationUpgradeBlockReason({
is_trial_expired: false,
required_plan: "solo",
required_team_quantity: 1,
active_subscription_plan: null,
active_subscription_quantity: 0,
})
).toBe("no_plan");
});
it("returns trial_expired when a paid plan no longer covers the organization", () => {
expect(
getOrganizationUpgradeBlockReason({
is_trial_expired: true,
required_plan: "team",
required_team_quantity: 3,
active_subscription_plan: "team",
active_subscription_quantity: 1,
})
).toBe("trial_expired");
});
it("returns null when the organization is compliant", () => {
expect(
getOrganizationUpgradeBlockReason({
is_trial_expired: true,
required_plan: "solo",
required_team_quantity: 1,
active_subscription_plan: "annual",
active_subscription_quantity: 1,
})
).toBe(null);
});
});

View file

@ -14,6 +14,7 @@ import { useOrganization } from "./organization";
export type BillingPlan = "solo" | "team" | "annual";
export type CheckoutPlan = "solo" | "team" | "founder";
export type UpgradeBlockReason = "no_plan" | "trial_expired";
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
if (!plan) return "solo";
@ -57,6 +58,32 @@ const hasCompliantPaidPlan = (input: {
return active_subscription_quantity >= required_team_quantity;
};
export const getOrganizationUpgradeBlockReason = (input: {
is_trial_expired: boolean;
required_plan: "solo" | "team";
required_team_quantity: number;
active_subscription_plan: "solo" | "team" | "annual" | null;
active_subscription_quantity: number;
}): UpgradeBlockReason | null => {
if (!input.active_subscription_plan) {
return "no_plan";
}
if (
input.is_trial_expired &&
!hasCompliantPaidPlan({
required_plan: input.required_plan,
required_team_quantity: input.required_team_quantity,
active_subscription_plan: input.active_subscription_plan,
active_subscription_quantity: input.active_subscription_quantity,
})
) {
return "trial_expired";
}
return null;
};
/**
* Hook to get user's subscription status from Supabase
* Uses RPC function to access stripe data without modifying stripe schema
@ -112,16 +139,15 @@ export const useIsPastTrial = () => {
return false;
}
if (!organizationData.is_trial_expired) {
return false;
}
return !hasCompliantPaidPlan({
required_plan: organizationData.required_plan,
required_team_quantity: organizationData.required_team_quantity,
active_subscription_plan: organizationData.active_subscription_plan,
active_subscription_quantity: organizationData.active_subscription_quantity,
});
return (
getOrganizationUpgradeBlockReason({
is_trial_expired: organizationData.is_trial_expired,
required_plan: organizationData.required_plan,
required_team_quantity: organizationData.required_team_quantity,
active_subscription_plan: organizationData.active_subscription_plan,
active_subscription_quantity: organizationData.active_subscription_quantity,
}) === "trial_expired"
);
}, [organizationData]);
return { isPastTrial, isLoading };

View file

@ -9,6 +9,7 @@ import type {
TaskStatus,
} from "@xtablo/shared-types";
import { supabase } from "../lib/supabase";
import { useMaybeUpgradeBlock } from "../contexts/UpgradeBlockContext";
type CreateEtapeInput = {
tabloId: string;
@ -165,9 +166,14 @@ export const useTask = (taskId: string | undefined) => {
// Create new task
export const useCreateTask = () => {
const queryClient = useQueryClient();
const upgradeBlock = useMaybeUpgradeBlock();
return useMutation({
mutationFn: async (task: KanbanTaskInsert) => {
if (upgradeBlock?.reason === "no_plan") {
throw new Error("An active subscription is required");
}
const { data, error } = await supabase
.from("tasks")
.insert({
@ -217,9 +223,14 @@ export const useCreateTask = () => {
// Update task
export const useUpdateTask = () => {
const queryClient = useQueryClient();
const upgradeBlock = useMaybeUpgradeBlock();
return useMutation({
mutationFn: async ({ id, ...updates }: KanbanTaskUpdate) => {
if (upgradeBlock?.reason === "no_plan") {
throw new Error("An active subscription is required");
}
const { data, error } = await supabase
.from("tasks")
.update(updates)

View file

@ -0,0 +1,76 @@
# No-Plan Subscription Gate Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Block app interaction when an authenticated organization has no active plan, while directing the user to checkout for Solo, Team, or Founder.
**Architecture:** Reuse the existing global upgrade blocker in the frontend, extending it to handle a `no_plan` state in addition to expired-trial blocking. Reinforce the UI gate with backend mutation guards for API-driven writes and frontend mutation guards for direct-Supabase task writes.
**Tech Stack:** React, TanStack Query, Hono, Supabase, Vitest
---
## Chunk 1: Frontend global blocker
### Task 1: Model the no-plan blocked state
**Files:**
- Modify: `apps/main/src/contexts/UpgradeBlockContext.tsx`
- Modify: `apps/main/src/hooks/stripe.ts`
- Test: `apps/main/src/components/TrialUpsellModal.test.ts`
- [ ] Add a failing test or helper assertions for the new no-plan blocking condition.
- [ ] Implement a shared blocker state that distinguishes `trial_expired` from `no_plan`.
- [ ] Verify blocker logic stays non-blocking for paid organizations.
### Task 2: Update the blocking modal
**Files:**
- Modify: `apps/main/src/components/UpgradePanel.tsx`
- Test: `apps/main/src/components/TrialUpsellModal.test.ts`
- [ ] Add a failing test for the no-plan upsell content if the component is already covered, otherwise cover the extracted logic.
- [ ] Update the blocker UI copy to explain that a subscription is required and expose Solo, Team, and Founder checkout actions.
- [ ] Keep sign-out accessible and preserve the billing-owner restriction messaging.
### Task 3: Prevent task creation/update from bypassing the blocker
**Files:**
- Modify: `apps/main/src/hooks/tasks.ts`
- Test: `apps/main/src/pages/tablo-details.layout.test.tsx` or a new focused hook/component test if needed
- [ ] Add a failing test for task mutation attempts while blocked for `no_plan`.
- [ ] Short-circuit direct-Supabase task mutations with a user-facing error when the app is in the blocked state.
- [ ] Re-run targeted frontend tests.
## Chunk 2: Backend mutation guards
### Task 4: Add a no-plan middleware
**Files:**
- Modify: `apps/api/src/middlewares/middleware.ts`
- Modify: `apps/api/src/helpers/billing.ts`
- Test: `apps/api/src/__tests__/middlewares/middlewares.test.ts`
- [ ] Add failing middleware tests for rejecting users whose organization has no active plan.
- [ ] Implement a middleware that resolves organization billing state and rejects interactive writes when `active_subscription_plan` is missing.
- [ ] Ensure Stripe checkout routes remain exempt.
### Task 5: Apply the middleware to write routes
**Files:**
- Modify: `apps/api/src/routers/tablo.ts`
- Modify: `apps/api/src/routers/tablo_data.ts`
- Modify: `apps/api/src/routers/user.ts`
- Test: `apps/api/src/__tests__/routes/tablo_data.test.ts`
- Test: `apps/api/src/__tests__/routes/tablo.test.ts`
- [ ] Add failing tests for no-plan users being blocked from key writes like tablo creation and file deletion/upload.
- [ ] Apply the no-plan middleware to interactive write endpoints while leaving read-only and billing endpoints alone.
- [ ] Verify existing paid-plan and temporary-user behaviors still pass.
## Chunk 3: Verification
### Task 6: Run focused and broad verification
**Files:**
- No code changes
- [ ] Run targeted frontend tests for the blocker and signup/checkout paths.
- [ ] Run targeted API middleware/route tests for no-plan rejection.
- [ ] Run `pnpm --filter @xtablo/api test` and any focused `@xtablo/main` test suites touched by this work.
- [ ] Summarize any remaining warnings separately from pass/fail results.