Merge pull request #66 from artslidd/develop
Add founder checkout flow and tablo overview layout updates
This commit is contained in:
commit
c03fb89139
12 changed files with 437 additions and 67 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
40
apps/main/src/hooks/stripe.test.ts
Normal file
40
apps/main/src/hooks/stripe.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in a new issue