From cfff0cbbe76ca91ae630ccd86ad7a9df65c14dc3 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Sat, 6 Dec 2025 21:42:36 -0300 Subject: [PATCH 001/102] :sparkles: feat: Prompt guest users to log in when attempting to download or export animations. --- .../animation/animation-download-menu.tsx | 21 +- .../animation/enhanced-share-dialog.tsx | 268 ++++++++++-------- 2 files changed, 164 insertions(+), 125 deletions(-) diff --git a/src/features/animation/animation-download-menu.tsx b/src/features/animation/animation-download-menu.tsx index 47c2d43..1802bbf 100644 --- a/src/features/animation/animation-download-menu.tsx +++ b/src/features/animation/animation-download-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -14,6 +14,7 @@ import { Tooltip } from "@/components/ui/tooltip"; import { useAnimationStore, useEditorStore, useUserStore } from "@/app/store"; import { calculateTotalDuration } from "@/features/animation"; import { trackAnimationEvent } from "@/features/animation/analytics"; +import { LoginDialog } from "@/features/login"; import { ExportOverlay } from "./share-dialog/export-overlay"; import { GifExporter } from "./gif-exporter"; @@ -37,6 +38,13 @@ export const AnimationDownloadMenu = () => { const [cancelExport, setCancelExport] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [currentExportFormat, setCurrentExportFormat] = useState<"mp4" | "webm" | "gif">("mp4"); + const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); + + useEffect(() => { + if (user && isLoginDialogOpen) { + setIsLoginDialogOpen(false); + } + }, [user, isLoginDialogOpen]); const serializedSlides = useMemo( () => @@ -57,6 +65,15 @@ export const AnimationDownloadMenu = () => { } const handleExport = (format: "mp4" | "webm" | "gif" = "mp4") => { + if (!user) { + setDropdownOpen(false); + setIsLoginDialogOpen(true); + trackAnimationEvent("guest_upgrade_prompted", user, { + trigger: "download_animation", + }); + return; + } + if (serializedSlides.length < 2) { toast.error("Add at least two slides to export."); return; @@ -131,6 +148,8 @@ export const AnimationDownloadMenu = () => { return ( <> + + diff --git a/src/features/animation/enhanced-share-dialog.tsx b/src/features/animation/enhanced-share-dialog.tsx index 64507e9..a65bf37 100644 --- a/src/features/animation/enhanced-share-dialog.tsx +++ b/src/features/animation/enhanced-share-dialog.tsx @@ -38,6 +38,7 @@ import { ShareTabPlatforms } from "./share-dialog/tabs/share-tab-platforms"; import { ShareTabSocial } from "./share-dialog/tabs/share-tab-social"; import { ShareTabPreview } from "./share-dialog/tabs/share-tab-preview"; import { ExportOverlay } from "./share-dialog/export-overlay"; +import { LoginDialog } from "@/features/login"; const shareFormSchema = z.object({ title: z.string().min(1, "Title is required").max(100), @@ -79,6 +80,7 @@ export const EnhancedAnimationShareDialog = () => { const previewTrackedRef = useRef(false); const loadTimestampRef = useRef(null); const firstExportTrackedRef = useRef(false); + const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); // Export state const [isExporting, setIsExporting] = useState(false); @@ -304,6 +306,12 @@ export const EnhancedAnimationShareDialog = () => { } }; + useEffect(() => { + if (user && isLoginDialogOpen) { + setIsLoginDialogOpen(false); + } + }, [user, isLoginDialogOpen]); + const handlePlatformCopy = (platform: "hashnode" | "medium" | "devto" | "notion") => { if (!shareUrl) return; @@ -408,6 +416,14 @@ export const EnhancedAnimationShareDialog = () => { }, [copyEmbed, embedHeight, embedWidth, form.formState.isDirty, generateShareUrl, shareUrl, user]); const handleExport = () => { + if (!user) { + setIsLoginDialogOpen(true); + trackAnimationEvent("guest_upgrade_prompted", user, { + trigger: "download_animation_share_modal", + }); + return; + } + if (serializedSlides.length < 2) { toast.error("Add at least two slides to export."); return; @@ -476,129 +492,133 @@ export const EnhancedAnimationShareDialog = () => { }; return ( - - - - - - - - - - Share animation - - Generate links, embeds, and videos to share your code animation anywhere. - - - - {isExporting && ( - { - console.error(err); - setIsExporting(false); - setCancelExport(false); - trackAnimationEvent("export_failed", user, { - error_type: err?.message || "unknown", - format: animationSettings.exportFormat, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - transition_type: animationSettings.transitionType, - progress_percent: Math.round(exportProgress * 100), - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - }); - toast.error("Export failed. Please try again."); - }} - onCancelled={() => { - setIsExporting(false); - setExportProgress(0); - setCancelExport(false); - toast("Export canceled."); - }} - /> - )} - -
- - - handleTabChange(value as (typeof TAB_KEYS)[number])}> - - Public link - Platforms - Social - Embed code - Preview - - - - void handleCopyUrl()} - isCopying={isCopyingLink} - /> - - - - void handleEmbedCopy()} - previewUrl={shareUrl ? shareUrl.replace("/animate/shared/", "/animate/embed/") : ""} - ogPreviewUrl={ogPreviewUrl} - /> - - - - - - - - - - - - - - -
-
-
+ <> + + + + + + + + + + + + Share animation + + Generate links, embeds, and videos to share your code animation anywhere. + + + + {isExporting && ( + { + console.error(err); + setIsExporting(false); + setCancelExport(false); + trackAnimationEvent("export_failed", user, { + error_type: err?.message || "unknown", + format: animationSettings.exportFormat, + resolution: animationSettings.resolution, + slide_count: serializedSlides.length, + transition_type: animationSettings.transitionType, + progress_percent: Math.round(exportProgress * 100), + export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", + transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", + }); + toast.error("Export failed. Please try again."); + }} + onCancelled={() => { + setIsExporting(false); + setExportProgress(0); + setCancelExport(false); + toast("Export canceled."); + }} + /> + )} + +
+ + + handleTabChange(value as (typeof TAB_KEYS)[number])}> + + Public link + Platforms + Social + Embed code + Preview + + + + void handleCopyUrl()} + isCopying={isCopyingLink} + /> + + + + void handleEmbedCopy()} + previewUrl={shareUrl ? shareUrl.replace("/animate/shared/", "/animate/embed/") : ""} + ogPreviewUrl={ogPreviewUrl} + /> + + + + + + + + + + + + + + +
+
+
+ ); }; From 42ac43785fb07c68fff290199c18f52a7e0094ce Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Sat, 6 Dec 2025 21:56:51 -0300 Subject: [PATCH 002/102] :sparkles: feat: Implement watermarks for exported images and OG images, and add an exporting state indicator. --- src/app/api/og-image/route.tsx | 29 ++++++++ src/features/code-editor/editor.tsx | 23 +++++- src/features/export/export-menu.tsx | 107 ++++++++++++++++------------ 3 files changed, 113 insertions(+), 46 deletions(-) diff --git a/src/app/api/og-image/route.tsx b/src/app/api/og-image/route.tsx index 48814c4..d0def40 100644 --- a/src/app/api/og-image/route.tsx +++ b/src/app/api/og-image/route.tsx @@ -1,4 +1,5 @@ import { ImageResponse } from "next/og"; +import { Logo } from "@/components/ui/logo"; export const runtime = "edge"; export const dynamic = "force-dynamic"; @@ -138,6 +139,7 @@ export async function GET(request: Request) { height: "100%", background, fontFamily: '"Inter", sans-serif', + position: "relative", }} >
+ + {/* Watermark */} +
+ jollycode.dev +
+ +
+
), { diff --git a/src/features/code-editor/editor.tsx b/src/features/code-editor/editor.tsx index 13bce53..69ed601 100644 --- a/src/features/code-editor/editor.tsx +++ b/src/features/code-editor/editor.tsx @@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; import { LoginDialog } from "@/features/login"; +import { Logo } from "@/components/ui/logo"; import { createSnippet, removeSnippet, @@ -425,11 +426,13 @@ export const Editor = forwardRef( S.background(), showBackground && !presentational && - themes[backgroundTheme!].background + themes[backgroundTheme!].background, + "relative overflow-hidden group/export" )} style={{ padding }} ref={ref} id={currentEditor?.id || "editor"} + data-exporting="false" > {!user ? ( @@ -603,6 +606,24 @@ export const Editor = forwardRef( + + {/* Watermark (included in exports) */} +
+ + jollycode.dev + + +
{ return; } + freshEditor.dataset.exporting = "true"; + const imageBlob = await toBlob(freshEditor!, { pixelRatio: 2, }); @@ -102,6 +104,12 @@ export const ExportMenu = ({ animationMode }: ExportMenuProps = {}) => { analytics.track("copy_image"); } catch (error) { toast.error("Something went wrong! Please try again."); + } finally { + const currentId = currentEditorState?.id || "editor"; + const freshEditor = document.getElementById(currentId); + if (freshEditor) { + freshEditor.dataset.exporting = "false"; + } } } @@ -136,58 +144,67 @@ export const ExportMenu = ({ animationMode }: ExportMenuProps = {}) => { let imageURL, fileName; const currentId = currentEditorState?.id || "editor"; const freshEditor = document.getElementById(currentId); + if (freshEditor) { + freshEditor.dataset.exporting = "true"; + } + + try { + switch (format) { + case "PNG": + imageURL = await toPng(freshEditor!, { + pixelRatio: 2, + }); + fileName = `${name}.png`; + break; + + case "JPG": + imageURL = await toJpeg(freshEditor!, { + pixelRatio: 2, + backgroundColor: "#fff", + }); + fileName = `${name}.jpg`; + break; - switch (format) { - case "PNG": - imageURL = await toPng(freshEditor!, { - pixelRatio: 2, - }); - fileName = `${name}.png`; - break; - - case "JPG": - imageURL = await toJpeg(freshEditor!, { - pixelRatio: 2, - backgroundColor: "#fff", - }); - fileName = `${name}.jpg`; - break; - - case "SVG": - // Convert to PNG first (which works reliably), then wrap in SVG - // This avoids the known issues with html-to-image's toSvg function - const pngDataUrl = await toPng(freshEditor!, { - pixelRatio: 2, - }); - - // Get the dimensions of the element - const rect = freshEditor!.getBoundingClientRect(); - const width = rect.width * 2; // Account for pixelRatio - const height = rect.height * 2; - - // Create an SVG that embeds the PNG - const svgContent = ` + case "SVG": + // Convert to PNG first (which works reliably), then wrap in SVG + // This avoids the known issues with html-to-image's toSvg function + const pngDataUrl = await toPng(freshEditor!, { + pixelRatio: 2, + }); + + // Get the dimensions of the element + const rect = freshEditor!.getBoundingClientRect(); + const width = rect.width * 2; // Account for pixelRatio + const height = rect.height * 2; + + // Create an SVG that embeds the PNG + const svgContent = ` `; - // Convert to data URL - imageURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; - fileName = `${name}.svg`; - break; + // Convert to data URL + imageURL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`; + fileName = `${name}.svg`; + break; - default: - // If the provided format is not supported, return without doing anything - return; - } + default: + // If the provided format is not supported, return without doing anything + return; + } - const link = document.createElement("a"); - // Set the link's href to the image URL - link.href = imageURL; - // Set the link's download attribute to the file name - link.download = fileName; - // Programmatically trigger a click on the link element to initiate the file download - link.click(); + const link = document.createElement("a"); + // Set the link's href to the image URL + link.href = imageURL; + // Set the link's download attribute to the file name + link.download = fileName; + // Programmatically trigger a click on the link element to initiate the file download + link.click(); + } finally { + if (freshEditor) { + freshEditor.dataset.exporting = "false"; + } + } } /** From f7e178b3c1c4ccbc716299a4d39dfbe693609da4 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Sun, 7 Dec 2025 15:30:52 -0300 Subject: [PATCH 003/102] :sparkles: feat: Implement Stripe integration for subscriptions and refactor usage limit checks to use Supabase RPC functions. --- .env.example | 16 +- AGENTS.md | 34 +- package.json | 2 + pnpm-lock.yaml | 35 ++ src/actions/animations/create-animation.ts | 69 ++-- src/actions/index.ts | 3 +- src/actions/snippets/create-snippet.ts | 47 ++- src/actions/stripe/checkout.ts | 69 ++++ src/app/api/checkout/route.ts | 102 +++++ src/app/api/customer-portal/route.ts | 50 +++ src/app/api/webhooks/stripe/route.ts | 168 +++++++++ src/app/manifest.ts | 32 +- src/components/ui/add-slide-card/index.tsx | 4 +- src/components/ui/upgrade-dialog/index.tsx | 301 +++++++++++---- src/components/usage-stats-widget/index.tsx | 14 +- src/features/animation/timeline.tsx | 5 + .../animations/hooks/use-animation-limits.ts | 12 +- src/features/animations/queries.ts | 17 +- src/features/code-editor/editor.tsx | 8 - src/features/code-editor/index.tsx | 2 +- src/features/snippets/queries.ts | 17 +- src/lib/config/plans.ts | 161 +++++++- src/lib/services/stripe.ts | 208 ++++++++++ src/lib/services/usage-limits.ts | 25 +- src/lib/stripe-client.ts | 21 ++ ...51207103258_add_usage_limits_and_plans.sql | 357 ++++++++++++++++++ 26 files changed, 1574 insertions(+), 205 deletions(-) create mode 100644 src/actions/stripe/checkout.ts create mode 100644 src/app/api/checkout/route.ts create mode 100644 src/app/api/customer-portal/route.ts create mode 100644 src/app/api/webhooks/stripe/route.ts create mode 100644 src/lib/services/stripe.ts create mode 100644 src/lib/stripe-client.ts create mode 100644 supabase/migrations/20251207103258_add_usage_limits_and_plans.sql diff --git a/.env.example b/.env.example index 52f75a4..9d4b4ab 100644 --- a/.env.example +++ b/.env.example @@ -31,4 +31,18 @@ NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL=false POSTHOG_API_KEY= #SECURITY -ARCJET_KEY= \ No newline at end of file +ARCJET_KEY= + +#STRIPE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Stripe Price IDs (from dashboard) +NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID=price_... + +# App URL for redirects +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/AGENTS.md b/AGENTS.md index d4f97fa..7fa1352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,35 @@ - PRs should describe intent, note any env vars touched (e.g., `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, Sentry/PostHog keys), and link related issues. Include screenshots or short clips for UI changes and mention manual checks run. ## Security & Configuration Tips -- Store secrets in `.env.local`; never commit keys. Required keys include Supabase (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`) and Sentry/PostHog credentials where applicable. +- Store secrets in `.env.local`; never commit keys. Required keys include Supabase (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`), Stripe (`STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, price IDs), and Sentry/PostHog credentials where applicable. - When developing analytics/Sentry changes, guard them behind environment checks to keep local runs noise-free. +- Stripe webhook endpoint: `/api/webhooks/stripe` - must be configured in Stripe Dashboard with events: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `checkout.session.completed`. ## Usage Limits & Plans -- Free plan caps: 10 snippets, 10 animations, 5 slides per animation. Pro plan removes limits. -- Usage counts live in `profiles` and `usage_limits` with helper RPCs (`check_*_limit`, `increment_*`, `decrement_*`). -- Use `src/lib/services/usage-limits.ts` + `src/features/user/queries.ts` for limit checks and usage fetch/invalidation. -- Surface upgrade prompts via `UpgradeDialog` and show current usage with `UsageStatsWidget`. +### Plan Tiers +- **Free**: 0 saved snippets/animations, 3 slides per animation, 3 public shares +- **Started** ($5/mo or $3/mo yearly): 50 snippets, 50 animations, 10 slides per animation, 10 folders, 50 video exports, 50 public shares +- **Pro** ($9/mo or $7/mo yearly): Unlimited everything + watermark removal + priority support + +### Implementation Details +- Plan configuration lives in `src/lib/config/plans.ts` with helper functions (`getPlanConfig`, `isLimitReached`, `getUsagePercentage`, etc.) +- Database schema in migration `supabase/migrations/20251207103258_add_usage_limits_and_plans.sql`: + - `profiles` table has plan columns: `plan` (enum), `plan_updated_at`, usage counters, and Stripe fields + - PostgreSQL RPC functions handle atomic limit checks and counter updates +- Usage tracking service: `src/lib/services/usage-limits.ts` provides `checkSnippetLimit`, `checkAnimationLimit`, `incrementUsageCount`, `decrementUsageCount`, `getUserUsage` +- Server actions enforce limits: `src/actions/snippets/create-snippet.ts` and `src/actions/animations/create-animation.ts` check RPCs before saving +- React Query hooks: `src/features/user/queries.ts` exports `useUserUsage()` and `useUserPlan()` for client-side usage display +- UI components: + - `src/components/usage-stats-widget` shows current usage with progress bars and upgrade CTA + - `src/components/ui/upgrade-dialog` displays plan comparison and pricing for upgrades +- Animation store (`src/app/store/animation-store.ts`) enforces slide limits via `addSlide({ maxSlides, onLimit })` parameter + +### Stripe Integration +- Service layer: `src/lib/services/stripe.ts` handles customer management, checkout sessions, subscriptions, and webhooks +- Client-side: `src/lib/stripe-client.ts` loads Stripe.js; `src/actions/stripe/checkout.ts` provides `createCheckoutSession()` and `createPortalSession()` server actions +- API endpoints: + - `/api/checkout` - Creates Stripe checkout session for plan upgrades + - `/api/webhooks/stripe` - Handles subscription lifecycle events and updates user plans + - `/api/customer-portal` - Creates Stripe customer portal session for subscription management +- Webhooks automatically update `profiles` table when subscriptions are created, updated, or canceled +- Price IDs must be configured in environment variables for both monthly and yearly billing for Started and Pro plans diff --git a/package.json b/package.json index 8562de7..2f40ef7 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^8.0.0", + "@stripe/stripe-js": "^8.5.3", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.84.0", "@tanstack/react-query": "^5.90.10", @@ -79,6 +80,7 @@ "remixicon": "^3.5.0", "schema-dts": "^1.1.5", "sonner": "^1.0.3", + "stripe": "^20.0.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59c21c..cbf75c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@sentry/nextjs': specifier: ^8.0.0 version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0) + '@stripe/stripe-js': + specifier: ^8.5.3 + version: 8.5.3 '@supabase/ssr': specifier: ^0.7.0 version: 0.7.0(@supabase/supabase-js@2.84.0) @@ -212,6 +215,9 @@ importers: sonner: specifier: ^1.0.3 version: 1.7.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + stripe: + specifier: ^20.0.0 + version: 20.0.0(@types/node@24.10.1) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -1835,6 +1841,10 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/stripe-js@8.5.3': + resolution: {integrity: sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==} + engines: {node: '>=12.16'} + '@supabase/auth-js@2.84.0': resolution: {integrity: sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==} engines: {node: '>=20.0.0'} @@ -3995,6 +4005,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4329,6 +4343,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@20.0.0: + resolution: {integrity: sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6426,6 +6449,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stripe/stripe-js@8.5.3': {} + '@supabase/auth-js@2.84.0': dependencies: tslib: 2.8.1 @@ -8889,6 +8914,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -9321,6 +9350,12 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@20.0.0(@types/node@24.10.1): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 24.10.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index d1318dd..6470800 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -7,12 +7,6 @@ import { success, error, type ActionResult } from '@/actions/utils/action-result import { insertAnimation } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' -import { - checkAnimationLimit, - checkSlideLimit, - incrementUsageCount, - type UsageLimitCheck, -} from '@/lib/services/usage-limits' export type CreateAnimationInput = { id: string @@ -22,14 +16,9 @@ export type CreateAnimationInput = { url?: string | null } -export type CreateAnimationResult = { - animation: Animation - usage: UsageLimitCheck -} - export async function createAnimation( input: CreateAnimationInput -): Promise> { +): Promise> { try { const { id, title, slides, settings, url } = input @@ -43,15 +32,45 @@ export async function createAnimation( const { user, supabase } = await requireAuth() - const limit = await checkAnimationLimit(supabase, user.id) - if (!limit.canSave) { - const maxText = limit.max ?? 'unlimited' - return error(`You've reached the free plan limit (${limit.current}/${maxText} animations). Upgrade to Pro for unlimited animations!`) + // Check animation save limit + const { data: animationLimitCheck, error: animationLimitError } = await supabase.rpc('check_animation_limit', { + p_user_id: user.id + }) + + if (animationLimitError) { + console.error('Error checking animation limit:', animationLimitError) + return error('Failed to verify save limit. Please try again.') } - const slideLimit = checkSlideLimit(slides.length, limit.plan) - if (!slideLimit.canSave) { - return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) + if (!animationLimitCheck.canSave) { + const plan = animationLimitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') + } + return error('Animation limit reached. Please upgrade your plan.') + } + + // Check slide count limit + const { data: slideLimitCheck, error: slideLimitError } = await supabase.rpc('check_slide_limit', { + p_user_id: user.id, + p_slide_count: slides.length + }) + + if (slideLimitError) { + console.error('Error checking slide limit:', slideLimitError) + return error('Failed to verify slide limit. Please try again.') + } + + if (!slideLimitCheck.canAdd) { + const plan = slideLimitCheck.plan + if (plan === 'free') { + return error('Free users can add up to 3 slides. Upgrade to Started for 10 slides per animation!') + } else if (plan === 'started') { + return error('Started users can add up to 10 slides. Upgrade to Pro for unlimited slides!') + } + return error('Slide limit exceeded. Please upgrade your plan.') } const data = await insertAnimation({ @@ -68,12 +87,20 @@ export async function createAnimation( return error('Failed to create animation') } - const usageResult = await incrementUsageCount(supabase, user.id, 'animations') + // Increment animation count after successful creation + const { error: incrementError } = await supabase.rpc('increment_animation_count', { + p_user_id: user.id + }) + + if (incrementError) { + console.error('Error incrementing animation count:', incrementError) + // Don't fail the operation, but log it + } revalidatePath('/animate') revalidatePath('/animations') - return success({ animation: data[0] as Animation, usage: usageResult }) + return success(data[0] as Animation) } catch (err) { console.error('Error creating animation:', err) diff --git a/src/actions/index.ts b/src/actions/index.ts index bf49e7d..e0e9ed8 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,7 +1,7 @@ // Snippets Actions export { getSnippets } from './snippets/get-snippets' export { getSnippetById } from './snippets/get-snippet-by-id' -export { createSnippet, type CreateSnippetInput, type CreateSnippetResult } from './snippets/create-snippet' +export { createSnippet, type CreateSnippetInput } from './snippets/create-snippet' export { updateSnippet, type UpdateSnippetInput } from './snippets/update-snippet' export { deleteSnippet } from './snippets/delete-snippet' @@ -18,7 +18,6 @@ export { getAnimationById } from './animations/get-animation-by-id' export { createAnimation, type CreateAnimationInput, - type CreateAnimationResult, } from './animations/create-animation' export { updateAnimation, type UpdateAnimationInput } from './animations/update-animation' export { deleteAnimation } from './animations/delete-animation' diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index 467d6e7..e28158b 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -5,12 +5,6 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertSnippet } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' -import { - checkSnippetLimit, - incrementUsageCount, - type UsageLimitCheck, -} from '@/lib/services/usage-limits' - export type CreateSnippetInput = { id: string title?: string @@ -19,11 +13,6 @@ export type CreateSnippetInput = { url?: string } -export type CreateSnippetResult = { - snippet: Snippet - usage: UsageLimitCheck -} - /** * Server Action: Create a new snippet * @@ -32,7 +21,7 @@ export type CreateSnippetResult = { */ export async function createSnippet( input: CreateSnippetInput -): Promise> { +): Promise> { try { const { id, title, code, language, url } = input @@ -43,10 +32,24 @@ export async function createSnippet( const { user, supabase } = await requireAuth() - const limit = await checkSnippetLimit(supabase, user.id) - if (!limit.canSave) { - const maxText = limit.max ?? 'unlimited' - return error(`You've reached the free plan limit (${limit.current}/${maxText} snippets). Upgrade to Pro for unlimited snippets!`) + // Check snippet limit before allowing save + const { data: limitCheck, error: limitError } = await supabase.rpc('check_snippet_limit', { + p_user_id: user.id + }) + + if (limitError) { + console.error('Error checking snippet limit:', limitError) + return error('Failed to verify save limit. Please try again.') + } + + if (!limitCheck.canSave) { + const plan = limitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') + } + return error('Snippet limit reached. Please upgrade your plan.') } const data = await insertSnippet({ @@ -63,13 +66,21 @@ export async function createSnippet( return error('Failed to create snippet') } - const updatedUsage = await incrementUsageCount(supabase, user.id, 'snippets') + // Increment snippet count after successful creation + const { error: incrementError } = await supabase.rpc('increment_snippet_count', { + p_user_id: user.id + }) + + if (incrementError) { + console.error('Error incrementing snippet count:', incrementError) + // Don't fail the operation, but log it + } // Revalidate the snippets list revalidatePath('/snippets') revalidatePath('/') - return success({ snippet: data[0], usage: updatedUsage }) + return success(data[0]) } catch (err) { console.error('Error creating snippet:', err) diff --git a/src/actions/stripe/checkout.ts b/src/actions/stripe/checkout.ts new file mode 100644 index 0000000..4aeb17d --- /dev/null +++ b/src/actions/stripe/checkout.ts @@ -0,0 +1,69 @@ +'use server'; + +import type { PlanId } from '@/lib/config/plans'; + +type CheckoutResponse = { + url?: string; + error?: string; +}; + +/** + * Create a Stripe checkout session + */ +export async function createCheckoutSession({ + plan, + interval, +}: { + plan: PlanId; + interval: 'monthly' | 'yearly'; +}): Promise { + try { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + + const response = await fetch(`${appUrl}/api/checkout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ plan, interval }), + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || 'Failed to create checkout session' }; + } + + return { url: data.url }; + } catch (error) { + console.error('Checkout action error:', error); + return { error: 'Failed to create checkout session' }; + } +} + +/** + * Create a Stripe customer portal session + */ +export async function createPortalSession(): Promise { + try { + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + + const response = await fetch(`${appUrl}/api/customer-portal`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || 'Failed to create portal session' }; + } + + return { url: data.url }; + } catch (error) { + console.error('Portal action error:', error); + return { error: 'Failed to create portal session' }; + } +} diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..3a25f44 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { + getOrCreateStripeCustomer, + createCheckoutSession, + getStripePriceId, +} from '@/lib/services/stripe'; +import type { PlanId } from '@/lib/config/plans'; + +export const dynamic = 'force-dynamic'; + +type CheckoutRequestBody = { + plan: PlanId; + interval: 'monthly' | 'yearly'; +}; + +export async function POST(request: NextRequest) { + try { + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Parse request body + const body: CheckoutRequestBody = await request.json(); + const { plan, interval } = body; + + // Validate plan + if (plan === 'free') { + return NextResponse.json( + { error: 'Cannot create checkout session for free plan' }, + { status: 400 } + ); + } + + if (!['started', 'pro'].includes(plan)) { + return NextResponse.json({ error: 'Invalid plan' }, { status: 400 }); + } + + if (!['monthly', 'yearly'].includes(interval)) { + return NextResponse.json({ error: 'Invalid billing interval' }, { status: 400 }); + } + + // Get user profile + const { data: profile } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single(); + + if (!user.email) { + return NextResponse.json({ error: 'User email not found' }, { status: 400 }); + } + + // Get or create Stripe customer + const customer = await getOrCreateStripeCustomer({ + userId: user.id, + email: user.email, + name: (profile as any)?.username || undefined, + }); + + // Update profile with Stripe customer ID if not already set + const existingCustomerId = (profile as any)?.stripe_customer_id; + if (!existingCustomerId) { + await supabase + .from('profiles') + .update({ stripe_customer_id: customer.id } as any) + .eq('id', user.id); + } + + // Get price ID for the plan + const priceId = getStripePriceId(plan, interval); + + // Create checkout session + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + const session = await createCheckoutSession({ + customerId: customer.id, + priceId, + successUrl: `${appUrl}/?checkout=success`, + cancelUrl: `${appUrl}/?checkout=canceled`, + metadata: { + userId: user.id, + plan, + interval, + }, + }); + + return NextResponse.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error('Checkout error:', error); + return NextResponse.json( + { error: 'Failed to create checkout session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/customer-portal/route.ts b/src/app/api/customer-portal/route.ts new file mode 100644 index 0000000..4000b11 --- /dev/null +++ b/src/app/api/customer-portal/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { createCustomerPortalSession } from '@/lib/services/stripe'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get user's Stripe customer ID + const { data: profile } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single(); + + const stripeCustomerId = (profile as any)?.stripe_customer_id; + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No active subscription found' }, + { status: 400 } + ); + } + + // Create customer portal session + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + const portalSession = await createCustomerPortalSession({ + customerId: stripeCustomerId, + returnUrl: `${appUrl}/`, + }); + + return NextResponse.json({ url: portalSession.url }); + } catch (error) { + console.error('Customer portal error:', error); + return NextResponse.json( + { error: 'Failed to create customer portal session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..9d817b7 --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { constructWebhookEvent } from '@/lib/services/stripe'; +import type { PlanId } from '@/lib/config/plans'; +import Stripe from 'stripe'; + +export const dynamic = 'force-dynamic'; + +// Map Stripe price IDs to plan IDs +function getPlanIdFromPriceId(priceId: string): PlanId | null { + const priceIdMap: Record = { + [process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID || '']: 'started', + [process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID || '']: 'started', + [process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID || '']: 'pro', + [process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID || '']: 'pro', + }; + + return priceIdMap[priceId] || null; +} + +// Handle subscription created or updated +async function handleSubscriptionChange(subscription: Stripe.Subscription) { + const supabase = await createClient(); + + const userId = subscription.metadata.userId; + if (!userId) { + console.error('No userId in subscription metadata'); + return; + } + + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) { + console.error('No price ID in subscription'); + return; + } + + const planId = getPlanIdFromPriceId(priceId); + if (!planId) { + console.error('Could not determine plan from price ID:', priceId); + return; + } + + const updateData: any = { + plan: planId, + plan_updated_at: new Date().toISOString(), + stripe_customer_id: subscription.customer as string, + stripe_subscription_id: subscription.id, + stripe_subscription_status: subscription.status, + subscription_period_end: new Date((subscription as any).current_period_end * 1000).toISOString(), + subscription_cancel_at_period_end: (subscription as any).cancel_at_period_end, + stripe_price_id: priceId, + }; + + const { error } = await supabase + .from('profiles') + .update(updateData) + .eq('id', userId); + + if (error) { + console.error('Error updating profile:', error); + throw error; + } + + console.log(`Updated user ${userId} to plan ${planId}`); +} + +// Handle subscription deleted (downgrade to free) +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const supabase = await createClient(); + + const userId = subscription.metadata.userId; + if (!userId) { + console.error('No userId in subscription metadata'); + return; + } + + const { error } = await supabase + .from('profiles') + .update({ + plan: 'free', + plan_updated_at: new Date().toISOString(), + stripe_subscription_status: 'canceled', + subscription_cancel_at_period_end: false, + }) + .eq('id', userId); + + if (error) { + console.error('Error downgrading user to free:', error); + throw error; + } + + console.log(`Downgraded user ${userId} to free plan`); +} + +export async function POST(request: NextRequest) { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!webhookSecret) { + console.error('STRIPE_WEBHOOK_SECRET not set'); + return NextResponse.json( + { error: 'Webhook secret not configured' }, + { status: 500 } + ); + } + + try { + const body = await request.text(); + const signature = request.headers.get('stripe-signature'); + + if (!signature) { + return NextResponse.json( + { error: 'Missing stripe-signature header' }, + { status: 400 } + ); + } + + // Verify webhook signature + const event = constructWebhookEvent(body, signature, webhookSecret); + + console.log('Received Stripe webhook:', event.type); + + // Handle different event types + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': + await handleSubscriptionChange(event.data.object as Stripe.Subscription); + break; + + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + break; + + case 'checkout.session.completed': + // Session completed, subscription webhook will handle the plan update + console.log('Checkout session completed:', event.data.object.id); + break; + + case 'invoice.payment_succeeded': + // Payment succeeded, log for tracking + console.log('Invoice payment succeeded:', event.data.object.id); + break; + + case 'invoice.payment_failed': + // Payment failed, could send notification to user + console.log('Invoice payment failed:', event.data.object.id); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error('Webhook error:', error); + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Webhook handler failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts index eb52f0c..d70dba4 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -3,20 +3,20 @@ import { MetadataRoute } from "next"; import { siteConfig } from "@/lib/utils/site-config"; export default function manifest(): MetadataRoute.Manifest { - return { - name: siteConfig.title, - short_name: "Jolly Code", - description: siteConfig.description, - start_url: "/", - display: "standalone", - background_color: "#09090b", - theme_color: "#09090b", - icons: [ - { - src: "/favicon.ico", - sizes: "any", - type: "image/x-icon", - }, - ], - }; + return { + name: siteConfig.title, + short_name: "Jolly Code", + description: siteConfig.description, + start_url: "/", + display: "standalone", + background_color: "#09090b", + theme_color: "#09090b", + icons: [ + { + src: "/favicon.ico", + sizes: "any", + type: "image/x-icon", + }, + ], + }; } diff --git a/src/components/ui/add-slide-card/index.tsx b/src/components/ui/add-slide-card/index.tsx index 15df634..32f12d9 100644 --- a/src/components/ui/add-slide-card/index.tsx +++ b/src/components/ui/add-slide-card/index.tsx @@ -12,13 +12,13 @@ export const AddSlideCard = React.memo( + ))}
-

- {free.features.join(" • ")} -

-

- Limits: {formatLimit(maxCount ?? freeLimit)} {limitLabel} - {maxCount ? "" : " per plan"} -

-
-
-
{pro.name}
- Pro -
-

- {pro.features.join(" • ")} -

-
- {["Unlimited saves", "Faster workflow", "Priority support"].map((perk) => ( - + {plans.map((plan) => { + const isSelected = selectedPlan === plan.id; + const isCurrent = currentPlan === plan.id; + const limit = formatLimit(getLimitForPlan(plan, limitType)); + const canCheckout = Boolean(plan.pricing); + + return ( +
setSelectedPlan(plan.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedPlan(plan.id); + } + }} + className={cn( + "group relative flex h-full flex-col justify-between gap-3 rounded-xl border bg-card/70 p-5 text-left transition", + "hover:border-foreground/30 hover:bg-card", + isSelected && "border-primary bg-primary/5 ring-2 ring-primary/30", + )} > - {perk} - - ))} -
+
+
+ + {plan.name} + + {plan.id === "started" && ( + + Most popular + + )} + {isCurrent && Current} +
+ +
+
+ {formatPrice(plan, billingInterval)} + + {plan.pricing ? intervalLabel[billingInterval] : ""} + +
+

{plan.description}

+

+ {limit} {limitLabel} +

+
+ + + +
    + {plan.features.map((feature) => ( + {feature} + ))} +
+
+ + +
+ ); + })}
- - + + +
+
+

Selected plan

+

+ {PLANS[selectedPlan].name} • {formatPrice(PLANS[selectedPlan], billingInterval)}{" "} + {PLANS[selectedPlan].pricing ? intervalLabel[billingInterval] : ""} +

+

+ {formatLimit(getLimitForPlan(PLANS[selectedPlan], limitType))} {limitLabel} +

+
-
-
-
Current usage
-

- {currentCount}/{formatLimit(maxCount)} {limitType} -

+
-
diff --git a/src/components/usage-stats-widget/index.tsx b/src/components/usage-stats-widget/index.tsx index b13b8d2..cbc1446 100644 --- a/src/components/usage-stats-widget/index.tsx +++ b/src/components/usage-stats-widget/index.tsx @@ -14,7 +14,7 @@ type UsageStatsWidgetProps = { }; const getRatio = (current: number, max: number | null) => { - if (!max) return 0; + if (max === null || max === 0) return 0; return Math.min(current / max, 1); }; @@ -34,8 +34,8 @@ const UsageRow = ({ max: number | null; }) => { const ratio = getRatio(current, max); - const maxLabel = max ? `${max}` : "Unlimited"; - const progressWidth = max ? `${ratio * 100}%` : "100%"; + const maxLabel = max === null ? "Unlimited" : `${max}`; + const progressWidth = max === null ? "100%" : `${ratio * 100}%`; return (
@@ -85,8 +85,8 @@ export function UsageStatsWidget({

Usage

{planName} plan

- - {usage.plan === "pro" ? "Pro" : "Free"} + + {planName}
@@ -101,9 +101,9 @@ export function UsageStatsWidget({ max={usage.animations.max} /> - {usage.plan === "free" && ( + {usage.plan !== "pro" && onUpgrade && ( )} diff --git a/src/features/animation/timeline.tsx b/src/features/animation/timeline.tsx index 6288457..0b6c603 100644 --- a/src/features/animation/timeline.tsx +++ b/src/features/animation/timeline.tsx @@ -205,6 +205,11 @@ export const Timeline = ({ maxSlides, onSlideLimitReached }: TimelineProps) => { {slides.length}/{maxSlidesLabel} + {/* {hasReachedMaxSlides && ( + + You’ve hit your plan limit — click “Add Slide” to upgrade. + + )} */} diff --git a/src/features/animations/hooks/use-animation-limits.ts b/src/features/animations/hooks/use-animation-limits.ts index 221c55a..dae8c60 100644 --- a/src/features/animations/hooks/use-animation-limits.ts +++ b/src/features/animations/hooks/use-animation-limits.ts @@ -1,11 +1,10 @@ "use client"; import { useState } from "react"; -import { toast } from "sonner"; import { User } from "@supabase/supabase-js"; import { useUserUsage } from "@/features/user/queries"; -import { getPlanLimitValue } from "@/lib/config/plans"; +import { getPlanConfig } from "@/lib/config/plans"; import { trackAnimationEvent } from "@/features/animation/analytics"; type AnimationLimitType = "animations" | "slides"; @@ -33,7 +32,8 @@ export function useAnimationLimits({ user, slidesCount }: UseAnimationLimitsProp animationLimit.current >= animationLimit.max; const plan = usage?.plan ?? "free"; - const slideLimitMax = getPlanLimitValue(plan, "maxSlidesPerAnimation"); + const planConfig = getPlanConfig(plan); + const slideLimitMax = planConfig.maxSlidesPerAnimation === Infinity ? null : planConfig.maxSlidesPerAnimation; // NOTE: This checks if LIMIT IS EXCEEDED for creating new ones // (e.g. if limit is 5, and we have 5, we can't create 6th) @@ -42,9 +42,6 @@ export function useAnimationLimits({ user, slidesCount }: UseAnimationLimitsProp const checkSaveLimits = (): boolean => { // 1. Check Animation Limit if (animationLimitReached && animationLimit) { - toast.error( - `You've reached the free plan limit (${animationLimit.current}/${animationLimit.max} animations). Upgrade to Pro for unlimited animations!` - ); trackAnimationEvent("limit_reached", user, { limit_type: "animations", current: animationLimit.current, @@ -65,9 +62,6 @@ export function useAnimationLimits({ user, slidesCount }: UseAnimationLimitsProp // 2. Check Slide Limit if (slideLimitMax !== null && slidesCount > slideLimitMax) { - toast.error( - `Free users can add up to ${slideLimitMax} slides per animation. Upgrade to Pro for unlimited slides!` - ); trackAnimationEvent("limit_reached", user, { limit_type: "slides", current: slidesCount, diff --git a/src/features/animations/queries.ts b/src/features/animations/queries.ts index 6f67a90..8bfa3a6 100644 --- a/src/features/animations/queries.ts +++ b/src/features/animations/queries.ts @@ -345,22 +345,11 @@ export async function createAnimation({ return undefined; } - const animation = result.data?.animation; - const usage = result.data?.usage; + const animation = result.data; - if (usage?.max && usage.current >= usage.max) { - toast.error( - `You've reached the free plan limit (${usage.current}/${usage.max} animations). Upgrade to Pro for unlimited animations!` - ); - } else { - toast.success("Your animation was saved."); - } - - if (usage?.max && usage.current >= usage.max - 1) { - toast.message(`You're almost at your animation limit (${usage.current}/${usage.max}).`); - } + toast.success("Your animation was saved."); - return animation ? { data: animation, usage } : undefined; + return animation ? { data: animation } : undefined; } catch (error) { console.log(error); toast.error(`Failed to save the animation.`); diff --git a/src/features/code-editor/editor.tsx b/src/features/code-editor/editor.tsx index 69ed601..3b25434 100644 --- a/src/features/code-editor/editor.tsx +++ b/src/features/code-editor/editor.tsx @@ -380,14 +380,6 @@ export const Editor = forwardRef( const handleSaveSnippet = () => { if (snippetLimitReached) { - const limitLabel = - snippetLimit?.max !== null && typeof snippetLimit?.max !== "undefined" - ? `${snippetLimit.current}/${snippetLimit.max}` - : `${snippetLimit?.current ?? 0}`; - - toast.error( - `You've reached the free plan limit (${limitLabel} snippets). Upgrade to Pro for unlimited snippets!` - ); analytics.track("limit_reached", { limit_type: "snippets", current: snippetLimit?.current ?? 0, diff --git a/src/features/code-editor/index.tsx b/src/features/code-editor/index.tsx index fa8c106..a6f92dd 100644 --- a/src/features/code-editor/index.tsx +++ b/src/features/code-editor/index.tsx @@ -84,7 +84,7 @@ export const CodeEditor = ({ isLoading }: CodeEditor) => { size={{ width, height: "auto" }} > - + {tabs.map((tab) => ( = usage.max) { - toast.error( - `You've reached the free plan limit (${usage.current}/${usage.max} snippets). Upgrade to Pro for unlimited snippets!` - ); - } else { - toast.success("Your code snippet was saved."); - } - - if (usage?.max && usage.current >= usage.max - 1) { - toast.message(`You're almost at your snippet limit (${usage.current}/${usage.max}).`); - } + toast.success("Your code snippet was saved."); - return snippet ? { data: snippet, usage } : undefined; + return snippet ? { data: snippet } : undefined; } catch (error) { console.log(error); toast.error(`Failed to save the snippet.`); diff --git a/src/lib/config/plans.ts b/src/lib/config/plans.ts index 6dd41e6..596280b 100644 --- a/src/lib/config/plans.ts +++ b/src/lib/config/plans.ts @@ -1,33 +1,158 @@ -export type PlanId = "free" | "pro"; +export type PlanId = 'free' | 'started' | 'pro'; +export type BillingInterval = 'monthly' | 'yearly'; export type PlanConfig = { + id: PlanId; name: string; - maxSnippets: number | null; - maxAnimations: number | null; - maxSlidesPerAnimation: number | null; + description: string; + maxSnippets: number; + maxAnimations: number; + maxSlidesPerAnimation: number; + maxSnippetsFolder: number; + maxVideoExportCount: number; + shareAsPublicURL: number; + removeWatermark: boolean; features: string[]; + pricing: { + monthly: { + amount: number; // in cents + displayAmount: string; + stripePriceId: string; // from Stripe dashboard + }; + yearly: { + amount: number; // in cents + displayAmount: string; + stripePriceId: string; + }; + } | null; }; export const PLANS: Record = { free: { - name: "Free", - maxSnippets: 10, - maxAnimations: 10, - maxSlidesPerAnimation: 5, - features: ["Basic code snippets", "Up to 5 slides per animation"], + id: 'free', + name: 'Free', + description: 'Perfect for trying out features', + maxSnippets: 0, + maxAnimations: 0, + maxSlidesPerAnimation: 3, + maxSnippetsFolder: 0, + maxVideoExportCount: 0, + shareAsPublicURL: 3, + removeWatermark: false, + features: [ + 'Create & edit snippets', + 'Up to 3 slides per animation', + 'Export as images (PNG/JPG/SVG)', + '3 public shares', + ], + pricing: null, // Free has no pricing + }, + started: { + id: 'started', + name: 'Started', + description: 'For individuals and small teams', + maxSnippets: 50, + maxAnimations: 50, + maxSlidesPerAnimation: 10, + maxSnippetsFolder: 10, + maxVideoExportCount: 50, + shareAsPublicURL: 50, + removeWatermark: false, + features: [ + 'Save up to 50 snippets', + 'Save up to 50 animations', + 'Up to 10 slides per animation', + '10 folders/collections', + '50 video exports', + '50 public shares', + ], + pricing: { + monthly: { + amount: 500, // $5.00 + displayAmount: '$5', + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID || '', + }, + yearly: { + amount: 3600, // $36/year ($3/month) + displayAmount: '$3', + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID || '', + }, + }, }, pro: { - name: "Pro", - maxSnippets: null, - maxAnimations: null, - maxSlidesPerAnimation: null, - features: ["Unlimited snippets", "Unlimited animations", "Unlimited slides"], + id: 'pro', + name: 'Pro', + description: 'For power users and professionals', + maxSnippets: Infinity, + maxAnimations: Infinity, + maxSlidesPerAnimation: Infinity, + maxSnippetsFolder: Infinity, + maxVideoExportCount: Infinity, + shareAsPublicURL: Infinity, + removeWatermark: true, + features: [ + 'Unlimited snippets', + 'Unlimited animations', + 'Unlimited slides', + 'Unlimited folders', + 'Unlimited video exports', + 'Unlimited public shares', + 'Remove watermarks', + 'Priority support', + ], + pricing: { + monthly: { + amount: 900, // $9.00 + displayAmount: '$9', + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID || '', + }, + yearly: { + amount: 8400, // $84/year ($7/month) + displayAmount: '$7', + stripePriceId: process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID || '', + }, + }, }, }; -export type PlanLimitKey = "maxSnippets" | "maxAnimations" | "maxSlidesPerAnimation"; +// Helper to get plan config by ID +export function getPlanConfig(planId: PlanId): PlanConfig { + return PLANS[planId]; +} -export const getPlanLimitValue = (plan: PlanId, key: PlanLimitKey): number | null => { - return PLANS[plan][key]; -}; +// Helper to check if a feature is available for a plan +export function canUsePlanFeature( + planId: PlanId, + feature: keyof Omit +): boolean { + const plan = PLANS[planId]; + const value = plan[feature]; + return typeof value === 'number' ? value === Infinity || value > 0 : Boolean(value); +} + +// Helper to get upgrade target (next plan tier) +export function getUpgradeTarget(currentPlan: PlanId): PlanId | null { + if (currentPlan === 'free') return 'started'; + if (currentPlan === 'started') return 'pro'; + return null; // Pro has no upgrade target +} + +// Helper to check if a limit is reached +export function isLimitReached(current: number, max: number): boolean { + if (max === Infinity) return false; + return current >= max; +} + +// Helper to get usage percentage +export function getUsagePercentage(current: number, max: number | null): number { + if (max === null || max === Infinity || max === 0) return 0; + return Math.min(100, Math.round((current / max) * 100)); +} + +// Helper to get usage color based on percentage +export function getUsageColor(percentage: number): 'green' | 'yellow' | 'red' { + if (percentage >= 91) return 'red'; + if (percentage >= 71) return 'yellow'; + return 'green'; +} diff --git a/src/lib/services/stripe.ts b/src/lib/services/stripe.ts new file mode 100644 index 0000000..360c551 --- /dev/null +++ b/src/lib/services/stripe.ts @@ -0,0 +1,208 @@ +import Stripe from 'stripe'; +import type { PlanId } from '@/lib/config/plans'; + +// Initialize Stripe with the secret key (allow builds without it) +const getStripeInstance = () => { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY is not set in environment variables'); + } + return new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-11-17.clover', + typescript: true, + }); +}; + +// Lazy initialization +let stripeInstance: Stripe | null = null; +export const stripe = new Proxy({} as Stripe, { + get: (target, prop) => { + if (!stripeInstance) { + stripeInstance = getStripeInstance(); + } + return (stripeInstance as any)[prop]; + }, +}); + +/** + * Get or create a Stripe customer for a user + */ +export async function getOrCreateStripeCustomer({ + userId, + email, + name, +}: { + userId: string; + email: string; + name?: string; +}): Promise { + // Search for existing customer by metadata + const existingCustomers = await stripe.customers.list({ + email, + limit: 1, + }); + + if (existingCustomers.data.length > 0) { + return existingCustomers.data[0]; + } + + // Create new customer + const customer = await stripe.customers.create({ + email, + name, + metadata: { + userId, + }, + }); + + return customer; +} + +/** + * Get Stripe price ID for a plan and billing interval + */ +export function getStripePriceId(plan: PlanId, interval: 'monthly' | 'yearly'): string { + const envVarMap = { + started: { + monthly: process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID, + yearly: process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID, + }, + pro: { + monthly: process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + yearly: process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, + }, + }; + + if (plan === 'free') { + throw new Error('Free plan does not have a Stripe price ID'); + } + + const priceId = envVarMap[plan][interval]; + + if (!priceId) { + throw new Error(`Stripe price ID not found for plan: ${plan}, interval: ${interval}`); + } + + return priceId; +} + +/** + * Create a Stripe checkout session + */ +export async function createCheckoutSession({ + customerId, + priceId, + successUrl, + cancelUrl, + metadata, +}: { + customerId: string; + priceId: string; + successUrl: string; + cancelUrl: string; + metadata?: Record; +}): Promise { + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: successUrl, + cancel_url: cancelUrl, + metadata, + allow_promotion_codes: true, + billing_address_collection: 'auto', + subscription_data: { + metadata, + }, + }); + + return session; +} + +/** + * Create a customer portal session for managing subscriptions + */ +export async function createCustomerPortalSession({ + customerId, + returnUrl, +}: { + customerId: string; + returnUrl: string; +}): Promise { + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: returnUrl, + }); + + return session; +} + +/** + * Cancel a subscription at period end + */ +export async function cancelSubscriptionAtPeriodEnd( + subscriptionId: string +): Promise { + return await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); +} + +/** + * Resume a subscription that was set to cancel + */ +export async function resumeSubscription( + subscriptionId: string +): Promise { + return await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: false, + }); +} + +/** + * Get subscription details + */ +export async function getSubscription( + subscriptionId: string +): Promise { + return await stripe.subscriptions.retrieve(subscriptionId); +} + +/** + * Update subscription to a different price + */ +export async function updateSubscriptionPrice({ + subscriptionId, + newPriceId, +}: { + subscriptionId: string; + newPriceId: string; +}): Promise { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + return await stripe.subscriptions.update(subscriptionId, { + items: [ + { + id: subscription.items.data[0].id, + price: newPriceId, + }, + ], + proration_behavior: 'create_prorations', + }); +} + +/** + * Verify webhook signature + */ +export function constructWebhookEvent( + payload: string | Buffer, + signature: string, + secret: string +): Stripe.Event { + return stripe.webhooks.constructEvent(payload, signature, secret); +} diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index dc88409..804e352 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -1,11 +1,13 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/types/database"; -import { getPlanLimitValue, type PlanId, type PlanLimitKey } from "@/lib/config/plans"; +import { getPlanConfig, type PlanId } from "@/lib/config/plans"; type Supabase = SupabaseClient; +// Only snippets and animations have RPC functions implemented type UsageLimitKind = "snippets" | "animations"; +type PlanLimitKey = "maxSnippets" | "maxAnimations"; export type UsageLimitCheck = { canSave: boolean; @@ -65,8 +67,19 @@ const normalizeLimitPayload = ( fallbackPlan: PlanId = "free" ): UsageLimitCheck => { const plan = (payload?.plan as PlanId | undefined) ?? fallbackPlan; + const planConfig = getPlanConfig(plan); const limitKey = RPC_MAP[kind].planKey; - const defaultMax = getPlanLimitValue(plan, limitKey); + + // Get default max from plan config + let defaultMax: number | null; + if (limitKey === "maxSnippets") { + defaultMax = planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets; + } else if (limitKey === "maxAnimations") { + defaultMax = planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations; + } else { + defaultMax = planConfig.maxSlidesPerAnimation === Infinity ? null : planConfig.maxSlidesPerAnimation; + } + const max = payload?.max === null || typeof payload?.max === "undefined" ? defaultMax @@ -171,6 +184,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< } const plan = (profile.plan as PlanId | null) ?? "free"; + const planConfig = getPlanConfig(plan); const snippetCountFromLimits = usage?.snippet_count ?? profile.snippet_count ?? 0; const animationCountFromLimits = usage?.animation_count ?? profile.animation_count ?? 0; const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); @@ -180,18 +194,19 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< plan, snippets: { current: snippetCount, - max: getPlanLimitValue(plan, "maxSnippets"), + max: planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets, }, animations: { current: animationCount, - max: getPlanLimitValue(plan, "maxAnimations"), + max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, }, lastResetAt: usage?.last_reset_at ?? undefined, }; }; export const checkSlideLimit = (slideCount: number, plan: PlanId): UsageLimitCheck => { - const max = getPlanLimitValue(plan, "maxSlidesPerAnimation"); + const planConfig = getPlanConfig(plan); + const max = planConfig.maxSlidesPerAnimation === Infinity ? null : planConfig.maxSlidesPerAnimation; return { canSave: max === null ? true : slideCount <= max, current: slideCount, diff --git a/src/lib/stripe-client.ts b/src/lib/stripe-client.ts new file mode 100644 index 0000000..f0699d3 --- /dev/null +++ b/src/lib/stripe-client.ts @@ -0,0 +1,21 @@ +import { loadStripe, Stripe } from '@stripe/stripe-js'; + +let stripePromise: Promise; + +/** + * Get Stripe.js instance + */ +export const getStripe = (): Promise => { + if (!stripePromise) { + const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + + if (!publishableKey) { + console.error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set'); + return Promise.resolve(null); + } + + stripePromise = loadStripe(publishableKey); + } + + return stripePromise; +}; diff --git a/supabase/migrations/20251207103258_add_usage_limits_and_plans.sql b/supabase/migrations/20251207103258_add_usage_limits_and_plans.sql new file mode 100644 index 0000000..812553f --- /dev/null +++ b/supabase/migrations/20251207103258_add_usage_limits_and_plans.sql @@ -0,0 +1,357 @@ +-- Create plan enum type +CREATE TYPE plan_type AS ENUM ('free', 'started', 'pro'); + +-- Add plan and usage tracking columns to profiles +ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS plan plan_type DEFAULT 'free' NOT NULL, + ADD COLUMN IF NOT EXISTS plan_updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS snippet_count INTEGER DEFAULT 0 NOT NULL CHECK (snippet_count >= 0), + ADD COLUMN IF NOT EXISTS animation_count INTEGER DEFAULT 0 NOT NULL CHECK (animation_count >= 0), + ADD COLUMN IF NOT EXISTS folder_count INTEGER DEFAULT 0 NOT NULL CHECK (folder_count >= 0), + ADD COLUMN IF NOT EXISTS video_export_count INTEGER DEFAULT 0 NOT NULL CHECK (video_export_count >= 0), + ADD COLUMN IF NOT EXISTS public_share_count INTEGER DEFAULT 0 NOT NULL CHECK (public_share_count >= 0); + +-- Add Stripe subscription columns to profiles +ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT UNIQUE, + ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT, + ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT CHECK ( + stripe_subscription_status IN ('active', 'canceled', 'past_due', 'unpaid', 'incomplete', 'incomplete_expired', 'trialing') + ), + ADD COLUMN IF NOT EXISTS subscription_period_end TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS subscription_cancel_at_period_end BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS stripe_price_id TEXT; + +-- Create indexes for Stripe columns +CREATE INDEX IF NOT EXISTS profiles_stripe_customer_id_idx ON public.profiles(stripe_customer_id); +CREATE INDEX IF NOT EXISTS profiles_stripe_subscription_id_idx ON public.profiles(stripe_subscription_id); +CREATE INDEX IF NOT EXISTS profiles_plan_idx ON public.profiles(plan); + +-- Create usage_limits table for tracking (optional, for potential future use) +CREATE TABLE IF NOT EXISTS public.usage_limits ( + user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE PRIMARY KEY, + snippet_count INTEGER DEFAULT 0 NOT NULL CHECK (snippet_count >= 0), + animation_count INTEGER DEFAULT 0 NOT NULL CHECK (animation_count >= 0), + folder_count INTEGER DEFAULT 0 NOT NULL CHECK (folder_count >= 0), + video_export_count INTEGER DEFAULT 0 NOT NULL CHECK (video_export_count >= 0), + public_share_count INTEGER DEFAULT 0 NOT NULL CHECK (public_share_count >= 0), + last_reset_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Enable RLS on usage_limits +ALTER TABLE public.usage_limits ENABLE ROW LEVEL SECURITY; + +-- RLS policies for usage_limits +CREATE POLICY "Users can view own usage limits." ON public.usage_limits + FOR SELECT USING (auth.uid() = user_id); + +-- Function to get plan limits +CREATE OR REPLACE FUNCTION get_plan_limits(plan_type plan_type) +RETURNS JSON AS $$ +BEGIN + RETURN CASE plan_type + WHEN 'free' THEN '{"maxSnippets": 0, "maxAnimations": 0, "maxSlidesPerAnimation": 3, "maxSnippetsFolder": 0, "maxVideoExportCount": 0, "shareAsPublicURL": 3}'::json + WHEN 'started' THEN '{"maxSnippets": 50, "maxAnimations": 50, "maxSlidesPerAnimation": 10, "maxSnippetsFolder": 10, "maxVideoExportCount": 50, "shareAsPublicURL": 50}'::json + WHEN 'pro' THEN '{"maxSnippets": null, "maxAnimations": null, "maxSlidesPerAnimation": null, "maxSnippetsFolder": null, "maxVideoExportCount": null, "shareAsPublicURL": null}'::json + END; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Function to check snippet limit +CREATE OR REPLACE FUNCTION check_snippet_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan, snippet_count INTO v_plan, v_current_count + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL -- NULL means unlimited + END; + + RETURN json_build_object( + 'canSave', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to check animation limit +CREATE OR REPLACE FUNCTION check_animation_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan, animation_count INTO v_plan, v_current_count + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canSave', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to check folder limit +CREATE OR REPLACE FUNCTION check_folder_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan, folder_count INTO v_plan, v_current_count + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 10 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canCreate', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to check video export limit +CREATE OR REPLACE FUNCTION check_video_export_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan, video_export_count INTO v_plan, v_current_count + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canExport', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to check public share limit +CREATE OR REPLACE FUNCTION check_public_share_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan, public_share_count INTO v_plan, v_current_count + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 3 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canShare', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to check slide count limit +CREATE OR REPLACE FUNCTION check_slide_limit(p_user_id UUID, p_slide_count INTEGER) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan + FROM public.profiles + WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 3 + WHEN 'started' THEN 10 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canAdd', v_max_limit IS NULL OR p_slide_count <= v_max_limit, + 'slideCount', p_slide_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment snippet count +CREATE OR REPLACE FUNCTION increment_snippet_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET snippet_count = snippet_count + 1 + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment animation count +CREATE OR REPLACE FUNCTION increment_animation_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET animation_count = animation_count + 1 + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment folder count +CREATE OR REPLACE FUNCTION increment_folder_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET folder_count = folder_count + 1 + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment video export count +CREATE OR REPLACE FUNCTION increment_video_export_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET video_export_count = video_export_count + 1 + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to increment public share count +CREATE OR REPLACE FUNCTION increment_public_share_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET public_share_count = public_share_count + 1 + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to decrement snippet count +CREATE OR REPLACE FUNCTION decrement_snippet_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET snippet_count = GREATEST(0, snippet_count - 1) + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to decrement animation count +CREATE OR REPLACE FUNCTION decrement_animation_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET animation_count = GREATEST(0, animation_count - 1) + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to decrement folder count +CREATE OR REPLACE FUNCTION decrement_folder_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET folder_count = GREATEST(0, folder_count - 1) + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to decrement public share count +CREATE OR REPLACE FUNCTION decrement_public_share_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + UPDATE public.profiles + SET public_share_count = GREATEST(0, public_share_count - 1) + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to get user usage stats +CREATE OR REPLACE FUNCTION get_user_usage(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_profile RECORD; +BEGIN + SELECT + plan, + snippet_count, + animation_count, + folder_count, + video_export_count, + public_share_count + INTO v_profile + FROM public.profiles + WHERE id = p_user_id; + + RETURN json_build_object( + 'plan', v_profile.plan, + 'snippetCount', v_profile.snippet_count, + 'animationCount', v_profile.animation_count, + 'folderCount', v_profile.folder_count, + 'videoExportCount', v_profile.video_export_count, + 'publicShareCount', v_profile.public_share_count + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant execute permissions on functions +GRANT EXECUTE ON FUNCTION check_snippet_limit(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_animation_limit(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_folder_limit(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_video_export_limit(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_public_share_limit(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION check_slide_limit(UUID, INTEGER) TO authenticated; +GRANT EXECUTE ON FUNCTION increment_snippet_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION increment_animation_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION increment_folder_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION increment_video_export_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION increment_public_share_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION decrement_snippet_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION decrement_animation_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION decrement_folder_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION decrement_public_share_count(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION get_user_usage(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION get_plan_limits(plan_type) TO authenticated; From 3b9463c04df76849f1c562d41e7551f186ddbb3e Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Sun, 7 Dec 2025 19:22:38 -0300 Subject: [PATCH 004/102] :sparkles: feat: Implement public sharing with view limits and usage tracking for shared links. --- src/app/[shared_link]/page.tsx | 32 ++ src/app/animate/shared/[slug]/page.tsx | 33 ++ src/app/api/shorten-url/route.ts | 121 +++++-- src/components/ui/nav/index.tsx | 23 +- src/components/ui/upgrade-dialog/index.tsx | 21 +- .../animation/enhanced-share-dialog.tsx | 86 ++++- src/features/share-code/index.tsx | 38 ++- src/lib/config/plans.ts | 9 +- src/lib/services/shared-link.ts | 2 +- src/lib/services/usage-limits.ts | 68 ++-- ...308120000_usage_limits_source_of_truth.sql | 302 ++++++++++++++++++ .../20260415090000_public_share_views.sql | 154 +++++++++ 12 files changed, 796 insertions(+), 93 deletions(-) create mode 100644 supabase/migrations/20250308120000_usage_limits_source_of_truth.sql create mode 100644 supabase/migrations/20260415090000_public_share_views.sql diff --git a/src/app/[shared_link]/page.tsx b/src/app/[shared_link]/page.tsx index 81bcafb..9d703fc 100644 --- a/src/app/[shared_link]/page.tsx +++ b/src/app/[shared_link]/page.tsx @@ -6,6 +6,8 @@ import { JsonLd } from "@/components/seo/json-ld"; import { siteConfig } from "@/lib/utils/site-config"; import Link from "next/link"; import { Metadata } from "next"; +import { cookies, headers } from "next/headers"; +import { createHash } from "crypto"; type SharedLinkPageProps = { params: Promise<{ @@ -55,6 +57,36 @@ export default async function SharedLinkPage({ params }: SharedLinkPageProps) { return notFound(); } + const cookieStore = await cookies(); + const viewerCookie = cookieStore.get("jc_viewer_token")?.value; + const headerStore = await headers(); + const fallbackTokenSource = `${headerStore.get("x-forwarded-for") ?? ""}|${headerStore.get("user-agent") ?? ""}`; + const hashedFallback = createHash("sha256").update(fallbackTokenSource || shared_link).digest("hex"); + const viewerToken = viewerCookie ?? hashedFallback; + + const supabase = await createClient(); + const { data: viewResult, error: viewError } = await supabase.rpc( + "record_public_share_view" as never, + { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } + ); + + if (viewError) { + console.error("Failed to record public share view", viewError); + } + + if (viewResult && (viewResult as any).allowed === false) { + return ( +
+
+

View limit reached

+

+ This shared link has reached its monthly view limit. Please ask the owner to upgrade their plan to enable more views. +

+
+
+ ); + } + // Track visit asynchronously (fire and forget) trackSharedLinkVisit(data.id); diff --git a/src/app/animate/shared/[slug]/page.tsx b/src/app/animate/shared/[slug]/page.tsx index 6fafdc6..1e1f66f 100644 --- a/src/app/animate/shared/[slug]/page.tsx +++ b/src/app/animate/shared/[slug]/page.tsx @@ -9,6 +9,9 @@ import { import { AnimateSharedClient } from "@/features/animation/shared-view"; import { JsonLd } from "@/components/seo/json-ld"; import { siteConfig } from "@/lib/utils/site-config"; +import { cookies, headers } from "next/headers"; +import { createClient } from "@/utils/supabase/server"; +import { createHash } from "crypto"; type SharedAnimationPageProps = { params: Promise<{ @@ -79,6 +82,36 @@ export default async function SharedAnimationPage({ params }: SharedAnimationPag return notFound(); } + const cookieStore = await cookies(); + const viewerCookie = cookieStore.get("jc_viewer_token")?.value; + const headerStore = await headers(); + const fallbackTokenSource = `${headerStore.get("x-forwarded-for") ?? ""}|${headerStore.get("user-agent") ?? ""}`; + const hashedFallback = createHash("sha256").update(fallbackTokenSource || slug).digest("hex"); + const viewerToken = viewerCookie ?? hashedFallback; + + const supabase = await createClient(); + const { data: viewResult, error: viewError } = await supabase.rpc( + "record_public_share_view" as never, + { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } + ); + + if (viewError) { + console.error("Failed to record public share view", viewError); + } + + if (viewResult && (viewResult as any).allowed === false) { + return ( +
+
+

View limit reached

+

+ This shared link has reached its monthly view limit. Please ask the owner to upgrade their plan to enable more views. +

+
+
+ ); + } + const encodedPayload = extractAnimationPayloadFromUrl(data.url); if (!encodedPayload) { return notFound(); diff --git a/src/app/api/shorten-url/route.ts b/src/app/api/shorten-url/route.ts index 4e6874e..57d7b0f 100644 --- a/src/app/api/shorten-url/route.ts +++ b/src/app/api/shorten-url/route.ts @@ -4,17 +4,14 @@ import { } from "@/lib/sentry-context"; import { isValidURL } from "@/lib/utils/is-valid-url"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; -import { Database } from "@/types/database"; import { createClient } from "@/utils/supabase/server"; import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; +import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { nanoid } from "nanoid"; export const runtime = "edge"; - - -const shortURLs: { [key: string]: string } = {}; const keySet: Set = new Set(); /** @@ -44,8 +41,8 @@ export const GET = wrapRouteHandlerWithSentry( try { const result = await supabase .from("links") - .select("id, url, title, description") - .eq("short_url", slug); + .select("id, url, title, description, user_id") + .eq("short_url", slug); data = result.data; @@ -54,17 +51,83 @@ export const GET = wrapRouteHandlerWithSentry( error = error.message; } - if (!data) { + if (!data || !data[0]) { applyResponseContextToSentry(404); return NextResponse.json({ error: "URL not found." }, { status: 404 }); } + const link = data[0]; + + const cookieStore = await cookies(); + const VIEWER_COOKIE = "jc_viewer_token"; + let viewerToken = cookieStore.get(VIEWER_COOKIE)?.value; + + if (!viewerToken) { + viewerToken = nanoid(24); + } + + let viewResult: + | { allowed?: boolean; counted?: boolean; current?: number; max?: number | null; plan?: string } + | null = null; + + try { + const { data: recordViewData, error: recordViewError } = await supabase.rpc( + "record_public_share_view" as never, + { p_owner_id: link.user_id, p_link_id: link.id, p_viewer_token: viewerToken } + ); + + if (recordViewError) { + throw recordViewError; + } + + viewResult = recordViewData as typeof viewResult; + } catch (recordError) { + console.error("Failed to record share view", recordError); + } + + if (viewResult && viewResult.allowed === false) { + applyResponseContextToSentry(429); + const response = NextResponse.json( + { + error: "View limit reached for this shared link.", + current: viewResult.current ?? null, + max: viewResult.max ?? null, + plan: viewResult.plan ?? null, + }, + { status: 429 } + ); + + if (!cookieStore.get(VIEWER_COOKIE)?.value && viewerToken) { + response.cookies.set(VIEWER_COOKIE, viewerToken, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + } + + return response; + } + applyResponseContextToSentry(200); - return NextResponse.json({ + const response = NextResponse.json({ status: 200, - id: data[0].id, - url: data[0].url, + id: link.id, + url: link.url, }); + + if (!cookieStore.get(VIEWER_COOKIE)?.value && viewerToken) { + response.cookies.set(VIEWER_COOKIE, viewerToken, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); + } + + return response; }, { method: "GET", @@ -83,15 +146,14 @@ export const POST = wrapRouteHandlerWithSentry( return NextResponse.json({ error: "Invalid request" }, { status: 415 }); } - const { url, snippet_id, user_id, title, description } = + const { url, snippet_id, title, description } = await validateContentType(request).json(); - applyRequestContextToSentry({ request, userId: user_id }); + applyRequestContextToSentry({ request }); const longUrl = url ? url : null; - const validURL = await isValidURL(longUrl); - - let data; + const isInternalPayload = typeof longUrl === "string" && longUrl.startsWith("animation:"); + const validURL = isInternalPayload || await isValidURL(longUrl); if (!validURL) { applyResponseContextToSentry(400); @@ -101,9 +163,19 @@ export const POST = wrapRouteHandlerWithSentry( ); } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + applyResponseContextToSentry(401); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { data: existingUrl, error } = await supabase .from("links") - .select("url, short_url, title, description") + .select("url, short_url, title, description, user_id") .eq("url", longUrl); if (error) { @@ -112,10 +184,10 @@ export const POST = wrapRouteHandlerWithSentry( return NextResponse.json({ error: "Database error" }, { status: 500 }); } - if (existingUrl && existingUrl.length > 0) { - // URL already exists, return the existing short URL and refresh metadata if needed - const existing = existingUrl[0]; + const existing = existingUrl?.[0]; + // If the current user already owns this link, allow reuse without consuming quota + if (existing && existing.user_id === user.id) { if (title || description) { try { await supabase @@ -137,6 +209,14 @@ export const POST = wrapRouteHandlerWithSentry( }); } + if (existing) { + applyResponseContextToSentry(200); + return NextResponse.json({ + status: 200, + short_url: existing.short_url, + }); + } + const key = nanoid(5); if (keySet.has(key)) { applyResponseContextToSentry(400); @@ -147,7 +227,6 @@ export const POST = wrapRouteHandlerWithSentry( } keySet.add(key); - shortURLs[key] = longUrl; const shortUrl = key; @@ -155,7 +234,7 @@ export const POST = wrapRouteHandlerWithSentry( .from("links") .insert([ { - user_id: user_id ? user_id : null, + user_id: user.id, url: longUrl, short_url: shortUrl, snippet_id: snippet_id ? snippet_id : null, diff --git a/src/components/ui/nav/index.tsx b/src/components/ui/nav/index.tsx index 7e59805..319d95e 100644 --- a/src/components/ui/nav/index.tsx +++ b/src/components/ui/nav/index.tsx @@ -242,17 +242,18 @@ export const Nav = () => {
{isPresentational && } - {isAnimationPage ? ( - <> - - - - ) : ( - <> - - - - )} + {!isPresentational && + (isAnimationPage ? ( + <> + + + + ) : ( + <> + + + + ))}
); @@ -263,24 +263,7 @@ export function UpgradeDialog({ {PLANS[selectedPlan].name} • {formatPrice(PLANS[selectedPlan], billingInterval)}{" "} {PLANS[selectedPlan].pricing ? intervalLabel[billingInterval] : ""}

-

- {formatLimit(getLimitForPlan(PLANS[selectedPlan], limitType))} {limitLabel} -

- - diff --git a/src/features/animation/enhanced-share-dialog.tsx b/src/features/animation/enhanced-share-dialog.tsx index a65bf37..dbf9101 100644 --- a/src/features/animation/enhanced-share-dialog.tsx +++ b/src/features/animation/enhanced-share-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; @@ -30,6 +30,8 @@ import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; import { calculateTotalDuration } from "@/features/animation"; import { trackAnimationEvent } from "@/features/animation/analytics"; import { debounce } from "@/lib/utils/debounce"; +import { UpgradeDialog } from "@/components/ui/upgrade-dialog"; +import { useUserUsage, USAGE_QUERY_KEY } from "@/features/user/queries"; import { ShareMetadataForm } from "./share-dialog/share-metadata-form"; import { ShareTabPublic } from "./share-dialog/tabs/share-tab-public"; @@ -87,6 +89,14 @@ export const EnhancedAnimationShareDialog = () => { const [exportProgress, setExportProgress] = useState(0); const [cancelExport, setCancelExport] = useState(false); + const queryClient = useQueryClient(); + const { data: usage } = useUserUsage(user?.id); + const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); + const [upgradeContext, setUpgradeContext] = useState<{ current?: number; max?: number | null }>({ + current: 0, + max: null, + }); + const { copy: copyLink, isCopying: isCopyingLink } = useCopyToClipboard({ successMessage: "Link copied to clipboard.", }); @@ -207,6 +217,14 @@ export const EnhancedAnimationShareDialog = () => { trackMetadataUpdated("description", Boolean(descriptionValue?.trim())); }, [descriptionValue, form.formState.isDirty, trackMetadataUpdated]); + const openUpgradeForShares = (current?: number, max?: number | null) => { + setUpgradeContext({ + current, + max: typeof max === "number" ? max : null, + }); + setIsUpgradeOpen(true); + }; + const shortenUrlMutation = useMutation({ mutationFn: async (params: { url: string; title?: string; description?: string }) => { const response = await fetch("/api/shorten-url", { @@ -224,7 +242,24 @@ export const EnhancedAnimationShareDialog = () => { }); if (!response.ok) { - throw new Error("Failed to shorten URL"); + const data = await response.json().catch(() => ({})); + + if (response.status === 401) { + const authError = new Error("AUTH_REQUIRED"); + (authError as any).code = "AUTH_REQUIRED"; + throw authError; + } + + if (response.status === 429) { + const limitError = new Error(data?.error || "Public share limit reached"); + (limitError as any).code = "PUBLIC_SHARE_LIMIT"; + (limitError as any).current = data?.current; + (limitError as any).max = data?.max; + (limitError as any).plan = data?.plan; + throw limitError; + } + + throw new Error(data?.error || "Failed to shorten URL"); } const data = await response.json(); @@ -238,6 +273,18 @@ export const EnhancedAnimationShareDialog = () => { return undefined; } + if (!user) { + setIsLoginDialogOpen(true); + return undefined; + } + + const publicShares = usage?.publicShares; + if (publicShares?.max !== null && publicShares.current >= publicShares.max) { + openUpgradeForShares(publicShares.current, publicShares.max); + toast.error("You’ve reached your public view limit. Upgrade for more views."); + return undefined; + } + const parsed = shareFormSchema.safeParse(values ?? form.getValues()); if (!parsed.success) { void form.trigger(); @@ -283,17 +330,37 @@ export const EnhancedAnimationShareDialog = () => { url_generated: Boolean(fullUrl), }); + await queryClient.invalidateQueries({ queryKey: [USAGE_QUERY_KEY, user?.id] }); + return fullUrl; } catch (error) { console.error("Failed to generate share URL", error); + const err = error as any; + + if (err?.code === "AUTH_REQUIRED") { + setIsLoginDialogOpen(true); + return undefined; + } + + if (err?.code === "PUBLIC_SHARE_LIMIT") { + openUpgradeForShares(err?.current, err?.max); + toast.error("Public share limit reached. Upgrade to continue sharing."); + return undefined; + } + toast.error("Oh no, something went wrong. Please try again."); return undefined; } }, - [currentUrl, form, payload, serializedSlides.length, shortenUrlMutation, user] + [currentUrl, form, payload, queryClient, serializedSlides.length, shortenUrlMutation, usage?.publicShares, user] ); const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen && !user) { + setIsLoginDialogOpen(true); + return; + } + setOpen(nextOpen); if (nextOpen) { @@ -377,6 +444,11 @@ export const EnhancedAnimationShareDialog = () => { const handleCopyUrl = useCallback(async () => { try { + if (!user) { + setIsLoginDialogOpen(true); + return; + } + const latestUrl = shareUrl && !form.formState.isDirty ? shareUrl : await generateShareUrl(); if (!latestUrl) { return; @@ -493,6 +565,14 @@ export const EnhancedAnimationShareDialog = () => { return ( <> + diff --git a/src/features/share-code/index.tsx b/src/features/share-code/index.tsx index 48c296d..b6b030a 100644 --- a/src/features/share-code/index.tsx +++ b/src/features/share-code/index.tsx @@ -10,9 +10,11 @@ import { Button } from "@/components/ui/button"; import { hotKeyList } from "@/lib/hot-key-list"; import { useUserStore, useEditorStore } from "@/app/store"; import { analytics } from "@/lib/services/tracking"; +import { LoginDialog } from "@/features/login"; export const CopyURLToClipboard = () => { const user = useUserStore((state) => state.user); + const [showLogin, setShowLogin] = useState(false); const [currentUrl, setCurrentUrl] = useState(null); const currentEditorState = useEditorStore( @@ -42,6 +44,10 @@ export const CopyURLToClipboard = () => { const postLinkDataToDatabase = useMutation({ mutationFn: async (url: string) => { + if (!user) { + throw Object.assign(new Error("AUTH_REQUIRED"), { code: "AUTH_REQUIRED" }); + } + const response = await fetch("/api/shorten-url", { method: "POST", headers: { @@ -64,12 +70,21 @@ export const CopyURLToClipboard = () => { onSuccess: async (shortUrl) => { await navigator.clipboard.writeText(`${currentUrl}/${shortUrl}`); }, - onError: (error) => { + onError: (error: any) => { + if (error?.code === "AUTH_REQUIRED") { + setShowLogin(true); + return; + } toast.error("Oh no, something went wrong. Please try again."); }, }); const handleCopyLinkToClipboard = useCallback(async () => { + if (!user) { + setShowLogin(true); + return; + } + const stringifiedState = Object.fromEntries( Object.entries(currentEditorState || {}).map(([key, value]) => [ key, @@ -109,14 +124,17 @@ export const CopyURLToClipboard = () => { } return ( - - - + <> + + + + + ); }; diff --git a/src/lib/config/plans.ts b/src/lib/config/plans.ts index 596280b..44c57b5 100644 --- a/src/lib/config/plans.ts +++ b/src/lib/config/plans.ts @@ -37,13 +37,13 @@ export const PLANS: Record = { maxSlidesPerAnimation: 3, maxSnippetsFolder: 0, maxVideoExportCount: 0, - shareAsPublicURL: 3, + shareAsPublicURL: 50, removeWatermark: false, features: [ 'Create & edit snippets', 'Up to 3 slides per animation', 'Export as images (PNG/JPG/SVG)', - '3 public shares', + '50 public views/month', ], pricing: null, // Free has no pricing }, @@ -56,7 +56,7 @@ export const PLANS: Record = { maxSlidesPerAnimation: 10, maxSnippetsFolder: 10, maxVideoExportCount: 50, - shareAsPublicURL: 50, + shareAsPublicURL: 1000, removeWatermark: false, features: [ 'Save up to 50 snippets', @@ -64,7 +64,7 @@ export const PLANS: Record = { 'Up to 10 slides per animation', '10 folders/collections', '50 video exports', - '50 public shares', + '1,000 public views/month', ], pricing: { monthly: { @@ -155,4 +155,3 @@ export function getUsageColor(percentage: number): 'green' | 'yellow' | 'red' { if (percentage >= 71) return 'yellow'; return 'green'; } - diff --git a/src/lib/services/shared-link.ts b/src/lib/services/shared-link.ts index d596d7b..4489c2e 100644 --- a/src/lib/services/shared-link.ts +++ b/src/lib/services/shared-link.ts @@ -10,7 +10,7 @@ export async function getSharedLink(slug: string) { try { const { data, error } = await supabase .from("links") - .select("id, url, snippet_id, title, description, created_at") + .select("id, url, snippet_id, title, description, created_at, user_id") .eq("short_url", slug) .single(); diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index 804e352..17db60e 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -5,9 +5,8 @@ import { getPlanConfig, type PlanId } from "@/lib/config/plans"; type Supabase = SupabaseClient; -// Only snippets and animations have RPC functions implemented -type UsageLimitKind = "snippets" | "animations"; -type PlanLimitKey = "maxSnippets" | "maxAnimations"; +type UsageLimitKind = "snippets" | "animations" | "publicShares"; +type PlanLimitKey = "maxSnippets" | "maxAnimations" | "shareAsPublicURL"; export type UsageLimitCheck = { canSave: boolean; @@ -27,15 +26,19 @@ export type UsageSummary = { current: number; max: number | null; }; + publicShares: { + current: number; + max: number | null; + }; lastResetAt?: string; }; const RPC_MAP: Record< UsageLimitKind, { - check: "check_snippet_limit" | "check_animation_limit"; - increment: "increment_snippet_count" | "increment_animation_count"; - decrement: "decrement_snippet_count" | "decrement_animation_count"; + check: "check_snippet_limit" | "check_animation_limit" | "check_public_share_limit"; + increment: "increment_snippet_count" | "increment_animation_count" | "increment_public_share_count"; + decrement: "decrement_snippet_count" | "decrement_animation_count" | "decrement_public_share_count"; planKey: PlanLimitKey; } > = { @@ -51,6 +54,12 @@ const RPC_MAP: Record< decrement: "decrement_animation_count", planKey: "maxAnimations", }, + publicShares: { + check: "check_public_share_limit", + increment: "increment_public_share_count", + decrement: "decrement_public_share_count", + planKey: "shareAsPublicURL", + }, }; type LimitRpcName = @@ -59,7 +68,10 @@ type LimitRpcName = | (typeof RPC_MAP)["snippets"]["decrement"] | (typeof RPC_MAP)["animations"]["check"] | (typeof RPC_MAP)["animations"]["increment"] - | (typeof RPC_MAP)["animations"]["decrement"]; + | (typeof RPC_MAP)["animations"]["decrement"] + | (typeof RPC_MAP)["publicShares"]["check"] + | (typeof RPC_MAP)["publicShares"]["increment"] + | (typeof RPC_MAP)["publicShares"]["decrement"]; const normalizeLimitPayload = ( payload: any, @@ -69,17 +81,14 @@ const normalizeLimitPayload = ( const plan = (payload?.plan as PlanId | undefined) ?? fallbackPlan; const planConfig = getPlanConfig(plan); const limitKey = RPC_MAP[kind].planKey; - - // Get default max from plan config - let defaultMax: number | null; - if (limitKey === "maxSnippets") { - defaultMax = planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets; - } else if (limitKey === "maxAnimations") { - defaultMax = planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations; - } else { - defaultMax = planConfig.maxSlidesPerAnimation === Infinity ? null : planConfig.maxSlidesPerAnimation; - } - + + const defaultMax = + limitKey === "maxSnippets" + ? planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets + : limitKey === "maxAnimations" + ? planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations + : planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL; + const max = payload?.max === null || typeof payload?.max === "undefined" ? defaultMax @@ -100,7 +109,7 @@ const callLimitRpc = async ( userId: string, kind: UsageLimitKind ): Promise => { - const { data, error } = await supabase.rpc(fn, { target_user_id: userId }); + const { data, error } = await supabase.rpc(fn, { p_user_id: userId }); if (error) { console.error(`RPC ${fn} failed`, error); @@ -124,6 +133,13 @@ export const checkAnimationLimit = async ( return callLimitRpc(supabase, RPC_MAP.animations.check, userId, "animations"); }; +export const checkPublicShareLimit = async ( + supabase: Supabase, + userId: string +): Promise => { + return callLimitRpc(supabase, RPC_MAP.publicShares.check, userId, "publicShares"); +}; + export const incrementUsageCount = async ( supabase: Supabase, userId: string, @@ -147,7 +163,7 @@ export const decrementUsageCount = async ( export const getUserUsage = async (supabase: Supabase, userId: string): Promise => { const { data: profile, error: profileError } = await supabase .from("profiles") - .select("plan, snippet_count, animation_count") + .select("plan") .eq("id", userId) .single(); @@ -158,7 +174,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const { data: usage, error: usageError } = await supabase .from("usage_limits") - .select("snippet_count, animation_count, last_reset_at") + .select("snippet_count, animation_count, public_share_count, last_reset_at") .eq("user_id", userId) .maybeSingle(); @@ -185,10 +201,12 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const plan = (profile.plan as PlanId | null) ?? "free"; const planConfig = getPlanConfig(plan); - const snippetCountFromLimits = usage?.snippet_count ?? profile.snippet_count ?? 0; - const animationCountFromLimits = usage?.animation_count ?? profile.animation_count ?? 0; + const snippetCountFromLimits = usage?.snippet_count ?? 0; + const animationCountFromLimits = usage?.animation_count ?? 0; + const publicShareCountFromLimits = usage?.public_share_count ?? 0; const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); + const publicShareCount = publicShareCountFromLimits; return { plan, @@ -200,6 +218,10 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< current: animationCount, max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, }, + publicShares: { + current: publicShareCount, + max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, + }, lastResetAt: usage?.last_reset_at ?? undefined, }; }; diff --git a/supabase/migrations/20250308120000_usage_limits_source_of_truth.sql b/supabase/migrations/20250308120000_usage_limits_source_of_truth.sql new file mode 100644 index 0000000..845d9e4 --- /dev/null +++ b/supabase/migrations/20250308120000_usage_limits_source_of_truth.sql @@ -0,0 +1,302 @@ +-- Make usage_limits the source of truth for counters (snippets, animations, folders, video exports, public shares) +-- while keeping profiles plan metadata. The existing RPCs are updated to read/write usage_limits and also +-- update profiles counts for backward compatibility with any legacy reads. + +-- 1) Backfill usage_limits from profiles for any missing rows +INSERT INTO public.usage_limits (user_id, snippet_count, animation_count, folder_count, video_export_count, public_share_count) +SELECT + id AS user_id, + COALESCE(snippet_count, 0), + COALESCE(animation_count, 0), + COALESCE(folder_count, 0), + COALESCE(video_export_count, 0), + COALESCE(public_share_count, 0) +FROM public.profiles +ON CONFLICT (user_id) DO NOTHING; + +-- 2) Relax RLS to allow owners to insert/update their own usage rows +DROP POLICY IF EXISTS "Users can view own usage limits." ON public.usage_limits; +CREATE POLICY "Users can view own usage limits." ON public.usage_limits + FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own usage limits." ON public.usage_limits + FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own usage limits." ON public.usage_limits + FOR UPDATE + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- 3) Helper to ensure a usage_limits row exists for the user +CREATE OR REPLACE FUNCTION ensure_usage_limits_row(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + INSERT INTO public.usage_limits (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 4) Rework limit checks to read usage_limits first (fallback to profiles counts) + +CREATE OR REPLACE FUNCTION check_snippet_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + SELECT snippet_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + v_current_count := COALESCE(v_current_count, (SELECT snippet_count FROM public.profiles WHERE id = p_user_id), 0); + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canSave', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION check_animation_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + SELECT animation_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + v_current_count := COALESCE(v_current_count, (SELECT animation_count FROM public.profiles WHERE id = p_user_id), 0); + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canSave', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION check_folder_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + SELECT folder_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + v_current_count := COALESCE(v_current_count, (SELECT folder_count FROM public.profiles WHERE id = p_user_id), 0); + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 10 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canCreate', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION check_video_export_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + SELECT video_export_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + v_current_count := COALESCE(v_current_count, (SELECT video_export_count FROM public.profiles WHERE id = p_user_id), 0); + + v_max_limit := CASE v_plan + WHEN 'free' THEN 0 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canExport', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION check_public_share_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + v_current_count := COALESCE(v_current_count, (SELECT public_share_count FROM public.profiles WHERE id = p_user_id), 0); + + v_max_limit := CASE v_plan + WHEN 'free' THEN 3 + WHEN 'started' THEN 50 + WHEN 'pro' THEN NULL + END; + + RETURN json_build_object( + 'canShare', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 5) Increment/decrement helpers use usage_limits as the source of truth and mirror into profiles for legacy reads + +CREATE OR REPLACE FUNCTION increment_snippet_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET snippet_count = snippet_count + 1 WHERE user_id = p_user_id; + UPDATE public.profiles SET snippet_count = snippet_count + 1 WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION increment_animation_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET animation_count = animation_count + 1 WHERE user_id = p_user_id; + UPDATE public.profiles SET animation_count = animation_count + 1 WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION increment_folder_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET folder_count = folder_count + 1 WHERE user_id = p_user_id; + UPDATE public.profiles SET folder_count = folder_count + 1 WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION increment_video_export_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET video_export_count = video_export_count + 1 WHERE user_id = p_user_id; + UPDATE public.profiles SET video_export_count = video_export_count + 1 WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION increment_public_share_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET public_share_count = public_share_count + 1 WHERE user_id = p_user_id; + UPDATE public.profiles SET public_share_count = public_share_count + 1 WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION decrement_snippet_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET snippet_count = GREATEST(0, snippet_count - 1) WHERE user_id = p_user_id; + UPDATE public.profiles SET snippet_count = GREATEST(0, snippet_count - 1) WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION decrement_animation_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET animation_count = GREATEST(0, animation_count - 1) WHERE user_id = p_user_id; + UPDATE public.profiles SET animation_count = GREATEST(0, animation_count - 1) WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION decrement_folder_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET folder_count = GREATEST(0, folder_count - 1) WHERE user_id = p_user_id; + UPDATE public.profiles SET folder_count = GREATEST(0, folder_count - 1) WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION decrement_public_share_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits SET public_share_count = GREATEST(0, public_share_count - 1) WHERE user_id = p_user_id; + UPDATE public.profiles SET public_share_count = GREATEST(0, public_share_count - 1) WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 6) get_user_usage now reads from usage_limits for counters and profiles for plan +CREATE OR REPLACE FUNCTION get_user_usage(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_profile RECORD; + v_usage RECORD; +BEGIN + SELECT plan INTO v_profile + FROM public.profiles + WHERE id = p_user_id; + + SELECT snippet_count, animation_count, folder_count, video_export_count, public_share_count + INTO v_usage + FROM public.usage_limits + WHERE user_id = p_user_id; + + RETURN json_build_object( + 'plan', v_profile.plan, + 'snippetCount', COALESCE(v_usage.snippet_count, 0), + 'animationCount', COALESCE(v_usage.animation_count, 0), + 'folderCount', COALESCE(v_usage.folder_count, 0), + 'videoExportCount', COALESCE(v_usage.video_export_count, 0), + 'publicShareCount', COALESCE(v_usage.public_share_count, 0) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/supabase/migrations/20260415090000_public_share_views.sql b/supabase/migrations/20260415090000_public_share_views.sql new file mode 100644 index 0000000..41f4c5d --- /dev/null +++ b/supabase/migrations/20260415090000_public_share_views.sql @@ -0,0 +1,154 @@ +-- Track public share usage by unique views per session per day with monthly reset +-- and enforce plan-based view caps when resolving short URLs. + +-- 1) Table to dedupe views by link + session + day +CREATE TABLE IF NOT EXISTS public.share_view_events ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + link_id UUID REFERENCES public.links(id) ON DELETE CASCADE, + owner_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE, + viewer_token TEXT NOT NULL, + viewed_on DATE NOT NULL DEFAULT CURRENT_DATE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS share_view_events_dedupe_idx + ON public.share_view_events (link_id, viewer_token, viewed_on); + +CREATE INDEX IF NOT EXISTS share_view_events_owner_idx + ON public.share_view_events (owner_id, viewed_on); + +-- 2) Align usage_limits reset to month start +ALTER TABLE public.usage_limits + ALTER COLUMN last_reset_at SET DEFAULT date_trunc('month', now()); + +-- 3) Ensure usage row exists +CREATE OR REPLACE FUNCTION ensure_usage_limits_row(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + INSERT INTO public.usage_limits (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 4) Reset helper for monthly public share views +CREATE OR REPLACE FUNCTION reset_public_share_usage(p_user_id UUID) +RETURNS VOID AS $$ +DECLARE + v_period_start TIMESTAMP WITH TIME ZONE; + v_current_reset TIMESTAMP WITH TIME ZONE; +BEGIN + v_period_start := date_trunc('month', now()); + + SELECT last_reset_at INTO v_current_reset + FROM public.usage_limits + WHERE user_id = p_user_id; + + IF v_current_reset IS NULL OR v_current_reset < v_period_start THEN + UPDATE public.usage_limits + SET public_share_count = 0, + last_reset_at = v_period_start + WHERE user_id = p_user_id; + + UPDATE public.profiles + SET public_share_count = 0 + WHERE id = p_user_id; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 5) Check limit now reflects monthly views +CREATE OR REPLACE FUNCTION check_public_share_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + PERFORM reset_public_share_usage(p_user_id); + + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 50 + WHEN 'started' THEN 1000 + WHEN 'pro' THEN NULL + END; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + RETURN json_build_object( + 'canShare', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 6) Record a view with per-session-per-day dedupe and cap enforcement +CREATE OR REPLACE FUNCTION record_public_share_view( + p_owner_id UUID, + p_link_id UUID, + p_viewer_token TEXT +) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_max_limit INTEGER; + v_current_count INTEGER; + v_inserted BOOLEAN; +BEGIN + PERFORM ensure_usage_limits_row(p_owner_id); + PERFORM reset_public_share_usage(p_owner_id); + + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_owner_id; + + v_max_limit := CASE v_plan + WHEN 'free' THEN 50 + WHEN 'started' THEN 1000 + WHEN 'pro' THEN NULL + END; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_owner_id; + + IF v_max_limit IS NOT NULL AND v_current_count >= v_max_limit THEN + RETURN json_build_object( + 'allowed', FALSE, + 'counted', FALSE, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); + END IF; + + INSERT INTO public.share_view_events (link_id, owner_id, viewer_token, viewed_on) + VALUES (p_link_id, p_owner_id, p_viewer_token, CURRENT_DATE) + ON CONFLICT (link_id, viewer_token, viewed_on) DO NOTHING; + v_inserted := FOUND; + + IF v_inserted THEN + UPDATE public.usage_limits SET public_share_count = public_share_count + 1 WHERE user_id = p_owner_id; + UPDATE public.profiles SET public_share_count = public_share_count + 1 WHERE id = p_owner_id; + END IF; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_owner_id; + + RETURN json_build_object( + 'allowed', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'counted', v_inserted, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION record_public_share_view(UUID, UUID, TEXT) TO anon, authenticated; From 3a1abf2e924f29ac71854aeeee6e8556f88f4e8c Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 09:05:09 -0300 Subject: [PATCH 005/102] :sparkles: feat: Introduce folder usage limits, integrate upgrade prompts into collection creation dialogs, and update usage limit service. --- src/actions/animations/create-collection.ts | 37 +++++ src/actions/animations/delete-collection.ts | 8 + src/actions/collections/create-collection.ts | 37 +++++ src/actions/collections/delete-collection.ts | 8 + src/components/usage-stats-widget/index.tsx | 11 +- .../create-collection-dialog/index.tsx | 138 +++++++++++------ .../create-collection-dialog/index.tsx | 144 ++++++++++++------ src/lib/services/usage-limits.ts | 78 +++++++++- 8 files changed, 363 insertions(+), 98 deletions(-) diff --git a/src/actions/animations/create-collection.ts b/src/actions/animations/create-collection.ts index b358f36..08d4915 100644 --- a/src/actions/animations/create-collection.ts +++ b/src/actions/animations/create-collection.ts @@ -6,6 +6,7 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { createAnimationCollection as createAnimationCollectionDb } from '@/lib/services/database/animations' import type { AnimationCollection, Animation } from '@/features/animations/dtos' +import type { PlanId } from '@/lib/config/plans' export type CreateAnimationCollectionInput = { title: string @@ -21,6 +22,34 @@ export async function createAnimationCollection( const { user, supabase } = await requireAuth() + const { data: folderLimit, error: folderLimitError } = await supabase.rpc('check_folder_limit', { + p_user_id: user.id + }) + + if (folderLimitError) { + console.error('Error checking folder limit:', folderLimitError) + return error('Failed to verify folder limit. Please try again.') + } + + const canCreateFolder = Boolean( + folderLimit?.can_create ?? + folderLimit?.canCreate ?? + folderLimit?.can_save ?? + folderLimit?.canSave ?? + false + ) + + if (!canCreateFolder) { + const plan = (folderLimit?.plan as PlanId | undefined) ?? 'free' + if (plan === 'free') { + return error('Free plan does not include folders. Upgrade to Started to organize your animations.') + } + if (plan === 'started') { + return error('You have reached your 10 folder limit. Upgrade to Pro for unlimited folders.') + } + return error('Folder limit reached. Please upgrade your plan.') + } + const data = await createAnimationCollectionDb({ user_id: user.id, title: sanitizedTitle, @@ -32,6 +61,14 @@ export async function createAnimationCollection( return error('Failed to create collection') } + const { error: incrementError } = await supabase.rpc('increment_folder_count', { + p_user_id: user.id + }) + + if (incrementError) { + console.error('Error incrementing folder count:', incrementError) + } + revalidatePath('/animations') revalidatePath('/animate') diff --git a/src/actions/animations/delete-collection.ts b/src/actions/animations/delete-collection.ts index e29e0cb..ab2382f 100644 --- a/src/actions/animations/delete-collection.ts +++ b/src/actions/animations/delete-collection.ts @@ -22,6 +22,14 @@ export async function deleteAnimationCollection( supabase }) + const { error: decrementError } = await supabase.rpc('decrement_folder_count', { + p_user_id: user.id + }) + + if (decrementError) { + console.error('Error decrementing folder count:', decrementError) + } + revalidatePath('/animations') revalidatePath('/animate') diff --git a/src/actions/collections/create-collection.ts b/src/actions/collections/create-collection.ts index 6dccb20..79e4307 100644 --- a/src/actions/collections/create-collection.ts +++ b/src/actions/collections/create-collection.ts @@ -5,6 +5,7 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertCollection } from '@/lib/services/database/collections' import type { Collection, Snippet } from '@/features/snippets/dtos' +import type { PlanId } from '@/lib/config/plans' export type CreateCollectionInput = { title: string @@ -27,6 +28,34 @@ export async function createCollection( const { user, supabase } = await requireAuth() + const { data: folderLimit, error: folderLimitError } = await supabase.rpc('check_folder_limit', { + p_user_id: user.id + }) + + if (folderLimitError) { + console.error('Error checking folder limit:', folderLimitError) + return error('Failed to verify folder limit. Please try again.') + } + + const canCreateFolder = Boolean( + folderLimit?.can_create ?? + folderLimit?.canCreate ?? + folderLimit?.can_save ?? + folderLimit?.canSave ?? + false + ) + + if (!canCreateFolder) { + const plan = (folderLimit?.plan as PlanId | undefined) ?? 'free' + if (plan === 'free') { + return error('Free plan does not include folders. Upgrade to Started to organize your snippets.') + } + if (plan === 'started') { + return error('You have reached your 10 folder limit. Upgrade to Pro for unlimited folders.') + } + return error('Folder limit reached. Please upgrade your plan.') + } + const data = await insertCollection({ user_id: user.id, title: sanitizedTitle, @@ -38,6 +67,14 @@ export async function createCollection( return error('Failed to create collection') } + const { error: incrementError } = await supabase.rpc('increment_folder_count', { + p_user_id: user.id + }) + + if (incrementError) { + console.error('Error incrementing folder count:', incrementError) + } + // Revalidate the collections list revalidatePath('/collections') revalidatePath('/') diff --git a/src/actions/collections/delete-collection.ts b/src/actions/collections/delete-collection.ts index cc2a348..0b80d67 100644 --- a/src/actions/collections/delete-collection.ts +++ b/src/actions/collections/delete-collection.ts @@ -27,6 +27,14 @@ export async function deleteCollection( supabase }) + const { error: decrementError } = await supabase.rpc('decrement_folder_count', { + p_user_id: user.id + }) + + if (decrementError) { + console.error('Error decrementing folder count:', decrementError) + } + // Revalidate relevant paths revalidatePath('/collections') revalidatePath('/') diff --git a/src/components/usage-stats-widget/index.tsx b/src/components/usage-stats-widget/index.tsx index cbc1446..68498ae 100644 --- a/src/components/usage-stats-widget/index.tsx +++ b/src/components/usage-stats-widget/index.tsx @@ -100,6 +100,16 @@ export function UsageStatsWidget({ current={usage.animations.current} max={usage.animations.max} /> + + {usage.plan !== "pro" && onUpgrade && ( - - - + <> + + + {children} + + + Use collections to organize your animations + + +
+ + ( + + + Collection name + + + {fieldState.invalid && ( + + )} + + )} + /> + +
+ + + + +
+
+ ); } diff --git a/src/features/snippets/create-collection-dialog/index.tsx b/src/features/snippets/create-collection-dialog/index.tsx index ae5ff1b..5d7e6bb 100644 --- a/src/features/snippets/create-collection-dialog/index.tsx +++ b/src/features/snippets/create-collection-dialog/index.tsx @@ -23,9 +23,11 @@ import { } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { UpgradeDialog } from "@/components/ui/upgrade-dialog"; import { createCollection } from "../queries"; import { Collection } from "../dtos"; import { analytics } from "@/lib/services/tracking"; +import { useUserUsage } from "@/features/user/queries"; const formSchema = z.object({ title: z.string().min(1, { message: "Collection name is required." }), @@ -37,7 +39,9 @@ export function CreateCollectionDialog({ children: React.ReactNode; }) { const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); const user = useUserStore((state) => state.user); + const { data: usage } = useUserUsage(user?.id); const queryClient = useQueryClient(); const queryKey = ["collections"]; @@ -69,59 +73,107 @@ export function CreateCollectionDialog({ }, }); + const folderLimit = usage?.folders; + const folderLimitReached = + folderLimit?.max !== null && + typeof folderLimit?.max !== "undefined" && + folderLimit.current >= folderLimit.max; + function onSubmit(data: z.infer) { + if (folderLimitReached) { + analytics.track("limit_reached", { + limit_type: "folders", + current: folderLimit.current, + max: folderLimit.max, + }); + setIsDialogOpen(false); + setIsUpgradeOpen(true); + analytics.track("upgrade_prompt_shown", { + limit_type: "folders", + trigger: "create_collection", + }); + return; + } + handleCreateCollection({ title: data.title, user_id: user?.id ?? "", }); } + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen && folderLimitReached) { + analytics.track("limit_reached", { + limit_type: "folders", + current: folderLimit?.current ?? 0, + max: folderLimit?.max ?? 0, + }); + setIsUpgradeOpen(true); + analytics.track("upgrade_prompt_shown", { + limit_type: "folders", + trigger: "create_collection", + }); + return; + } + setIsDialogOpen(nextOpen); + }; + return ( - - {children} - - - Use collections to organize your snippets. - - -
- - ( - - - Collection name - - - {fieldState.invalid && ( - - )} - - )} - /> - -
- - - - -
-
+ <> + + + {children} + + + Use collections to organize your snippets. + + +
+ + ( + + + Collection name + + + {fieldState.invalid && ( + + )} + + )} + /> + +
+ + + + +
+
+ ); } diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index 17db60e..77c1353 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -5,8 +5,8 @@ import { getPlanConfig, type PlanId } from "@/lib/config/plans"; type Supabase = SupabaseClient; -type UsageLimitKind = "snippets" | "animations" | "publicShares"; -type PlanLimitKey = "maxSnippets" | "maxAnimations" | "shareAsPublicURL"; +type UsageLimitKind = "snippets" | "animations" | "folders" | "publicShares"; +type PlanLimitKey = "maxSnippets" | "maxAnimations" | "maxSnippetsFolder" | "shareAsPublicURL"; export type UsageLimitCheck = { canSave: boolean; @@ -26,6 +26,10 @@ export type UsageSummary = { current: number; max: number | null; }; + folders: { + current: number; + max: number | null; + }; publicShares: { current: number; max: number | null; @@ -36,9 +40,21 @@ export type UsageSummary = { const RPC_MAP: Record< UsageLimitKind, { - check: "check_snippet_limit" | "check_animation_limit" | "check_public_share_limit"; - increment: "increment_snippet_count" | "increment_animation_count" | "increment_public_share_count"; - decrement: "decrement_snippet_count" | "decrement_animation_count" | "decrement_public_share_count"; + check: + | "check_snippet_limit" + | "check_animation_limit" + | "check_folder_limit" + | "check_public_share_limit"; + increment: + | "increment_snippet_count" + | "increment_animation_count" + | "increment_folder_count" + | "increment_public_share_count"; + decrement: + | "decrement_snippet_count" + | "decrement_animation_count" + | "decrement_folder_count" + | "decrement_public_share_count"; planKey: PlanLimitKey; } > = { @@ -54,6 +70,12 @@ const RPC_MAP: Record< decrement: "decrement_animation_count", planKey: "maxAnimations", }, + folders: { + check: "check_folder_limit", + increment: "increment_folder_count", + decrement: "decrement_folder_count", + planKey: "maxSnippetsFolder", + }, publicShares: { check: "check_public_share_limit", increment: "increment_public_share_count", @@ -69,6 +91,9 @@ type LimitRpcName = | (typeof RPC_MAP)["animations"]["check"] | (typeof RPC_MAP)["animations"]["increment"] | (typeof RPC_MAP)["animations"]["decrement"] + | (typeof RPC_MAP)["folders"]["check"] + | (typeof RPC_MAP)["folders"]["increment"] + | (typeof RPC_MAP)["folders"]["decrement"] | (typeof RPC_MAP)["publicShares"]["check"] | (typeof RPC_MAP)["publicShares"]["increment"] | (typeof RPC_MAP)["publicShares"]["decrement"]; @@ -87,7 +112,9 @@ const normalizeLimitPayload = ( ? planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets : limitKey === "maxAnimations" ? planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations - : planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL; + : limitKey === "maxSnippetsFolder" + ? planConfig.maxSnippetsFolder === Infinity ? null : planConfig.maxSnippetsFolder + : planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL; const max = payload?.max === null || typeof payload?.max === "undefined" @@ -95,7 +122,13 @@ const normalizeLimitPayload = ( : Number(payload.max); return { - canSave: Boolean(payload?.can_save ?? payload?.canSave ?? true), + canSave: Boolean( + payload?.can_save ?? + payload?.canSave ?? + payload?.can_create ?? + payload?.canCreate ?? + true + ), current: Number(payload?.current ?? 0), max, plan, @@ -140,6 +173,13 @@ export const checkPublicShareLimit = async ( return callLimitRpc(supabase, RPC_MAP.publicShares.check, userId, "publicShares"); }; +export const checkFolderLimit = async ( + supabase: Supabase, + userId: string +): Promise => { + return callLimitRpc(supabase, RPC_MAP.folders.check, userId, "folders"); +}; + export const incrementUsageCount = async ( supabase: Supabase, userId: string, @@ -174,7 +214,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const { data: usage, error: usageError } = await supabase .from("usage_limits") - .select("snippet_count, animation_count, public_share_count, last_reset_at") + .select("snippet_count, animation_count, folder_count, public_share_count, last_reset_at") .eq("user_id", userId) .maybeSingle(); @@ -187,9 +227,16 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const [ { count: actualSnippetCount, error: snippetCountError }, { count: actualAnimationCount, error: animationCountError }, + { count: actualFolderCount, error: folderCountError }, + { count: actualAnimationFolderCount, error: animationFolderCountError }, ] = await Promise.all([ supabase.from("snippet").select("id", { count: "exact", head: true }).eq("user_id", userId), supabase.from("animation").select("id", { count: "exact", head: true }).eq("user_id", userId), + supabase.from("collection").select("id", { count: "exact", head: true }).eq("user_id", userId), + supabase + .from("animation_collection") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId), ]); if (snippetCountError) { @@ -198,14 +245,25 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< if (animationCountError) { console.error("Failed to count animations for usage", animationCountError); } + if (folderCountError) { + console.error("Failed to count snippet folders for usage", folderCountError); + } + if (animationFolderCountError) { + console.error("Failed to count animation folders for usage", animationFolderCountError); + } const plan = (profile.plan as PlanId | null) ?? "free"; const planConfig = getPlanConfig(plan); const snippetCountFromLimits = usage?.snippet_count ?? 0; const animationCountFromLimits = usage?.animation_count ?? 0; + const folderCountFromLimits = usage?.folder_count ?? 0; const publicShareCountFromLimits = usage?.public_share_count ?? 0; const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); + const folderCount = Math.max( + folderCountFromLimits, + (actualFolderCount ?? 0) + (actualAnimationFolderCount ?? 0) + ); const publicShareCount = publicShareCountFromLimits; return { @@ -218,6 +276,10 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< current: animationCount, max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, }, + folders: { + current: folderCount, + max: planConfig.maxSnippetsFolder === Infinity ? null : planConfig.maxSnippetsFolder, + }, publicShares: { current: publicShareCount, max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, From 226b5d562acb1fa23f4fa6187d107b1dba025e0c Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 10:17:55 -0300 Subject: [PATCH 006/102] :sparkles: feat: Use Supabase service role client in Stripe webhook and add a utility for resolving subscription plan IDs. --- src/app/api/webhooks/stripe/route.ts | 27 +++++++++++++++++---------- src/utils/supabase/admin.ts | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 src/utils/supabase/admin.ts diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 9d817b7..e780f46 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@/utils/supabase/server'; +import { createServiceRoleClient } from '@/utils/supabase/admin'; import { constructWebhookEvent } from '@/lib/services/stripe'; import type { PlanId } from '@/lib/config/plans'; import Stripe from 'stripe'; @@ -18,9 +18,21 @@ function getPlanIdFromPriceId(priceId: string): PlanId | null { return priceIdMap[priceId] || null; } +function resolvePlan(subscription: Stripe.Subscription): PlanId | null { + const metadataPlan = subscription.metadata?.plan; + if (metadataPlan === 'started' || metadataPlan === 'pro') { + return metadataPlan; + } + + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) return null; + + return getPlanIdFromPriceId(priceId); +} + // Handle subscription created or updated async function handleSubscriptionChange(subscription: Stripe.Subscription) { - const supabase = await createClient(); + const supabase = createServiceRoleClient(); const userId = subscription.metadata.userId; if (!userId) { @@ -29,14 +41,9 @@ async function handleSubscriptionChange(subscription: Stripe.Subscription) { } const priceId = subscription.items.data[0]?.price.id; - if (!priceId) { - console.error('No price ID in subscription'); - return; - } - - const planId = getPlanIdFromPriceId(priceId); + const planId = resolvePlan(subscription); if (!planId) { - console.error('Could not determine plan from price ID:', priceId); + console.error('Could not determine plan from subscription metadata/price'); return; } @@ -66,7 +73,7 @@ async function handleSubscriptionChange(subscription: Stripe.Subscription) { // Handle subscription deleted (downgrade to free) async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const supabase = await createClient(); + const supabase = createServiceRoleClient(); const userId = subscription.metadata.userId; if (!userId) { diff --git a/src/utils/supabase/admin.ts b/src/utils/supabase/admin.ts new file mode 100644 index 0000000..2a5a1da --- /dev/null +++ b/src/utils/supabase/admin.ts @@ -0,0 +1,22 @@ +import { createClient } from '@supabase/supabase-js'; +import { Database } from '@/types/database'; + +/** + * Service role Supabase client for backend-only tasks (bypasses RLS). + */ +export function createServiceRoleClient() { + if (!process.env.SUPABASE_SERVICE_ROLE_KEY) { + throw new Error('SUPABASE_SERVICE_ROLE_KEY is not set'); + } + + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + } + ); +} From 21e9994fe24418f3cc62ce9db9eb1565ad576455 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 11:05:08 -0300 Subject: [PATCH 007/102] :sparkles: feat: Centralize SQL plan limit definitions and add a validation script with a CI workflow to ensure consistency. --- .github/workflows/plan-validation.yml | 31 ++++ package.json | 3 +- scripts/validate-plan-limits.js | 138 ++++++++++++++++++ .../20260417100000_align_plan_limits.sql | 101 +++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/plan-validation.yml create mode 100644 scripts/validate-plan-limits.js create mode 100644 supabase/migrations/20260417100000_align_plan_limits.sql diff --git a/.github/workflows/plan-validation.yml b/.github/workflows/plan-validation.yml new file mode 100644 index 0000000..c778840 --- /dev/null +++ b/.github/workflows/plan-validation.yml @@ -0,0 +1,31 @@ +name: Plan Limits + +on: + pull_request: + push: + branches: + - main + +jobs: + validate-plan-limits: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate plan limits + run: pnpm run validate:plans diff --git a/package.json b/package.json index 2f40ef7..5025776 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "next build", "start": "next start", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", - "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix && pnpm run lint" + "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix && pnpm run lint", + "validate:plans": "node scripts/validate-plan-limits.js" }, "dependencies": { "@arcjet/inspect": "1.0.0-beta.15", diff --git a/scripts/validate-plan-limits.js b/scripts/validate-plan-limits.js new file mode 100644 index 0000000..9acd3d8 --- /dev/null +++ b/scripts/validate-plan-limits.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.join(__dirname, '..'); +const plansPath = path.join(projectRoot, 'src/lib/config/plans.ts'); +const migrationsDir = path.join(projectRoot, 'supabase', 'migrations'); + +const planIds = ['free', 'started', 'pro']; + +function readFile(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function normalizeLimit(value) { + if (value === undefined || value === null) return null; + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + const trimmed = value.toString().trim(); + if (trimmed.toLowerCase() === 'infinity' || trimmed.toLowerCase() === 'null') { + return null; + } + + const numeric = Number(trimmed.replace(/[^0-9.-]/g, '')); + return Number.isNaN(numeric) ? null : numeric; +} + +function extractShareLimitsFromTs() { + const content = readFile(plansPath); + return planIds.reduce((acc, planId) => { + const planBlock = new RegExp( + `${planId}\\s*:\\s*{[\\s\\S]*?shareAsPublicURL:\\s*([^,\\n]+)`, + 'm' + ); + const match = content.match(planBlock); + if (!match) { + throw new Error(`Could not find shareAsPublicURL for plan "${planId}" in ${plansPath}`); + } + + acc[planId] = normalizeLimit(match[1]); + return acc; + }, {}); +} + +function findLatestMigrationContaining(keyword) { + const files = fs.readdirSync(migrationsDir).filter((file) => file.endsWith('.sql')); + const matching = files + .filter((file) => readFile(path.join(migrationsDir, file)).includes(keyword)) + .sort(); + + if (!matching.length) { + throw new Error(`No migration found containing "${keyword}" in ${migrationsDir}`); + } + + return path.join(migrationsDir, matching[matching.length - 1]); +} + +function getFunctionBody(sqlContent, functionName) { + const fnRegex = new RegExp( + `FUNCTION\\s+${functionName}\\s*\\([\\s\\S]*?\\$\\$(.*?)\\$\\$`, + 's' + ); + const match = sqlContent.match(fnRegex); + if (!match) { + throw new Error(`Function "${functionName}" not found in provided SQL content.`); + } + + return match[1]; +} + +function extractShareLimitsFromGetPlanLimits(sqlContent) { + const body = getFunctionBody(sqlContent, 'get_plan_limits'); + + return planIds.reduce((acc, planId) => { + const planRegex = new RegExp( + `WHEN\\s+'${planId}'\\s+THEN\\s+'({[\\s\\S]*?})'::json`, + 'i' + ); + const planMatch = body.match(planRegex); + + if (!planMatch) { + throw new Error(`Plan "${planId}" branch missing in get_plan_limits.`); + } + + const planJson = JSON.parse(planMatch[1]); + acc[planId] = normalizeLimit(planJson.shareAsPublicURL); + return acc; + }, {}); +} + +function assertFunctionReferencesGetPlanLimits(sqlContent, functionName) { + const body = getFunctionBody(sqlContent, functionName); + if (!body.includes('get_plan_limits')) { + throw new Error( + `Function "${functionName}" should reference get_plan_limits to stay in sync with plan configuration.` + ); + } +} + +function main() { + const tsLimits = extractShareLimitsFromTs(); + + const planLimitsPath = findLatestMigrationContaining('FUNCTION get_plan_limits'); + const planLimitsSql = readFile(planLimitsPath); + const sqlLimits = extractShareLimitsFromGetPlanLimits(planLimitsSql); + + const mismatches = []; + planIds.forEach((planId) => { + if (tsLimits[planId] !== sqlLimits[planId]) { + mismatches.push( + `${planId}: TypeScript=${tsLimits[planId]} vs SQL=${sqlLimits[planId]} (migration ${path.basename( + planLimitsPath + )})` + ); + } + }); + + const checkLimitPath = findLatestMigrationContaining('FUNCTION check_public_share_limit'); + const checkLimitSql = readFile(checkLimitPath); + assertFunctionReferencesGetPlanLimits(checkLimitSql, 'check_public_share_limit'); + + const recordViewPath = findLatestMigrationContaining('FUNCTION record_public_share_view'); + const recordViewSql = readFile(recordViewPath); + assertFunctionReferencesGetPlanLimits(recordViewSql, 'record_public_share_view'); + + if (mismatches.length) { + console.error('Plan limit mismatch detected:'); + mismatches.forEach((line) => console.error(`- ${line}`)); + process.exit(1); + } + + console.log('Plan limits are in sync between plans.ts and SQL functions.'); +} + +main(); diff --git a/supabase/migrations/20260417100000_align_plan_limits.sql b/supabase/migrations/20260417100000_align_plan_limits.sql new file mode 100644 index 0000000..eec5567 --- /dev/null +++ b/supabase/migrations/20260417100000_align_plan_limits.sql @@ -0,0 +1,101 @@ +-- Align plan limits with application configuration and centralize lookups. + +CREATE OR REPLACE FUNCTION get_plan_limits(plan_type plan_type) +RETURNS JSON AS $$ +BEGIN + RETURN CASE plan_type + WHEN 'free' THEN '{"maxSnippets": 0, "maxAnimations": 0, "maxSlidesPerAnimation": 3, "maxSnippetsFolder": 0, "maxVideoExportCount": 0, "shareAsPublicURL": 50}'::json + WHEN 'started' THEN '{"maxSnippets": 50, "maxAnimations": 50, "maxSlidesPerAnimation": 10, "maxSnippetsFolder": 10, "maxVideoExportCount": 50, "shareAsPublicURL": 1000}'::json + WHEN 'pro' THEN '{"maxSnippets": null, "maxAnimations": null, "maxSlidesPerAnimation": null, "maxSnippetsFolder": null, "maxVideoExportCount": null, "shareAsPublicURL": null}'::json + END; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Public share limits now reuse get_plan_limits to avoid drift across environments. +CREATE OR REPLACE FUNCTION check_public_share_limit(p_user_id UUID) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_plan_limits JSON; + v_current_count INTEGER; + v_max_limit INTEGER; +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + PERFORM reset_public_share_usage(p_user_id); + + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_user_id; + + v_plan_limits := get_plan_limits(v_plan); + v_max_limit := (v_plan_limits->>'shareAsPublicURL')::INTEGER; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_user_id; + + RETURN json_build_object( + 'canShare', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Record public share views using centralized plan limits. +CREATE OR REPLACE FUNCTION record_public_share_view( + p_owner_id UUID, + p_link_id UUID, + p_viewer_token TEXT +) +RETURNS JSON AS $$ +DECLARE + v_plan plan_type; + v_plan_limits JSON; + v_max_limit INTEGER; + v_current_count INTEGER; + v_inserted BOOLEAN; +BEGIN + PERFORM ensure_usage_limits_row(p_owner_id); + PERFORM reset_public_share_usage(p_owner_id); + + SELECT plan INTO v_plan FROM public.profiles WHERE id = p_owner_id; + v_plan_limits := get_plan_limits(v_plan); + v_max_limit := (v_plan_limits->>'shareAsPublicURL')::INTEGER; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_owner_id; + + IF v_max_limit IS NOT NULL AND v_current_count >= v_max_limit THEN + RETURN json_build_object( + 'allowed', FALSE, + 'counted', FALSE, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); + END IF; + + INSERT INTO public.share_view_events (link_id, owner_id, viewer_token, viewed_on) + VALUES (p_link_id, p_owner_id, p_viewer_token, CURRENT_DATE) + ON CONFLICT (link_id, viewer_token, viewed_on) DO NOTHING; + v_inserted := FOUND; + + IF v_inserted THEN + UPDATE public.usage_limits SET public_share_count = public_share_count + 1 WHERE user_id = p_owner_id; + UPDATE public.profiles SET public_share_count = public_share_count + 1 WHERE id = p_owner_id; + END IF; + + SELECT public_share_count INTO v_current_count + FROM public.usage_limits + WHERE user_id = p_owner_id; + + RETURN json_build_object( + 'allowed', v_max_limit IS NULL OR v_current_count < v_max_limit, + 'counted', v_inserted, + 'current', v_current_count, + 'max', v_max_limit, + 'plan', v_plan + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; From bcf7b65c9a4d99dfc1aeda262ca7464749ee4efa Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 11:48:51 -0300 Subject: [PATCH 008/102] :sparkles: feat: introduce video export limits with usage tracking, upgrade prompts, and harden function search paths --- .../animation/animation-download-menu.tsx | 142 +++++++++++++++++- src/lib/services/usage-limits.ts | 50 +++++- ...0417104000_harden_function_search_path.sql | 6 + 3 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260417104000_harden_function_search_path.sql diff --git a/src/features/animation/animation-download-menu.tsx b/src/features/animation/animation-download-menu.tsx index 1802bbf..af81249 100644 --- a/src/features/animation/animation-download-menu.tsx +++ b/src/features/animation/animation-download-menu.tsx @@ -15,6 +15,10 @@ import { useAnimationStore, useEditorStore, useUserStore } from "@/app/store"; import { calculateTotalDuration } from "@/features/animation"; import { trackAnimationEvent } from "@/features/animation/analytics"; import { LoginDialog } from "@/features/login"; +import { UpgradeDialog } from "@/components/ui/upgrade-dialog"; +import { useUserUsage } from "@/features/user/queries"; +import { getPlanConfig, type PlanId } from "@/lib/config/plans"; +import { createClient } from "@/utils/supabase/client"; import { ExportOverlay } from "./share-dialog/export-overlay"; import { GifExporter } from "./gif-exporter"; @@ -39,6 +43,14 @@ export const AnimationDownloadMenu = () => { const [dropdownOpen, setDropdownOpen] = useState(false); const [currentExportFormat, setCurrentExportFormat] = useState<"mp4" | "webm" | "gif">("mp4"); const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); + const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); + const [upgradeContext, setUpgradeContext] = useState<{ + current?: number; + max?: number | null; + plan?: PlanId; + }>({}); + const supabase = useMemo(() => createClient(), []); + const { data: usage } = useUserUsage(user?.id ?? undefined); useEffect(() => { if (user && isLoginDialogOpen) { @@ -64,7 +76,112 @@ export const AnimationDownloadMenu = () => { loadTimestampRef.current = Date.now(); } - const handleExport = (format: "mp4" | "webm" | "gif" = "mp4") => { + const buildVideoLimitMessage = ({ + plan, + current, + max, + }: { + plan?: PlanId | null; + current?: number | null; + max?: number | null; + }) => { + const safePlan = plan ?? "free"; + const planConfig = getPlanConfig(safePlan); + const effectiveMax = + typeof max === "number" + ? max + : planConfig.maxVideoExportCount === Infinity + ? null + : planConfig.maxVideoExportCount; + + if (!effectiveMax) { + return "Video export limit reached. Please upgrade your plan."; + } + + return `You've reached your video export limit (${Math.min( + current ?? effectiveMax, + effectiveMax + )}/${effectiveMax}). Upgrade to continue exporting videos.`; + }; + + const openUpgradeForVideoExports = (payload: { current?: number; max?: number | null; plan?: PlanId }) => { + setUpgradeContext(payload); + setIsUpgradeOpen(true); + }; + + const verifyVideoExportAllowance = async () => { + if (!user?.id) return false; + + const plan = usage?.plan ?? "free"; + const current = usage?.videoExports?.current ?? 0; + const max = usage?.videoExports?.max ?? getPlanConfig(plan).maxVideoExportCount ?? 0; + + if (max !== null && max <= 0) { + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + + // If we know the user already exhausted the limit from cached usage, prompt immediately. + if (max !== null && current >= max) { + trackAnimationEvent("limit_reached", user, { + limit_type: "video_exports", + current, + max, + }); + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + + const { data, error } = await supabase.rpc("check_video_export_limit", { + p_user_id: user.id, + }); + + if (error) { + console.error("Error checking video export limit:", error); + // If the function is missing (404) or fails, fall back to local plan limits to avoid a silent bypass. + if (max !== null && current >= max) { + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + toast.error("Unable to verify video export limit. Please try again."); + return false; + } + + const canExport = Boolean(data?.canExport ?? data?.can_export ?? false); + const rpcPlan = (data?.plan as PlanId | undefined) ?? plan; + const rpcCurrent = typeof data?.current === "number" ? data.current : current; + const rpcMax = + data?.max === null || typeof data?.max === "undefined" ? max : Number(data.max); + + if (!canExport) { + trackAnimationEvent("export_blocked_limit", user, { + limit: "video_exports", + plan: rpcPlan, + current: rpcCurrent, + max: rpcMax, + source: "download_menu", + }); + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ plan: rpcPlan, current: rpcCurrent, max: rpcMax }); + toast.error(buildVideoLimitMessage({ plan: rpcPlan, current: rpcCurrent, max: rpcMax })); + return false; + } + + return true; + }; + + const handleExport = async (format: "mp4" | "webm" | "gif" = "mp4") => { if (!user) { setDropdownOpen(false); setIsLoginDialogOpen(true); @@ -79,6 +196,9 @@ export const AnimationDownloadMenu = () => { return; } + const allowed = await verifyVideoExportAllowance(); + if (!allowed) return; + setCurrentExportFormat(format); setDropdownOpen(false); setIsExporting(true); @@ -117,7 +237,7 @@ export const AnimationDownloadMenu = () => { }); }; - const onExportComplete = (blob: Blob) => { + const onExportComplete = async (blob: Blob) => { setIsExporting(false); setExportProgress(0); setCancelExport(false); @@ -143,12 +263,30 @@ export const AnimationDownloadMenu = () => { source: "download_menu", }); + if (user?.id) { + const { error } = await supabase.rpc("increment_video_export_count", { + p_user_id: user.id, + }); + + if (error) { + console.error("Error incrementing video export count:", error); + } + } + toast.success(`${currentExportFormat.toUpperCase()} downloaded successfully.`); }; return ( <> + diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index 77c1353..286e885 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -5,8 +5,13 @@ import { getPlanConfig, type PlanId } from "@/lib/config/plans"; type Supabase = SupabaseClient; -type UsageLimitKind = "snippets" | "animations" | "folders" | "publicShares"; -type PlanLimitKey = "maxSnippets" | "maxAnimations" | "maxSnippetsFolder" | "shareAsPublicURL"; +type UsageLimitKind = "snippets" | "animations" | "folders" | "publicShares" | "videoExports"; +type PlanLimitKey = + | "maxSnippets" + | "maxAnimations" + | "maxSnippetsFolder" + | "shareAsPublicURL" + | "maxVideoExportCount"; export type UsageLimitCheck = { canSave: boolean; @@ -30,6 +35,10 @@ export type UsageSummary = { current: number; max: number | null; }; + videoExports: { + current: number; + max: number | null; + }; publicShares: { current: number; max: number | null; @@ -44,13 +53,15 @@ const RPC_MAP: Record< | "check_snippet_limit" | "check_animation_limit" | "check_folder_limit" - | "check_public_share_limit"; + | "check_public_share_limit" + | "check_video_export_limit"; increment: | "increment_snippet_count" | "increment_animation_count" | "increment_folder_count" + | "increment_video_export_count" | "increment_public_share_count"; - decrement: + decrement?: | "decrement_snippet_count" | "decrement_animation_count" | "decrement_folder_count" @@ -76,6 +87,11 @@ const RPC_MAP: Record< decrement: "decrement_folder_count", planKey: "maxSnippetsFolder", }, + videoExports: { + check: "check_video_export_limit", + increment: "increment_video_export_count", + planKey: "maxVideoExportCount", + }, publicShares: { check: "check_public_share_limit", increment: "increment_public_share_count", @@ -94,6 +110,8 @@ type LimitRpcName = | (typeof RPC_MAP)["folders"]["check"] | (typeof RPC_MAP)["folders"]["increment"] | (typeof RPC_MAP)["folders"]["decrement"] + | (typeof RPC_MAP)["videoExports"]["check"] + | (typeof RPC_MAP)["videoExports"]["increment"] | (typeof RPC_MAP)["publicShares"]["check"] | (typeof RPC_MAP)["publicShares"]["increment"] | (typeof RPC_MAP)["publicShares"]["decrement"]; @@ -125,6 +143,8 @@ const normalizeLimitPayload = ( canSave: Boolean( payload?.can_save ?? payload?.canSave ?? + payload?.can_export ?? + payload?.canExport ?? payload?.can_create ?? payload?.canCreate ?? true @@ -166,6 +186,13 @@ export const checkAnimationLimit = async ( return callLimitRpc(supabase, RPC_MAP.animations.check, userId, "animations"); }; +export const checkVideoExportLimit = async ( + supabase: Supabase, + userId: string +): Promise => { + return callLimitRpc(supabase, RPC_MAP.videoExports.check, userId, "videoExports"); +}; + export const checkPublicShareLimit = async ( supabase: Supabase, userId: string @@ -197,7 +224,11 @@ export const decrementUsageCount = async ( userId: string, kind: UsageLimitKind ): Promise => { - return callLimitRpc(supabase, RPC_MAP[kind].decrement, userId, kind); + const decrementFn = RPC_MAP[kind].decrement; + if (!decrementFn) { + throw new Error(`No decrement RPC configured for usage kind: ${kind}`); + } + return callLimitRpc(supabase, decrementFn, userId, kind); }; export const getUserUsage = async (supabase: Supabase, userId: string): Promise => { @@ -214,7 +245,9 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const { data: usage, error: usageError } = await supabase .from("usage_limits") - .select("snippet_count, animation_count, folder_count, public_share_count, last_reset_at") + .select( + "snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at" + ) .eq("user_id", userId) .maybeSingle(); @@ -257,6 +290,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const snippetCountFromLimits = usage?.snippet_count ?? 0; const animationCountFromLimits = usage?.animation_count ?? 0; const folderCountFromLimits = usage?.folder_count ?? 0; + const videoExportCountFromLimits = usage?.video_export_count ?? 0; const publicShareCountFromLimits = usage?.public_share_count ?? 0; const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); @@ -280,6 +314,10 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< current: folderCount, max: planConfig.maxSnippetsFolder === Infinity ? null : planConfig.maxSnippetsFolder, }, + videoExports: { + current: videoExportCountFromLimits, + max: planConfig.maxVideoExportCount === Infinity ? null : planConfig.maxVideoExportCount, + }, publicShares: { current: publicShareCount, max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, diff --git a/supabase/migrations/20260417104000_harden_function_search_path.sql b/supabase/migrations/20260417104000_harden_function_search_path.sql new file mode 100644 index 0000000..a403a02 --- /dev/null +++ b/supabase/migrations/20260417104000_harden_function_search_path.sql @@ -0,0 +1,6 @@ +-- Ensure immutable search_path for security-sensitive functions. + +ALTER FUNCTION public.get_plan_limits(plan_type) SET search_path = public, extensions, pg_temp; +ALTER FUNCTION public.check_public_share_limit(UUID) SET search_path = public, extensions, pg_temp; +ALTER FUNCTION public.record_public_share_view(UUID, UUID, TEXT) SET search_path = public, extensions, pg_temp; +ALTER FUNCTION public.sync_stripe_subscription(UUID, plan_type, TEXT, TEXT, TEXT, TEXT) SET search_path = public, extensions, pg_temp; From eaa7ae597ed6735ee6b77fb7f471197d6bd61841 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 11:56:44 -0300 Subject: [PATCH 009/102] :sparkles: feat: Implement video export usage limits and add decrement function for accurate tracking. --- .../animation/animation-download-menu.tsx | 796 +++++++++--------- .../animation/enhanced-share-dialog.tsx | 24 +- src/features/animation/video-exporter.tsx | 2 +- src/features/share-code/index.tsx | 11 +- src/lib/services/usage-limits.ts | 5 +- ...60417113000_add_decrement_video_export.sql | 15 + 6 files changed, 428 insertions(+), 425 deletions(-) create mode 100644 supabase/migrations/20260417113000_add_decrement_video_export.sql diff --git a/src/features/animation/animation-download-menu.tsx b/src/features/animation/animation-download-menu.tsx index af81249..c0c1f51 100644 --- a/src/features/animation/animation-download-menu.tsx +++ b/src/features/animation/animation-download-menu.tsx @@ -5,10 +5,10 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip } from "@/components/ui/tooltip"; import { useAnimationStore, useEditorStore, useUserStore } from "@/app/store"; @@ -23,405 +23,399 @@ import { ExportOverlay } from "./share-dialog/export-overlay"; import { GifExporter } from "./gif-exporter"; export const AnimationDownloadMenu = () => { - const user = useUserStore((state) => state.user); - const slides = useAnimationStore((state) => state.slides); - const animationSettings = useAnimationStore((state) => state.animationSettings); - const totalDuration = useMemo(() => calculateTotalDuration(slides), [slides]); - - const backgroundTheme = useEditorStore((state) => state.backgroundTheme); - const fontFamily = useEditorStore((state) => state.fontFamily); - const fontSize = useEditorStore((state) => state.fontSize); - const showBackground = useEditorStore((state) => state.showBackground); - - const loadTimestampRef = useRef(null); - const firstExportTrackedRef = useRef(false); - - // Export state - const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(0); - const [cancelExport, setCancelExport] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [currentExportFormat, setCurrentExportFormat] = useState<"mp4" | "webm" | "gif">("mp4"); - const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); - const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); - const [upgradeContext, setUpgradeContext] = useState<{ - current?: number; - max?: number | null; - plan?: PlanId; - }>({}); - const supabase = useMemo(() => createClient(), []); - const { data: usage } = useUserUsage(user?.id ?? undefined); - - useEffect(() => { - if (user && isLoginDialogOpen) { - setIsLoginDialogOpen(false); - } - }, [user, isLoginDialogOpen]); - - const serializedSlides = useMemo( - () => - slides.map((slide) => ({ - id: slide.id, - code: slide.code, - title: slide.title, - language: slide.language, - autoDetectLanguage: slide.autoDetectLanguage, - duration: slide.duration, - })), - [slides] - ); + const user = useUserStore((state) => state.user); + const slides = useAnimationStore((state) => state.slides); + const animationSettings = useAnimationStore((state) => state.animationSettings); + const totalDuration = useMemo(() => calculateTotalDuration(slides), [slides]); + + const backgroundTheme = useEditorStore((state) => state.backgroundTheme); + const fontFamily = useEditorStore((state) => state.fontFamily); + const fontSize = useEditorStore((state) => state.fontSize); + const showBackground = useEditorStore((state) => state.showBackground); + + const loadTimestampRef = useRef(null); + const firstExportTrackedRef = useRef(false); + + // Export state + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + const [cancelExport, setCancelExport] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [currentExportFormat, setCurrentExportFormat] = useState<"mp4" | "webm" | "gif">("mp4"); + const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); + const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); + const [upgradeContext, setUpgradeContext] = useState<{ + current?: number; + max?: number | null; + plan?: PlanId; + }>({}); + const supabase = useMemo(() => createClient(), []); + const { data: usage } = useUserUsage(user?.id ?? undefined); + + const serializedSlides = useMemo( + () => + slides.map((slide) => ({ + id: slide.id, + code: slide.code, + title: slide.title, + language: slide.language, + autoDetectLanguage: slide.autoDetectLanguage, + duration: slide.duration, + })), + [slides] + ); // Track load timestamp - if (!loadTimestampRef.current) { - loadTimestampRef.current = Date.now(); - } - - const buildVideoLimitMessage = ({ - plan, - current, - max, - }: { - plan?: PlanId | null; - current?: number | null; - max?: number | null; - }) => { - const safePlan = plan ?? "free"; - const planConfig = getPlanConfig(safePlan); - const effectiveMax = - typeof max === "number" - ? max - : planConfig.maxVideoExportCount === Infinity - ? null - : planConfig.maxVideoExportCount; - - if (!effectiveMax) { - return "Video export limit reached. Please upgrade your plan."; - } - - return `You've reached your video export limit (${Math.min( - current ?? effectiveMax, - effectiveMax - )}/${effectiveMax}). Upgrade to continue exporting videos.`; - }; - - const openUpgradeForVideoExports = (payload: { current?: number; max?: number | null; plan?: PlanId }) => { - setUpgradeContext(payload); - setIsUpgradeOpen(true); - }; - - const verifyVideoExportAllowance = async () => { - if (!user?.id) return false; - - const plan = usage?.plan ?? "free"; - const current = usage?.videoExports?.current ?? 0; - const max = usage?.videoExports?.max ?? getPlanConfig(plan).maxVideoExportCount ?? 0; - - if (max !== null && max <= 0) { - trackAnimationEvent("upgrade_prompt_shown", user, { - limit_type: "video_exports", - trigger: "download_menu", - }); - openUpgradeForVideoExports({ current, max, plan }); - return false; - } - - // If we know the user already exhausted the limit from cached usage, prompt immediately. - if (max !== null && current >= max) { - trackAnimationEvent("limit_reached", user, { - limit_type: "video_exports", - current, - max, - }); - trackAnimationEvent("upgrade_prompt_shown", user, { - limit_type: "video_exports", - trigger: "download_menu", - }); - openUpgradeForVideoExports({ current, max, plan }); - return false; - } - - const { data, error } = await supabase.rpc("check_video_export_limit", { - p_user_id: user.id, - }); - - if (error) { - console.error("Error checking video export limit:", error); - // If the function is missing (404) or fails, fall back to local plan limits to avoid a silent bypass. - if (max !== null && current >= max) { - openUpgradeForVideoExports({ current, max, plan }); - return false; - } - toast.error("Unable to verify video export limit. Please try again."); - return false; - } - - const canExport = Boolean(data?.canExport ?? data?.can_export ?? false); - const rpcPlan = (data?.plan as PlanId | undefined) ?? plan; - const rpcCurrent = typeof data?.current === "number" ? data.current : current; - const rpcMax = - data?.max === null || typeof data?.max === "undefined" ? max : Number(data.max); - - if (!canExport) { - trackAnimationEvent("export_blocked_limit", user, { - limit: "video_exports", - plan: rpcPlan, - current: rpcCurrent, - max: rpcMax, - source: "download_menu", - }); - trackAnimationEvent("upgrade_prompt_shown", user, { - limit_type: "video_exports", - trigger: "download_menu", - }); - openUpgradeForVideoExports({ plan: rpcPlan, current: rpcCurrent, max: rpcMax }); - toast.error(buildVideoLimitMessage({ plan: rpcPlan, current: rpcCurrent, max: rpcMax })); - return false; - } - - return true; - }; - - const handleExport = async (format: "mp4" | "webm" | "gif" = "mp4") => { - if (!user) { - setDropdownOpen(false); - setIsLoginDialogOpen(true); - trackAnimationEvent("guest_upgrade_prompted", user, { - trigger: "download_animation", - }); - return; - } - - if (serializedSlides.length < 2) { - toast.error("Add at least two slides to export."); - return; - } - - const allowed = await verifyVideoExportAllowance(); - if (!allowed) return; - - setCurrentExportFormat(format); - setDropdownOpen(false); - setIsExporting(true); - setExportProgress(0); - setCancelExport(false); - const isFirstExport = !firstExportTrackedRef.current; - if (isFirstExport) { - firstExportTrackedRef.current = true; - } - trackAnimationEvent("export_started", user, { - format: format, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - total_duration: totalDuration, - transition_type: animationSettings.transitionType, - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - time_to_first_export_ms: - isFirstExport && loadTimestampRef.current !== null - ? Date.now() - loadTimestampRef.current - : undefined, - source: "download_menu", - }); - }; - - const handleCancelExport = () => { - setCancelExport(true); - trackAnimationEvent("export_cancelled", user, { - progress_percent: Math.round(exportProgress * 100), - format: currentExportFormat, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - source: "download_menu", - }); - }; - - const onExportComplete = async (blob: Blob) => { - setIsExporting(false); - setExportProgress(0); - setCancelExport(false); - - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `animation-${Date.now()}.${currentExportFormat}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - trackAnimationEvent("export_completed", user, { - format: currentExportFormat, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - file_size_mb: Number((blob.size / (1024 * 1024)).toFixed(2)), - duration_seconds: totalDuration, - transition_type: animationSettings.transitionType, - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - source: "download_menu", - }); - - if (user?.id) { - const { error } = await supabase.rpc("increment_video_export_count", { - p_user_id: user.id, - }); - - if (error) { - console.error("Error incrementing video export count:", error); - } + useEffect(() => { + if (!loadTimestampRef.current) { + loadTimestampRef.current = Date.now(); } - - toast.success(`${currentExportFormat.toUpperCase()} downloaded successfully.`); - }; - - return ( - <> - - { + const safePlan = plan ?? "free"; + const planConfig = getPlanConfig(safePlan); + const effectiveMax = + typeof max === "number" + ? max + : planConfig.maxVideoExportCount === Infinity + ? null + : planConfig.maxVideoExportCount; + + if (!effectiveMax) { + return "Video export limit reached. Please upgrade your plan."; + } + + return `You've reached your video export limit (${Math.min( + current ?? effectiveMax, + effectiveMax + )}/${effectiveMax}). Upgrade to continue exporting videos.`; + }; + + const openUpgradeForVideoExports = (payload: { current?: number; max?: number | null; plan?: PlanId }) => { + setUpgradeContext(payload); + setIsUpgradeOpen(true); + }; + + const verifyVideoExportAllowance = async () => { + if (!user?.id) return false; + + const plan = usage?.plan ?? "free"; + const current = usage?.videoExports?.current ?? 0; + const max = usage?.videoExports?.max ?? getPlanConfig(plan).maxVideoExportCount ?? 0; + + if (max !== null && max <= 0) { + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + + // If we know the user already exhausted the limit from cached usage, prompt immediately. + if (max !== null && current >= max) { + trackAnimationEvent("limit_reached", user, { + limit_type: "video_exports", + current, + max, + }); + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + + const { data, error } = await supabase.rpc("check_video_export_limit", { + p_user_id: user.id, + }); + + if (error) { + console.error("Error checking video export limit:", error); + // If the function is missing (404) or fails, fall back to local plan limits to avoid a silent bypass. + if (max !== null && current >= max) { + openUpgradeForVideoExports({ current, max, plan }); + return false; + } + toast.error("Unable to verify video export limit. Please try again."); + return false; + } + + const canExport = Boolean(data?.canExport ?? data?.can_export ?? false); + const rpcPlan = (data?.plan as PlanId | undefined) ?? plan; + const rpcCurrent = typeof data?.current === "number" ? data.current : current; + const rpcMax = + data?.max === null || typeof data?.max === "undefined" ? max : Number(data.max); + + if (!canExport) { + trackAnimationEvent("export_blocked_limit", user, { + limit: "video_exports", + plan: rpcPlan, + current: rpcCurrent, + max: rpcMax, + source: "download_menu", + }); + trackAnimationEvent("upgrade_prompt_shown", user, { + limit_type: "video_exports", + trigger: "download_menu", + }); + openUpgradeForVideoExports({ plan: rpcPlan, current: rpcCurrent, max: rpcMax }); + toast.error(buildVideoLimitMessage({ plan: rpcPlan, current: rpcCurrent, max: rpcMax })); + return false; + } + + return true; + }; + + const handleExport = async (format: "mp4" | "webm" | "gif" = "mp4") => { + if (!user) { + setDropdownOpen(false); + setIsLoginDialogOpen(true); + trackAnimationEvent("guest_upgrade_prompted", user, { + trigger: "download_animation", + }); + return; + } + + if (serializedSlides.length < 2) { + toast.error("Add at least two slides to export."); + return; + } + + const allowed = await verifyVideoExportAllowance(); + if (!allowed) return; + + setCurrentExportFormat(format); + setDropdownOpen(false); + setIsExporting(true); + setExportProgress(0); + setCancelExport(false); + const isFirstExport = !firstExportTrackedRef.current; + if (isFirstExport) { + firstExportTrackedRef.current = true; + } + trackAnimationEvent("export_started", user, { + format: format, + resolution: animationSettings.resolution, + slide_count: serializedSlides.length, + total_duration: totalDuration, + transition_type: animationSettings.transitionType, + export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", + transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", + time_to_first_export_ms: + isFirstExport && loadTimestampRef.current !== null + ? Date.now() - loadTimestampRef.current + : undefined, + source: "download_menu", + }); + }; + + const handleCancelExport = () => { + setCancelExport(true); + trackAnimationEvent("export_cancelled", user, { + progress_percent: Math.round(exportProgress * 100), + format: currentExportFormat, + resolution: animationSettings.resolution, + slide_count: serializedSlides.length, + export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", + transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", + source: "download_menu", + }); + }; + + const onExportComplete = async (blob: Blob) => { + setIsExporting(false); + setExportProgress(0); + setCancelExport(false); + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `animation-${Date.now()}.${currentExportFormat}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + trackAnimationEvent("export_completed", user, { + format: currentExportFormat, + resolution: animationSettings.resolution, + slide_count: serializedSlides.length, + file_size_mb: Number((blob.size / (1024 * 1024)).toFixed(2)), + duration_seconds: totalDuration, + transition_type: animationSettings.transitionType, + export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", + transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", + source: "download_menu", + }); + + if (user?.id) { + const { error } = await supabase.rpc("increment_video_export_count", { + p_user_id: user.id, + }); + + if (error) { + console.error("Error incrementing video export count:", error); + } + } + + toast.success(`${currentExportFormat.toUpperCase()} downloaded successfully.`); + }; + + const onExportError = async (err: Error) => { + console.error(err); + setIsExporting(false); + setCancelExport(false); + + if (user?.id && exportProgress > 0) { + // Defensive rollback if we ever incremented before failure (future-proofing). + await supabase.rpc("decrement_video_export_count", { + p_user_id: user.id, + }); + } + + trackAnimationEvent("export_failed", user, { + error_type: err?.message || "unknown", + format: currentExportFormat, + resolution: animationSettings.resolution, + slide_count: serializedSlides.length, + transition_type: animationSettings.transitionType, + progress_percent: Math.round(exportProgress * 100), + export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", + transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", + source: "download_menu", + }); + toast.error("Export failed. Please try again."); + }; + + return ( + <> + - - - - - - - - - - handleExport("mp4")}> - - Download Video (MP4) - - handleExport("gif")}> - - Download as GIF - - - - - {isExporting && ( -
- {currentExportFormat === "gif" ? ( -
-
-
-
-

Generating GIF...

- {Math.round(exportProgress * 100)}% -
-
-
-
-

- Please wait while we render your animation frame by frame. -

-
-
- -
-
- { - console.error(err); - setIsExporting(false); - setCancelExport(false); - trackAnimationEvent("export_failed", user, { - error_type: err?.message || "unknown", - format: currentExportFormat, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - transition_type: animationSettings.transitionType, - progress_percent: Math.round(exportProgress * 100), - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - source: "download_menu", - }); - toast.error("Export failed. Please try again."); - }} - cancelled={cancelExport} - onCancelled={() => { - setIsExporting(false); - setExportProgress(0); - setCancelExport(false); - toast("Export canceled."); - }} - /> -
- ) : ( - { - console.error(err); - setIsExporting(false); - setCancelExport(false); - trackAnimationEvent("export_failed", user, { - error_type: err?.message || "unknown", - format: currentExportFormat, - resolution: animationSettings.resolution, - slide_count: serializedSlides.length, - transition_type: animationSettings.transitionType, - progress_percent: Math.round(exportProgress * 100), - export_format_experiment: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT ?? "control", - transition_experiment: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT ?? "control", - source: "download_menu", - }); - toast.error("Export failed. Please try again."); - }} - onCancelled={() => { - setIsExporting(false); - setExportProgress(0); - setCancelExport(false); - toast("Export canceled."); - }} - /> - )} -
- )} - - ); + + + + + + + + + + + handleExport("mp4")}> + + Download Video (MP4) + + handleExport("gif")}> + + Download as GIF + + + + + {isExporting && ( +
+ {currentExportFormat === "gif" ? ( +
+
+
+
+

Generating GIF...

+ {Math.round(exportProgress * 100)}% +
+
+
+
+

+ Please wait while we render your animation frame by frame. +

+
+
+ +
+
+ { + setIsExporting(false); + setExportProgress(0); + setCancelExport(false); + toast("Export canceled."); + }} + /> +
+ ) : ( + { + setIsExporting(false); + setExportProgress(0); + setCancelExport(false); + toast("Export canceled."); + }} + /> + )} +
+ )} + + ); }; diff --git a/src/features/animation/enhanced-share-dialog.tsx b/src/features/animation/enhanced-share-dialog.tsx index dbf9101..b98cfe3 100644 --- a/src/features/animation/enhanced-share-dialog.tsx +++ b/src/features/animation/enhanced-share-dialog.tsx @@ -373,12 +373,6 @@ export const EnhancedAnimationShareDialog = () => { } }; - useEffect(() => { - if (user && isLoginDialogOpen) { - setIsLoginDialogOpen(false); - } - }, [user, isLoginDialogOpen]); - const handlePlatformCopy = (platform: "hashnode" | "medium" | "devto" | "notion") => { if (!shareUrl) return; @@ -431,14 +425,10 @@ export const EnhancedAnimationShareDialog = () => { }); }; - const embedCode = useMemo( - () => - generateEmbedCode(shareUrl, { - width: embedWidth || defaultEmbedSizes.width, - height: embedHeight || defaultEmbedSizes.height, - }), - [embedHeight, embedWidth, shareUrl] - ); + const embedCode = generateEmbedCode(shareUrl, { + width: embedWidth || defaultEmbedSizes.width, + height: embedHeight || defaultEmbedSizes.height, + }); const isGenerating = shortenUrlMutation.isPending; @@ -573,7 +563,11 @@ export const EnhancedAnimationShareDialog = () => { maxCount={upgradeContext.max ?? null} currentPlan={usage?.plan} /> - + diff --git a/src/features/animation/video-exporter.tsx b/src/features/animation/video-exporter.tsx index 8e10805..0b373cf 100644 --- a/src/features/animation/video-exporter.tsx +++ b/src/features/animation/video-exporter.tsx @@ -225,7 +225,7 @@ export const VideoExporter = ({ }; processAnimation(); - }, [settings, slides, editorSettings, onProgress, onComplete, onError, cancelled, onCancelled]); // Added editorSettings to deps + }, [settings, slides, editorSettings, onProgress, onComplete, onError, cancelled, onCancelled, width, height]); // Added editorSettings to deps const { width, height } = getResolutionDimensions(settings.resolution); diff --git a/src/features/share-code/index.tsx b/src/features/share-code/index.tsx index b6b030a..3868322 100644 --- a/src/features/share-code/index.tsx +++ b/src/features/share-code/index.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; @@ -79,7 +79,7 @@ export const CopyURLToClipboard = () => { }, }); - const handleCopyLinkToClipboard = useCallback(async () => { + const handleCopyLinkToClipboard = async () => { if (!user) { setShowLogin(true); return; @@ -106,12 +106,9 @@ export const CopyURLToClipboard = () => { }); toast.success("Link copied to clipboard."); - }, [postLinkDataToDatabase, currentUrl, code, currentEditorState]); + }; - const copyLink = useMemo( - () => hotKeyList.filter((item) => item.label === "Copy link"), - [] - ); + const copyLink = hotKeyList.filter((item) => item.label === "Copy link"); useHotkeys(copyLink[0]?.hotKey, () => { if (copyLink[0]) { diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index 286e885..0baf50e 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -65,7 +65,8 @@ const RPC_MAP: Record< | "decrement_snippet_count" | "decrement_animation_count" | "decrement_folder_count" - | "decrement_public_share_count"; + | "decrement_public_share_count" + | "decrement_video_export_count"; planKey: PlanLimitKey; } > = { @@ -90,6 +91,7 @@ const RPC_MAP: Record< videoExports: { check: "check_video_export_limit", increment: "increment_video_export_count", + decrement: "decrement_video_export_count", planKey: "maxVideoExportCount", }, publicShares: { @@ -112,6 +114,7 @@ type LimitRpcName = | (typeof RPC_MAP)["folders"]["decrement"] | (typeof RPC_MAP)["videoExports"]["check"] | (typeof RPC_MAP)["videoExports"]["increment"] + | (typeof RPC_MAP)["videoExports"]["decrement"] | (typeof RPC_MAP)["publicShares"]["check"] | (typeof RPC_MAP)["publicShares"]["increment"] | (typeof RPC_MAP)["publicShares"]["decrement"]; diff --git a/supabase/migrations/20260417113000_add_decrement_video_export.sql b/supabase/migrations/20260417113000_add_decrement_video_export.sql new file mode 100644 index 0000000..f5e1ccb --- /dev/null +++ b/supabase/migrations/20260417113000_add_decrement_video_export.sql @@ -0,0 +1,15 @@ +-- Add decrement function for video export counts to keep usage accurate on failure or rollback. + +CREATE OR REPLACE FUNCTION decrement_video_export_count(p_user_id UUID) +RETURNS VOID AS $$ +BEGIN + PERFORM ensure_usage_limits_row(p_user_id); + UPDATE public.usage_limits + SET video_export_count = GREATEST(0, video_export_count - 1) + WHERE user_id = p_user_id; + + UPDATE public.profiles + SET video_export_count = GREATEST(0, video_export_count - 1) + WHERE id = p_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; From fe74ba0f51e4d94e42fc3ef81c222dc80f419a1d Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 12:04:27 -0300 Subject: [PATCH 010/102] :sparkles: feat: Add Stripe webhook audit logging, Sentry error reporting, and improved user resolution to the Stripe webhook handler. --- src/app/api/webhooks/stripe/route.ts | 137 +++++++++++++++--- src/types/database.ts | 44 ++++++ ...8145943_add_stripe_webhook_audit_table.sql | 17 +++ 3 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 supabase/migrations/20251208145943_add_stripe_webhook_audit_table.sql diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index e780f46..f23b159 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -1,10 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; import { NextRequest, NextResponse } from 'next/server'; import { createServiceRoleClient } from '@/utils/supabase/admin'; import { constructWebhookEvent } from '@/lib/services/stripe'; import type { PlanId } from '@/lib/config/plans'; +import type { Json } from '@/types/database'; import Stripe from 'stripe'; export const dynamic = 'force-dynamic'; +type ServiceRoleClient = ReturnType; // Map Stripe price IDs to plan IDs function getPlanIdFromPriceId(priceId: string): PlanId | null { @@ -30,21 +33,89 @@ function resolvePlan(subscription: Stripe.Subscription): PlanId | null { return getPlanIdFromPriceId(priceId); } +function getStripeCustomerId( + stripeObject: Stripe.Subscription | Stripe.Invoice | Stripe.Checkout.Session +): string | null { + const customer = (stripeObject as { customer?: string | Stripe.Customer | null }).customer; + if (!customer) return null; + + return typeof customer === 'string' ? customer : customer.id; +} + +async function resolveUserIdFromSubscription( + subscription: Stripe.Subscription, + supabase: ServiceRoleClient +): Promise { + if (subscription.metadata?.userId) { + return subscription.metadata.userId; + } + + const customerId = getStripeCustomerId(subscription); + if (!customerId) return null; + + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('stripe_customer_id', customerId) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to resolve user by stripe_customer_id: ${error.message}`); + } + + return data?.id ?? null; +} + +async function upsertWebhookAudit( + supabase: ServiceRoleClient, + event: Stripe.Event, + { + status, + errorMessage, + userId, + }: { status: string; errorMessage?: string; userId?: string | null } +) { + const stripeObject = event.data?.object as + | Stripe.Subscription + | Stripe.Invoice + | Stripe.Checkout.Session + | undefined; + + const stripeCustomerId = stripeObject ? getStripeCustomerId(stripeObject) : null; + const payload = JSON.parse(JSON.stringify(event)) as Json; + + const { error } = await supabase.from('stripe_webhook_audit').upsert( + { + event_id: event.id, + event_type: event.type, + payload, + status, + error_message: errorMessage ?? null, + stripe_customer_id: stripeCustomerId, + user_id: userId ?? null, + }, + { onConflict: 'event_id' } + ); + + if (error) { + console.error('Failed to log Stripe webhook audit event', error); + } +} + // Handle subscription created or updated -async function handleSubscriptionChange(subscription: Stripe.Subscription) { - const supabase = createServiceRoleClient(); - - const userId = subscription.metadata.userId; +async function handleSubscriptionChange( + subscription: Stripe.Subscription, + supabase: ServiceRoleClient +) { + const userId = await resolveUserIdFromSubscription(subscription, supabase); if (!userId) { - console.error('No userId in subscription metadata'); - return; + throw new Error('No user found for subscription (missing metadata and lookup failed)'); } const priceId = subscription.items.data[0]?.price.id; const planId = resolvePlan(subscription); if (!planId) { - console.error('Could not determine plan from subscription metadata/price'); - return; + throw new Error('Could not determine plan from subscription metadata/price'); } const updateData: any = { @@ -64,21 +135,21 @@ async function handleSubscriptionChange(subscription: Stripe.Subscription) { .eq('id', userId); if (error) { - console.error('Error updating profile:', error); throw error; } console.log(`Updated user ${userId} to plan ${planId}`); + return userId; } // Handle subscription deleted (downgrade to free) -async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const supabase = createServiceRoleClient(); - - const userId = subscription.metadata.userId; +async function handleSubscriptionDeleted( + subscription: Stripe.Subscription, + supabase: ServiceRoleClient +) { + const userId = await resolveUserIdFromSubscription(subscription, supabase); if (!userId) { - console.error('No userId in subscription metadata'); - return; + throw new Error('No user found for subscription deletion (missing metadata and lookup failed)'); } const { error } = await supabase @@ -92,11 +163,11 @@ async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { .eq('id', userId); if (error) { - console.error('Error downgrading user to free:', error); throw error; } console.log(`Downgraded user ${userId} to free plan`); + return userId; } export async function POST(request: NextRequest) { @@ -110,6 +181,10 @@ export async function POST(request: NextRequest) { ); } + const supabase = createServiceRoleClient(); + let event: Stripe.Event | null = null; + let resolvedUserId: string | null = null; + try { const body = await request.text(); const signature = request.headers.get('stripe-signature'); @@ -122,19 +197,26 @@ export async function POST(request: NextRequest) { } // Verify webhook signature - const event = constructWebhookEvent(body, signature, webhookSecret); + event = constructWebhookEvent(body, signature, webhookSecret); console.log('Received Stripe webhook:', event.type); + await upsertWebhookAudit(supabase, event, { status: 'received' }); // Handle different event types switch (event.type) { case 'customer.subscription.created': case 'customer.subscription.updated': - await handleSubscriptionChange(event.data.object as Stripe.Subscription); + resolvedUserId = await handleSubscriptionChange( + event.data.object as Stripe.Subscription, + supabase + ); break; case 'customer.subscription.deleted': - await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + resolvedUserId = await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription, + supabase + ); break; case 'checkout.session.completed': @@ -156,9 +238,26 @@ export async function POST(request: NextRequest) { console.log(`Unhandled event type: ${event.type}`); } + await upsertWebhookAudit(supabase, event, { status: 'processed', userId: resolvedUserId }); + return NextResponse.json({ received: true }); } catch (error) { console.error('Webhook error:', error); + Sentry.captureException(error, { + extra: { + source: 'stripe-webhook', + eventType: event?.type, + eventId: event?.id, + }, + }); + + if (event) { + await upsertWebhookAudit(supabase, event, { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unknown webhook error', + userId: resolvedUserId, + }); + } if (error instanceof Error) { return NextResponse.json( diff --git a/src/types/database.ts b/src/types/database.ts index 60c6a21..47c1052 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -287,6 +287,50 @@ export type Database = { }, ] } + stripe_webhook_audit: { + Row: { + created_at: string + error_message: string | null + event_id: string + event_type: string + id: string + payload: Json + status: string + stripe_customer_id: string | null + user_id: string | null + } + Insert: { + created_at?: string + error_message?: string | null + event_id: string + event_type: string + id?: string + payload: Json + status?: string + stripe_customer_id?: string | null + user_id?: string | null + } + Update: { + created_at?: string + error_message?: string | null + event_id?: string + event_type?: string + id?: string + payload?: Json + status?: string + stripe_customer_id?: string | null + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "stripe_webhook_audit_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } usage_limits: { Row: { animation_count: number diff --git a/supabase/migrations/20251208145943_add_stripe_webhook_audit_table.sql b/supabase/migrations/20251208145943_add_stripe_webhook_audit_table.sql new file mode 100644 index 0000000..ce81746 --- /dev/null +++ b/supabase/migrations/20251208145943_add_stripe_webhook_audit_table.sql @@ -0,0 +1,17 @@ +create table if not exists public.stripe_webhook_audit ( + id uuid default uuid_generate_v4() primary key, + event_id text not null, + event_type text not null, + stripe_customer_id text, + user_id uuid references public.profiles(id) on delete set null, + status text not null default 'received', + error_message text, + payload jsonb not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +create unique index if not exists stripe_webhook_audit_event_id_idx on public.stripe_webhook_audit(event_id); +create index if not exists stripe_webhook_audit_customer_idx on public.stripe_webhook_audit(stripe_customer_id); +create index if not exists stripe_webhook_audit_created_at_idx on public.stripe_webhook_audit(created_at); + +alter table public.stripe_webhook_audit enable row level security; From 98f6aa4a48fe5ca04284fa3dd1d64468e005b5c6 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 12:54:30 -0300 Subject: [PATCH 011/102] :sparkles: feat: Introduce usage tracking and sharing features with schema updates for profiles, snippets, and usage limits. --- src/types/database.ts | 484 +++++++++++++----- .../20251208151046_usage_counter_sync_job.sql | 220 ++++++++ ...fix_search_path_decrement_video_export.sql | 20 + 3 files changed, 597 insertions(+), 127 deletions(-) create mode 100644 supabase/migrations/20251208151046_usage_counter_sync_job.sql create mode 100644 supabase/migrations/20251208160000_fix_search_path_decrement_video_export.sql diff --git a/src/types/database.ts b/src/types/database.ts index 47c1052..3bae1f8 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -175,13 +175,6 @@ export type Database = { visits?: number | null } Relationships: [ - { - foreignKeyName: "links_snippet_id_fkey" - columns: ["snippet_id"] - isOneToOne: false - referencedRelation: "snippet" - referencedColumns: ["id"] - }, { foreignKeyName: "links_user_id_fkey" columns: ["user_id"] @@ -196,88 +189,150 @@ export type Database = { animation_count: number avatar_url: string | null created_at: string + email: string | null + folder_count: number id: string + name: string | null plan: Database["public"]["Enums"]["user_plan"] plan_updated_at: string + public_share_count: number snippet_count: number + stripe_customer_id: string | null + stripe_price_id: string | null + stripe_subscription_id: string | null + stripe_subscription_status: string | null + subscription_cancel_at_period_end: boolean | null subscription_id: string | null + subscription_period_end: string | null subscription_status: string | null + updated_at: string | null username: string | null + video_export_count: number } Insert: { animation_count?: number avatar_url?: string | null created_at?: string + email?: string | null + folder_count?: number id: string + name?: string | null plan?: Database["public"]["Enums"]["user_plan"] plan_updated_at?: string + public_share_count?: number snippet_count?: number + stripe_customer_id?: string | null + stripe_price_id?: string | null + stripe_subscription_id?: string | null + stripe_subscription_status?: string | null + subscription_cancel_at_period_end?: boolean | null subscription_id?: string | null + subscription_period_end?: string | null subscription_status?: string | null + updated_at?: string | null username?: string | null + video_export_count?: number } Update: { animation_count?: number avatar_url?: string | null created_at?: string + email?: string | null + folder_count?: number id?: string + name?: string | null plan?: Database["public"]["Enums"]["user_plan"] plan_updated_at?: string + public_share_count?: number snippet_count?: number + stripe_customer_id?: string | null + stripe_price_id?: string | null + stripe_subscription_id?: string | null + stripe_subscription_status?: string | null + subscription_cancel_at_period_end?: boolean | null subscription_id?: string | null + subscription_period_end?: string | null subscription_status?: string | null + updated_at?: string | null username?: string | null + video_export_count?: number } Relationships: [] } + share_view_events: { + Row: { + created_at: string | null + id: string + link_id: string | null + owner_id: string | null + viewed_on: string + viewer_token: string + } + Insert: { + created_at?: string | null + id?: string + link_id?: string | null + owner_id?: string | null + viewed_on?: string + viewer_token: string + } + Update: { + created_at?: string | null + id?: string + link_id?: string | null + owner_id?: string | null + viewed_on?: string + viewer_token?: string + } + Relationships: [ + { + foreignKeyName: "share_view_events_link_id_fkey" + columns: ["link_id"] + isOneToOne: false + referencedRelation: "links" + referencedColumns: ["id"] + }, + { + foreignKeyName: "share_view_events_owner_id_fkey" + columns: ["owner_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } snippet: { Row: { - code: string | null + code: string created_at: string - favorite: boolean | null - folder_id: string | null id: string - language: string | null - tags: string[] | null - title: string | null - trash: boolean | null + language: string + title: string updated_at: string | null + url: string | null user_id: string } Insert: { - code?: string | null + code: string created_at?: string - favorite?: boolean | null - folder_id?: string | null id?: string - language?: string | null - tags?: string[] | null - title?: string | null - trash?: boolean | null + language: string + title: string updated_at?: string | null + url?: string | null user_id: string } Update: { - code?: string | null + code?: string created_at?: string - favorite?: boolean | null - folder_id?: string | null id?: string - language?: string | null - tags?: string[] | null - title?: string | null - trash?: boolean | null + language?: string + title?: string updated_at?: string | null + url?: string | null user_id?: string } Relationships: [ - { - foreignKeyName: "snippet_folder_id_fkey" - columns: ["folder_id"] - isOneToOne: false - referencedRelation: "collection" - referencedColumns: ["id"] - }, { foreignKeyName: "snippet_user_id_fkey" columns: ["user_id"] @@ -331,24 +386,71 @@ export type Database = { }, ] } + usage_drift_alerts: { + Row: { + created_at: string + id: string + metric: string + new_count: number + percent_drift: number + previous_count: number + user_id: string | null + } + Insert: { + created_at?: string + id?: string + metric: string + new_count: number + percent_drift: number + previous_count: number + user_id?: string | null + } + Update: { + created_at?: string + id?: string + metric?: string + new_count?: number + percent_drift?: number + previous_count?: number + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "usage_drift_alerts_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } usage_limits: { Row: { animation_count: number + folder_count: number last_reset_at: string + public_share_count: number snippet_count: number user_id: string + video_export_count: number } Insert: { animation_count?: number + folder_count?: number last_reset_at?: string + public_share_count?: number snippet_count?: number user_id: string + video_export_count?: number } Update: { animation_count?: number + folder_count?: number last_reset_at?: string + public_share_count?: number snippet_count?: number user_id?: string + video_export_count?: number } Relationships: [ { @@ -362,30 +464,39 @@ export type Database = { } waitlist: { Row: { - created_at: string email: string feature_key: string + granted_at: string | null id: string - metadata: Json | null + metadata: Json + notified_at: string | null referer: string | null + requested_at: string + status: string user_id: string | null } Insert: { - created_at?: string email: string feature_key: string + granted_at?: string | null id?: string - metadata?: Json | null + metadata?: Json + notified_at?: string | null referer?: string | null + requested_at?: string + status?: string user_id?: string | null } Update: { - created_at?: string email?: string feature_key?: string + granted_at?: string | null id?: string - metadata?: Json | null + metadata?: Json + notified_at?: string | null referer?: string | null + requested_at?: string + status?: string user_id?: string | null } Relationships: [ @@ -403,51 +514,147 @@ export type Database = { [_ in never]: never } Functions: { + calculate_usage_counts: { + Args: { + p_user_id: string + } + Returns: { + animation_count: number + folder_count: number + public_share_count: number + snippet_count: number + video_export_count: number + }[] + } check_animation_limit: { Args: { - target_user_id: string + p_user_id: string + } + Returns: Json + } + check_public_share_limit: { + Args: { + p_user_id: string } Returns: Json } check_snippet_limit: { Args: { - target_user_id: string + p_user_id: string } Returns: Json } decrement_animation_count: { Args: { - target_user_id: string + p_user_id: string } - Returns: Json + Returns: undefined + } + decrement_public_share_count: { + Args: { + p_user_id: string + } + Returns: undefined } decrement_snippet_count: { + Args: { + p_user_id: string + } + Returns: undefined + } + decrement_video_export_count: { + Args: { + p_user_id: string + } + Returns: undefined + } + ensure_usage_limits_row: { + Args: { + p_user_id: string + } + Returns: undefined + } + force_sync_user_usage: { Args: { target_user_id: string } Returns: Json } + get_plan_limits: { + Args: { + plan_type: Database["public"]["Enums"]["plan_type"] + } + Returns: Json + } + get_user_usage: { + Args: { + p_user_id: string + } + Returns: Json + } + increment_animation_count: { + Args: { + p_user_id: string + } + Returns: undefined + } increment_link_visits: { Args: { link_id: string } Returns: undefined } - increment_animation_count: { + increment_public_share_count: { Args: { - target_user_id: string + p_user_id: string } - Returns: Json + Returns: undefined } increment_snippet_count: { Args: { - target_user_id: string + p_user_id: string + } + Returns: undefined + } + record_public_share_view: { + Args: { + p_link_id: string + p_owner_id: string + p_viewer_token: string + } + Returns: Json + } + reset_public_share_usage: { + Args: { + p_user_id: string + } + Returns: undefined + } + sync_all_user_usage_counts: { + Args: Record + Returns: undefined + } + sync_stripe_subscription: { + Args: { + p_plan: Database["public"]["Enums"]["plan_type"] + p_stripe_customer_id: string + p_stripe_price_id: string + p_stripe_subscription_id: string + p_stripe_subscription_status: string + p_user_id: string + } + Returns: undefined + } + sync_user_usage_counts: { + Args: { + p_user_id: string } Returns: Json } } Enums: { - user_plan: "free" | "pro" + plan_type: "free" | "started" | "pro" + user_plan: "free" | "pro" | "started" } CompositeTypes: { [_ in never]: never @@ -455,101 +662,124 @@ export type Database = { } } -type PublicSchema = Database[Extract] - type DatabaseWithoutInternals = Omit +type DefaultSchema = DatabaseWithoutInternals[Extract] + export type Tables< - PublicTableNameOrOptions extends - | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? keyof (DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Views"]) - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? (DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & - PublicSchema["Views"]) - ? (PublicSchema["Tables"] & - PublicSchema["Views"])[PublicTableNameOrOptions] extends { + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { Row: infer R } - ? R - : never - : never + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never export type TablesInsert< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? keyof DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I - } - ? I - : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { - Insert: infer I + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals } - ? I - : never - : never + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never export type TablesUpdate< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? keyof DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = PublicTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? DatabaseWithoutInternals[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U - } - ? U - : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { - Update: infer U + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals } - ? U - : never - : never + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never export type Enums< - PublicEnumNameOrOptions extends - | keyof PublicSchema["Enums"] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends PublicEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? keyof DatabaseWithoutInternals[PublicEnumNameOrOptions["schema"]]["Enums"] - : never = never, -> = PublicEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? DatabaseWithoutInternals[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] - : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] - ? PublicSchema["Enums"][PublicEnumNameOrOptions] - : never + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never export type CompositeTypes< PublicCompositeTypeNameOrOptions extends - | keyof PublicSchema["CompositeTypes"] - | { schema: keyof DatabaseWithoutInternals }, + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } - ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] - ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + plan_type: ["free", "started", "pro"], + user_plan: ["free", "pro", "started"], + }, + }, +} as const diff --git a/supabase/migrations/20251208151046_usage_counter_sync_job.sql b/supabase/migrations/20251208151046_usage_counter_sync_job.sql new file mode 100644 index 0000000..2b9b1ee --- /dev/null +++ b/supabase/migrations/20251208151046_usage_counter_sync_job.sql @@ -0,0 +1,220 @@ +-- Ensure pg_cron is available for scheduling +DO $$ +BEGIN + BEGIN + CREATE SCHEMA cron; + EXCEPTION + WHEN duplicate_schema THEN NULL; + END; +END $$; + +DO $$ +BEGIN + CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA cron; +EXCEPTION + WHEN others THEN NULL; +END $$; + +-- Ensure profiles keeps mirrored counters for drift detection +alter table public.profiles + add column if not exists snippet_count integer default 0 not null, + add column if not exists animation_count integer default 0 not null, + add column if not exists folder_count integer default 0 not null, + add column if not exists video_export_count integer default 0 not null, + add column if not exists public_share_count integer default 0 not null; + +-- Alerts table for drift +create table if not exists public.usage_drift_alerts ( + id uuid default uuid_generate_v4() primary key, + user_id uuid references public.profiles(id) on delete cascade, + metric text not null check (metric in ('snippets','animations','folders','video_exports','public_shares')), + previous_count integer not null, + new_count integer not null, + percent_drift numeric(10,2) not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +create index if not exists usage_drift_alerts_user_id_idx on public.usage_drift_alerts(user_id); +create index if not exists usage_drift_alerts_created_at_idx on public.usage_drift_alerts(created_at); + +alter table public.usage_drift_alerts enable row level security; + +-- Calculate actual usage counts for a user +create or replace function public.calculate_usage_counts(p_user_id uuid) +returns table( + snippet_count integer, + animation_count integer, + folder_count integer, + video_export_count integer, + public_share_count integer +) language sql +security definer +set search_path = public +as $$ + select + (select count(*) from public.snippet s where s.user_id = p_user_id) as snippet_count, + (select count(*) from public.animation a where a.user_id = p_user_id) as animation_count, + ( + coalesce((select count(*) from public.collection c where c.user_id = p_user_id), 0) + + coalesce((select count(*) from public.animation_collection ac where ac.user_id = p_user_id), 0) + ) as folder_count, + greatest( + coalesce((select video_export_count from public.usage_limits ul where ul.user_id = p_user_id), 0), + coalesce((select video_export_count from public.profiles p where p.id = p_user_id), 0) + ) as video_export_count, + greatest( + coalesce((select count(*) from public.links l where l.user_id = p_user_id), 0), + coalesce((select public_share_count from public.usage_limits ul2 where ul2.user_id = p_user_id), 0) + ) as public_share_count; +$$; + +-- Sync a single user's usage counts and alert on drift +create or replace function public.sync_user_usage_counts(p_user_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + usage_counts record; + previous_limits record; + drift jsonb := '[]'::jsonb; + metric_record record; + percent numeric; + jwt_role text := current_setting('request.jwt.claim.role', true); +begin + if jwt_role is not null and jwt_role <> 'service_role' then + raise exception 'Unauthorized'; + end if; + + select * into usage_counts from public.calculate_usage_counts(p_user_id); + + select snippet_count, animation_count, folder_count, video_export_count, public_share_count + into previous_limits + from public.usage_limits + where user_id = p_user_id; + + insert into public.usage_limits as ul (user_id, snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at) + values ( + p_user_id, + usage_counts.snippet_count, + usage_counts.animation_count, + usage_counts.folder_count, + usage_counts.video_export_count, + usage_counts.public_share_count, + timezone('utc'::text, now()) + ) + on conflict (user_id) do update + set snippet_count = excluded.snippet_count, + animation_count = excluded.animation_count, + folder_count = excluded.folder_count, + video_export_count = excluded.video_export_count, + public_share_count = excluded.public_share_count, + last_reset_at = excluded.last_reset_at; + + update public.profiles + set snippet_count = usage_counts.snippet_count, + animation_count = usage_counts.animation_count, + folder_count = usage_counts.folder_count, + video_export_count = usage_counts.video_export_count, + public_share_count = usage_counts.public_share_count, + plan_updated_at = timezone('utc'::text, now()) + where id = p_user_id; + + for metric_record in + select * from ( + values + ('snippets', coalesce(previous_limits.snippet_count, 0), usage_counts.snippet_count), + ('animations', coalesce(previous_limits.animation_count, 0), usage_counts.animation_count), + ('folders', coalesce(previous_limits.folder_count, 0), usage_counts.folder_count), + ('video_exports', coalesce(previous_limits.video_export_count, 0), usage_counts.video_export_count), + ('public_shares', coalesce(previous_limits.public_share_count, 0), usage_counts.public_share_count) + ) as t(metric, previous_value, current_value) + loop + percent := case + when metric_record.previous_value = 0 then case when metric_record.current_value = 0 then 0 else 100 end + else round((abs(metric_record.current_value - metric_record.previous_value)::numeric * 100) / greatest(metric_record.previous_value, 1), 2) + end; + + if percent > 10 then + insert into public.usage_drift_alerts (user_id, metric, previous_count, new_count, percent_drift) + values (p_user_id, metric_record.metric, metric_record.previous_value, metric_record.current_value, percent); + + drift := drift || jsonb_build_object( + 'metric', metric_record.metric, + 'previous', metric_record.previous_value, + 'current', metric_record.current_value, + 'percent', percent + ); + + perform pg_notify('usage_drift_alert', jsonb_build_object( + 'user_id', p_user_id, + 'metric', metric_record.metric, + 'previous', metric_record.previous_value, + 'current', metric_record.current_value, + 'percent', percent + )::text); + end if; + end loop; + + return jsonb_build_object( + 'user_id', p_user_id, + 'counts', jsonb_build_object( + 'snippet_count', usage_counts.snippet_count, + 'animation_count', usage_counts.animation_count, + 'folder_count', usage_counts.folder_count, + 'video_export_count', usage_counts.video_export_count, + 'public_share_count', usage_counts.public_share_count + ), + 'drift', drift + ); +end; +$$; + +grant execute on function public.sync_user_usage_counts(uuid) to service_role; + +-- RPC wrapper to allow manual re-sync +create or replace function public.force_sync_user_usage(target_user_id uuid) +returns jsonb +language sql +security definer +set search_path = public +as $$ + select public.sync_user_usage_counts(target_user_id); +$$; + +grant execute on function public.force_sync_user_usage(uuid) to service_role; + +-- Sync all users +create or replace function public.sync_all_user_usage_counts() +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + rec record; +begin + for rec in select id from public.profiles loop + perform public.sync_user_usage_counts(rec.id); + end loop; +end; +$$; + +-- Weekly cron job Sunday 03:00 UTC +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'cron' AND table_name = 'job') THEN + DELETE FROM cron.job WHERE jobname = 'weekly_usage_sync'; + INSERT INTO cron.job (schedule, command, jobname, nodename, nodeport, database, username) + VALUES ( + '0 3 * * 0', + 'call public.sync_all_user_usage_counts();', + 'weekly_usage_sync', + 'localhost', + 5432, + current_database(), + current_user + ); + END IF; +END $$; diff --git a/supabase/migrations/20251208160000_fix_search_path_decrement_video_export.sql b/supabase/migrations/20251208160000_fix_search_path_decrement_video_export.sql new file mode 100644 index 0000000..217fdf5 --- /dev/null +++ b/supabase/migrations/20251208160000_fix_search_path_decrement_video_export.sql @@ -0,0 +1,20 @@ +-- Harden search_path for decrement_video_export_count to avoid role mutation issues. + +create or replace function public.decrement_video_export_count(p_user_id uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + perform ensure_usage_limits_row(p_user_id); + + update public.usage_limits + set video_export_count = greatest(0, video_export_count - 1) + where user_id = p_user_id; + + update public.profiles + set video_export_count = greatest(0, video_export_count - 1) + where id = p_user_id; +end; +$$; From d59c4ba67682d087dd055594946d43435f80fae4 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 13:05:26 -0300 Subject: [PATCH 012/102] :wrench: refactor: Migrate usage counter updates from application actions to database triggers. --- src/actions/animations/create-animation.ts | 10 --- src/actions/animations/create-collection.ts | 8 -- src/actions/animations/delete-animation.ts | 9 +- src/actions/animations/delete-collection.ts | 8 -- src/actions/collections/create-collection.ts | 8 -- src/actions/collections/delete-collection.ts | 8 -- src/actions/snippets/create-snippet.ts | 10 --- src/actions/snippets/delete-snippet.ts | 5 -- ...20260522120000_usage_counters_triggers.sql | 85 +++++++++++++++++++ 9 files changed, 86 insertions(+), 65 deletions(-) create mode 100644 supabase/migrations/20260522120000_usage_counters_triggers.sql diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index 6470800..380e471 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -87,16 +87,6 @@ export async function createAnimation( return error('Failed to create animation') } - // Increment animation count after successful creation - const { error: incrementError } = await supabase.rpc('increment_animation_count', { - p_user_id: user.id - }) - - if (incrementError) { - console.error('Error incrementing animation count:', incrementError) - // Don't fail the operation, but log it - } - revalidatePath('/animate') revalidatePath('/animations') diff --git a/src/actions/animations/create-collection.ts b/src/actions/animations/create-collection.ts index 08d4915..945ca8a 100644 --- a/src/actions/animations/create-collection.ts +++ b/src/actions/animations/create-collection.ts @@ -61,14 +61,6 @@ export async function createAnimationCollection( return error('Failed to create collection') } - const { error: incrementError } = await supabase.rpc('increment_folder_count', { - p_user_id: user.id - }) - - if (incrementError) { - console.error('Error incrementing folder count:', incrementError) - } - revalidatePath('/animations') revalidatePath('/animate') diff --git a/src/actions/animations/delete-animation.ts b/src/actions/animations/delete-animation.ts index 3834e0c..b2a33ee 100644 --- a/src/actions/animations/delete-animation.ts +++ b/src/actions/animations/delete-animation.ts @@ -5,7 +5,6 @@ import { revalidatePath } from 'next/cache' import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { deleteAnimation as deleteAnimationDb } from '@/lib/services/database/animations' -import { decrementUsageCount } from '@/lib/services/usage-limits' export async function deleteAnimation( animation_id: string @@ -17,18 +16,12 @@ export async function deleteAnimation( const { user, supabase } = await requireAuth() - const { deletedCount } = await deleteAnimationDb({ + await deleteAnimationDb({ animation_id, user_id: user.id, supabase }) - if (deletedCount > 0) { - await decrementUsageCount(supabase, user.id, 'animations').catch((decrementError) => { - console.error('Failed to decrement animation usage', decrementError) - }) - } - revalidatePath('/animate') revalidatePath('/animations') diff --git a/src/actions/animations/delete-collection.ts b/src/actions/animations/delete-collection.ts index ab2382f..e29e0cb 100644 --- a/src/actions/animations/delete-collection.ts +++ b/src/actions/animations/delete-collection.ts @@ -22,14 +22,6 @@ export async function deleteAnimationCollection( supabase }) - const { error: decrementError } = await supabase.rpc('decrement_folder_count', { - p_user_id: user.id - }) - - if (decrementError) { - console.error('Error decrementing folder count:', decrementError) - } - revalidatePath('/animations') revalidatePath('/animate') diff --git a/src/actions/collections/create-collection.ts b/src/actions/collections/create-collection.ts index 79e4307..2fd48c1 100644 --- a/src/actions/collections/create-collection.ts +++ b/src/actions/collections/create-collection.ts @@ -67,14 +67,6 @@ export async function createCollection( return error('Failed to create collection') } - const { error: incrementError } = await supabase.rpc('increment_folder_count', { - p_user_id: user.id - }) - - if (incrementError) { - console.error('Error incrementing folder count:', incrementError) - } - // Revalidate the collections list revalidatePath('/collections') revalidatePath('/') diff --git a/src/actions/collections/delete-collection.ts b/src/actions/collections/delete-collection.ts index 0b80d67..cc2a348 100644 --- a/src/actions/collections/delete-collection.ts +++ b/src/actions/collections/delete-collection.ts @@ -27,14 +27,6 @@ export async function deleteCollection( supabase }) - const { error: decrementError } = await supabase.rpc('decrement_folder_count', { - p_user_id: user.id - }) - - if (decrementError) { - console.error('Error decrementing folder count:', decrementError) - } - // Revalidate relevant paths revalidatePath('/collections') revalidatePath('/') diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index e28158b..568fbce 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -66,16 +66,6 @@ export async function createSnippet( return error('Failed to create snippet') } - // Increment snippet count after successful creation - const { error: incrementError } = await supabase.rpc('increment_snippet_count', { - p_user_id: user.id - }) - - if (incrementError) { - console.error('Error incrementing snippet count:', incrementError) - // Don't fail the operation, but log it - } - // Revalidate the snippets list revalidatePath('/snippets') revalidatePath('/') diff --git a/src/actions/snippets/delete-snippet.ts b/src/actions/snippets/delete-snippet.ts index f0496e6..8f27473 100644 --- a/src/actions/snippets/delete-snippet.ts +++ b/src/actions/snippets/delete-snippet.ts @@ -4,7 +4,6 @@ import { revalidatePath } from 'next/cache' import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { deleteSnippet as deleteSnippetFromDb } from '@/lib/services/database/snippets' -import { decrementUsageCount } from '@/lib/services/usage-limits' /** * Server Action: Delete a snippet @@ -28,10 +27,6 @@ export async function deleteSnippet( supabase }) - await decrementUsageCount(supabase, user.id, 'snippets').catch((decrementError) => { - console.error('Failed to decrement snippet usage', decrementError) - }) - // Revalidate relevant paths revalidatePath('/snippets') revalidatePath('/') diff --git a/supabase/migrations/20260522120000_usage_counters_triggers.sql b/supabase/migrations/20260522120000_usage_counters_triggers.sql new file mode 100644 index 0000000..0cc5566 --- /dev/null +++ b/supabase/migrations/20260522120000_usage_counters_triggers.sql @@ -0,0 +1,85 @@ +-- Keep usage counters in sync inside the same transaction as inserts/deletes to avoid drift. + +create or replace function public.refresh_usage_counters(p_user_id uuid) +returns void +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + v_snippet_count integer; + v_animation_count integer; + v_folder_count integer; +begin + perform ensure_usage_limits_row(p_user_id); + + select count(*) into v_snippet_count from public.snippet where user_id = p_user_id; + select count(*) into v_animation_count from public.animation where user_id = p_user_id; + select count(*) into v_folder_count from ( + select 1 from public.collection where user_id = p_user_id + union all + select 1 from public.animation_collection where user_id = p_user_id + ) as folders; + + update public.usage_limits + set snippet_count = v_snippet_count, + animation_count = v_animation_count, + folder_count = v_folder_count, + updated_at = timezone('utc'::text, now()) + where user_id = p_user_id; + + update public.profiles + set snippet_count = v_snippet_count, + animation_count = v_animation_count, + folder_count = v_folder_count + where id = p_user_id; +end; +$$; + +create or replace function public.refresh_usage_counters_from_change() +returns trigger +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + target_user_id uuid; +begin + target_user_id := coalesce(new.user_id, old.user_id); + + if target_user_id is null then + if tg_op = 'DELETE' then + return old; + end if; + return new; + end if; + + perform public.refresh_usage_counters(target_user_id); + + if tg_op = 'DELETE' then + return old; + end if; + + return new; +end; +$$; + +drop trigger if exists refresh_usage_on_snippet_change on public.snippet; +create trigger refresh_usage_on_snippet_change +after insert or delete or update of user_id on public.snippet +for each row execute procedure public.refresh_usage_counters_from_change(); + +drop trigger if exists refresh_usage_on_animation_change on public.animation; +create trigger refresh_usage_on_animation_change +after insert or delete or update of user_id on public.animation +for each row execute procedure public.refresh_usage_counters_from_change(); + +drop trigger if exists refresh_usage_on_collection_change on public.collection; +create trigger refresh_usage_on_collection_change +after insert or delete or update of user_id on public.collection +for each row execute procedure public.refresh_usage_counters_from_change(); + +drop trigger if exists refresh_usage_on_animation_collection_change on public.animation_collection; +create trigger refresh_usage_on_animation_collection_change +after insert or delete or update of user_id on public.animation_collection +for each row execute procedure public.refresh_usage_counters_from_change(); From c85aa656ccdafa82853b25f912120e44bb76d517 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 13:13:36 -0300 Subject: [PATCH 013/102] :sparkles: feat: Implement consistent error handling with new `FriendlyError` component, client error boundary, and Next.js error pages. --- src/app/[shared_link]/error.tsx | 27 ++++++++++ src/app/animate/animation-client.tsx | 9 +++- src/app/animate/embed/[slug]/error.tsx | 27 ++++++++++ src/app/animate/error.tsx | 26 ++++++++++ src/app/animate/shared/[slug]/error.tsx | 27 ++++++++++ src/app/error.tsx | 26 ++++++++++ .../errors/client-error-boundary.tsx | 49 +++++++++++++++++++ src/components/errors/friendly-error.tsx | 48 ++++++++++++++++++ 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/app/[shared_link]/error.tsx create mode 100644 src/app/animate/embed/[slug]/error.tsx create mode 100644 src/app/animate/error.tsx create mode 100644 src/app/animate/shared/[slug]/error.tsx create mode 100644 src/app/error.tsx create mode 100644 src/components/errors/client-error-boundary.tsx create mode 100644 src/components/errors/friendly-error.tsx diff --git a/src/app/[shared_link]/error.tsx b/src/app/[shared_link]/error.tsx new file mode 100644 index 0000000..3610fc0 --- /dev/null +++ b/src/app/[shared_link]/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function SharedLinkError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/animation-client.tsx b/src/app/animate/animation-client.tsx index 6d99e62..8b03e00 100644 --- a/src/app/animate/animation-client.tsx +++ b/src/app/animate/animation-client.tsx @@ -27,6 +27,7 @@ import { useAnimationLimits } from "@/features/animations/hooks/use-animation-li import { AnimationTabs } from "@/features/animation/components/animation-tabs"; import { AnimationBottomBar } from "@/features/animation/components/animation-bottom-bar"; import { ThemeInjector } from "@/features/animation/components/theme-injector"; +import { ClientErrorBoundary } from "@/components/errors/client-error-boundary"; export default function AnimationClientPage() { const router = useRouter(); @@ -147,7 +148,11 @@ export default function AnimationClientPage() { ); return ( - <> +
- + ); } diff --git a/src/app/animate/embed/[slug]/error.tsx b/src/app/animate/embed/[slug]/error.tsx new file mode 100644 index 0000000..50dba43 --- /dev/null +++ b/src/app/animate/embed/[slug]/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function EmbeddedAnimationError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/error.tsx b/src/app/animate/error.tsx new file mode 100644 index 0000000..a1f0000 --- /dev/null +++ b/src/app/animate/error.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function AnimateError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/shared/[slug]/error.tsx b/src/app/animate/shared/[slug]/error.tsx new file mode 100644 index 0000000..c6390f6 --- /dev/null +++ b/src/app/animate/shared/[slug]/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function SharedAnimationError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..d4f5d17 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + ); +} diff --git a/src/components/errors/client-error-boundary.tsx b/src/components/errors/client-error-boundary.tsx new file mode 100644 index 0000000..1ad3eb7 --- /dev/null +++ b/src/components/errors/client-error-boundary.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "./friendly-error"; + +type Props = { + children: ReactNode; + title?: string; + description?: string; + actionLabel?: string; +}; + +type State = { + hasError: boolean; + error?: Error; +}; + +export class ClientErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: any) { + Sentry.captureException(error, { extra: errorInfo }); + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + }; + + render() { + if (this.state.hasError) { + return ( + + ); + } + + return this.props.children; + } +} diff --git a/src/components/errors/friendly-error.tsx b/src/components/errors/friendly-error.tsx new file mode 100644 index 0000000..516e4e9 --- /dev/null +++ b/src/components/errors/friendly-error.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +type Props = { + title?: string; + description?: string; + reset?: () => void; + actionLabel?: string; + className?: string; +}; + +export function FriendlyError({ + title = "Something went wrong", + description = "We hit a snag. Try again or head back home while we sort this out.", + reset, + actionLabel = "Try again", + className, +}: Props) { + return ( +
+ +
+
+ + ! + + Unexpected error +
+

{title}

+

{description}

+
+ {reset ? ( + + ) : null} + +
+
+
+
+ ); +} From 76cecf4f890979244835f08c3790a90d0aa59aa9 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 13:32:48 -0300 Subject: [PATCH 014/102] :sparkles: feat: Introduce Arcjet rate limiting to various API routes and create new limiters. --- src/app/[shared_link]/page.tsx | 16 +++- src/app/animate/shared/[slug]/page.tsx | 16 +++- src/app/api/auth/callback/route.ts | 6 ++ src/app/api/auth/logout/route.ts | 6 ++ src/app/api/changelog/route.ts | 6 ++ src/app/api/checkout/route.ts | 12 +++ src/app/api/customer-portal/route.ts | 12 +++ src/app/api/keepalive/route.ts | 5 ++ src/app/api/liveblocks-auth/route.ts | 11 +++ src/app/api/oembed/route.ts | 6 ++ src/app/api/og-image/route.tsx | 6 ++ src/app/api/save-shared-url-visits/route.ts | 5 ++ src/app/api/shorten-url/route.ts | 35 ++++++-- src/app/api/waitlist/route.ts | 6 ++ src/app/api/webhooks/stripe/route.ts | 5 ++ .../animation/enhanced-share-dialog.tsx | 6 +- src/features/animation/video-exporter.tsx | 3 +- src/lib/arcjet/limiters.ts | 89 +++++++++++++++++++ src/lib/services/usage-limits.ts | 2 +- 19 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 src/lib/arcjet/limiters.ts diff --git a/src/app/[shared_link]/page.tsx b/src/app/[shared_link]/page.tsx index 9d703fc..721fffc 100644 --- a/src/app/[shared_link]/page.tsx +++ b/src/app/[shared_link]/page.tsx @@ -64,9 +64,17 @@ export default async function SharedLinkPage({ params }: SharedLinkPageProps) { const hashedFallback = createHash("sha256").update(fallbackTokenSource || shared_link).digest("hex"); const viewerToken = viewerCookie ?? hashedFallback; + type ViewResult = { + allowed?: boolean; + counted?: boolean; + current?: number | null; + max?: number | null; + plan?: string | null; + }; + const supabase = await createClient(); - const { data: viewResult, error: viewError } = await supabase.rpc( - "record_public_share_view" as never, + const { data: viewResult, error: viewError } = await (supabase as any).rpc( + "record_public_share_view", { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } ); @@ -74,7 +82,9 @@ export default async function SharedLinkPage({ params }: SharedLinkPageProps) { console.error("Failed to record public share view", viewError); } - if (viewResult && (viewResult as any).allowed === false) { + const view = viewResult as ViewResult | null; + + if (view && view.allowed === false) { return (
diff --git a/src/app/animate/shared/[slug]/page.tsx b/src/app/animate/shared/[slug]/page.tsx index 1e1f66f..9a6dbaf 100644 --- a/src/app/animate/shared/[slug]/page.tsx +++ b/src/app/animate/shared/[slug]/page.tsx @@ -89,9 +89,17 @@ export default async function SharedAnimationPage({ params }: SharedAnimationPag const hashedFallback = createHash("sha256").update(fallbackTokenSource || slug).digest("hex"); const viewerToken = viewerCookie ?? hashedFallback; + type ViewResult = { + allowed?: boolean; + counted?: boolean; + current?: number | null; + max?: number | null; + plan?: string | null; + }; + const supabase = await createClient(); - const { data: viewResult, error: viewError } = await supabase.rpc( - "record_public_share_view" as never, + const { data: viewResult, error: viewError } = await (supabase as any).rpc( + "record_public_share_view", { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } ); @@ -99,7 +107,9 @@ export default async function SharedAnimationPage({ params }: SharedAnimationPag console.error("Failed to record public share view", viewError); } - if (viewResult && (viewResult as any).allowed === false) { + const view = viewResult as ViewResult | null; + + if (view && view.allowed === false) { return (
diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index f115993..fc6ef40 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -2,8 +2,14 @@ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; export async function GET(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["auth:callback"], + }); + if (limitResponse) return limitResponse; + const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get("code"); const next = requestUrl.searchParams.get("next") || "/"; diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 05aecb7..e9b8afd 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,13 @@ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; +import { enforceRateLimit, authedLimiter } from "@/lib/arcjet/limiters"; export async function POST(request: Request) { + const limitResponse = await enforceRateLimit(authedLimiter, request, { + tags: ["auth:logout"], + }); + if (limitResponse) return limitResponse; + const requestUrl = new URL(request.url); const supabase = await createClient(); diff --git a/src/app/api/changelog/route.ts b/src/app/api/changelog/route.ts index 697dbec..1a3f58e 100644 --- a/src/app/api/changelog/route.ts +++ b/src/app/api/changelog/route.ts @@ -1,9 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const CANNY_ENTRIES_URL = "https://canny.io/api/v1/entries/list"; export async function POST(req: NextRequest | Request) { try { + const limitResponse = await enforceRateLimit(publicLimiter, req as Request, { + tags: ["changelog"], + }); + if (limitResponse) return limitResponse; + const apiKey = process.env.CANNY_API_KEY; if (!apiKey) { diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 3a25f44..c51c273 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -6,6 +6,7 @@ import { getStripePriceId, } from '@/lib/services/stripe'; import type { PlanId } from '@/lib/config/plans'; +import { authedLimiter, enforceRateLimit, strictLimiter } from '@/lib/arcjet/limiters'; export const dynamic = 'force-dynamic'; @@ -16,6 +17,11 @@ type CheckoutRequestBody = { export async function POST(request: NextRequest) { try { + const limitResponse = await enforceRateLimit(strictLimiter, request, { + tags: ["checkout:create"], + }); + if (limitResponse) return limitResponse; + // Get authenticated user const supabase = await createClient(); const { @@ -27,6 +33,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["checkout:create", "user"], + }); + if (userLimited) return userLimited; + // Parse request body const body: CheckoutRequestBody = await request.json(); const { plan, interval } = body; diff --git a/src/app/api/customer-portal/route.ts b/src/app/api/customer-portal/route.ts index 4000b11..09c9df1 100644 --- a/src/app/api/customer-portal/route.ts +++ b/src/app/api/customer-portal/route.ts @@ -1,11 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/utils/supabase/server'; import { createCustomerPortalSession } from '@/lib/services/stripe'; +import { enforceRateLimit, strictLimiter } from '@/lib/arcjet/limiters'; export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest) { try { + const limitResponse = await enforceRateLimit(strictLimiter, request, { + tags: ["customer-portal"], + }); + if (limitResponse) return limitResponse; + // Get authenticated user const supabase = await createClient(); const { @@ -17,6 +23,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["customer-portal", "user"], + }); + if (userLimited) return userLimited; + // Get user's Stripe customer ID const { data: profile } = await supabase .from('profiles') diff --git a/src/app/api/keepalive/route.ts b/src/app/api/keepalive/route.ts index 57561c1..d43f473 100644 --- a/src/app/api/keepalive/route.ts +++ b/src/app/api/keepalive/route.ts @@ -3,10 +3,15 @@ import { createClient } from '@supabase/supabase-js' import { NextRequest, NextResponse } from 'next/server' import { applyRequestContextToSentry, applyResponseContextToSentry } from '@/lib/sentry-context' +import { enforceRateLimit, publicLimiter } from '@/lib/arcjet/limiters' export const GET = wrapRouteHandlerWithSentry( async function GET(request: NextRequest) { applyRequestContextToSentry({ request }) + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["keepalive"], + }); + if (limitResponse) return limitResponse; try { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL diff --git a/src/app/api/liveblocks-auth/route.ts b/src/app/api/liveblocks-auth/route.ts index 7776413..daa1d6d 100644 --- a/src/app/api/liveblocks-auth/route.ts +++ b/src/app/api/liveblocks-auth/route.ts @@ -3,6 +3,7 @@ import { createClient } from "@/utils/supabase/server"; import { Liveblocks } from "@liveblocks/node"; import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; +import { authedLimiter, enforceRateLimit } from "@/lib/arcjet/limiters"; const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!; @@ -11,6 +12,10 @@ const liveblocks = new Liveblocks({ secret: API_KEY }); export const POST = wrapRouteHandlerWithSentry( async function POST(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(authedLimiter, request, { + tags: ["liveblocks-auth"], + }); + if (limitResponse) return limitResponse; const supabase = await createClient(); @@ -21,6 +26,12 @@ export const POST = wrapRouteHandlerWithSentry( let userId = "anonymous"; if (data && data.user?.id) { userId = data.user.id; + + const userLimited = await enforceRateLimit(authedLimiter, request, { + userId, + tags: ["liveblocks-auth", "user"], + }); + if (userLimited) return userLimited; } applyRequestContextToSentry({ diff --git a/src/app/api/oembed/route.ts b/src/app/api/oembed/route.ts index 0a7720b..87571f8 100644 --- a/src/app/api/oembed/route.ts +++ b/src/app/api/oembed/route.ts @@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { siteConfig } from "@/lib/utils/site-config"; import { getSharedLink } from "@/lib/services/shared-link"; import { decodeAnimationSharePayload, extractAnimationPayloadFromUrl } from "@/features/animation/share-utils"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; export async function GET(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["oembed"], + }); + if (limitResponse) return limitResponse; + const searchParams = request.nextUrl.searchParams; const urlParam = searchParams.get("url"); diff --git a/src/app/api/og-image/route.tsx b/src/app/api/og-image/route.tsx index d0def40..a5d7a6e 100644 --- a/src/app/api/og-image/route.tsx +++ b/src/app/api/og-image/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from "next/og"; import { Logo } from "@/components/ui/logo"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; export const runtime = "edge"; export const dynamic = "force-dynamic"; @@ -53,6 +54,11 @@ const safeDecodePayload = (raw: string | null): Payload | null => { export async function GET(request: Request) { try { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["og-image"], + }); + if (limitResponse) return limitResponse; + const { searchParams } = new URL(request.url); const payloadParam = searchParams.get("payload"); const slugParam = searchParams.get("slug"); diff --git a/src/app/api/save-shared-url-visits/route.ts b/src/app/api/save-shared-url-visits/route.ts index 6de0733..a056888 100644 --- a/src/app/api/save-shared-url-visits/route.ts +++ b/src/app/api/save-shared-url-visits/route.ts @@ -3,6 +3,7 @@ import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { SupabaseClient, createClient } from "@supabase/supabase-js"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const supabase: SupabaseClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -18,6 +19,10 @@ const supabase: SupabaseClient = createClient( export const POST = wrapRouteHandlerWithSentry( async function POST(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["shared-url:visits"], + }); + if (limitResponse) return limitResponse; try { const { id } = await await validateContentType(request).json(); diff --git a/src/app/api/shorten-url/route.ts b/src/app/api/shorten-url/route.ts index 57d7b0f..400a007 100644 --- a/src/app/api/shorten-url/route.ts +++ b/src/app/api/shorten-url/route.ts @@ -9,6 +9,7 @@ import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { nanoid } from "nanoid"; +import { enforceRateLimit, publicLimiter, strictLimiter } from "@/lib/arcjet/limiters"; export const runtime = "edge"; @@ -23,6 +24,11 @@ const keySet: Set = new Set(); export const GET = wrapRouteHandlerWithSentry( async function GET(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["shorten-url:get"], + }); + if (limitResponse) return limitResponse; + const supabase = await createClient(); const url = new URL(request.url); @@ -66,13 +72,19 @@ export const GET = wrapRouteHandlerWithSentry( viewerToken = nanoid(24); } - let viewResult: - | { allowed?: boolean; counted?: boolean; current?: number; max?: number | null; plan?: string } - | null = null; + type ViewResult = { + allowed?: boolean; + counted?: boolean; + current?: number | null; + max?: number | null; + plan?: string | null; + }; + + let viewResult: ViewResult | null = null; try { - const { data: recordViewData, error: recordViewError } = await supabase.rpc( - "record_public_share_view" as never, + const { data: recordViewData, error: recordViewError } = await (supabase as any).rpc( + "record_public_share_view", { p_owner_id: link.user_id, p_link_id: link.id, p_viewer_token: viewerToken } ); @@ -80,7 +92,7 @@ export const GET = wrapRouteHandlerWithSentry( throw recordViewError; } - viewResult = recordViewData as typeof viewResult; + viewResult = (recordViewData as ViewResult) ?? null; } catch (recordError) { console.error("Failed to record share view", recordError); } @@ -138,6 +150,11 @@ export const GET = wrapRouteHandlerWithSentry( export const POST = wrapRouteHandlerWithSentry( async function POST(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(strictLimiter, request, { + tags: ["shorten-url:create"], + }); + if (limitResponse) return limitResponse; + const supabase = await createClient(); try { const contentType = await request.headers.get("content-type"); @@ -173,6 +190,12 @@ export const POST = wrapRouteHandlerWithSentry( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["shorten-url:create", "user"], + }); + if (userLimited) return userLimited; + const { data: existingUrl, error } = await supabase .from("links") .select("url, short_url, title, description, user_id") diff --git a/src/app/api/waitlist/route.ts b/src/app/api/waitlist/route.ts index 3a61062..431e7a0 100644 --- a/src/app/api/waitlist/route.ts +++ b/src/app/api/waitlist/route.ts @@ -3,10 +3,16 @@ import { NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; import { FEATURE_FLAG_KEYS } from "@/lib/services/tracking/feature-flag-keys"; import type { Json } from "@/types/database"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const allowedFeatures = new Set(Object.values(FEATURE_FLAG_KEYS)); export async function POST(request: Request) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["waitlist"], + }); + if (limitResponse) return limitResponse; + const supabase = await createClient(); const { data: userData } = await supabase.auth.getUser(); const user = userData.user ?? null; diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index f23b159..8a0d7a4 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -5,6 +5,7 @@ import { constructWebhookEvent } from '@/lib/services/stripe'; import type { PlanId } from '@/lib/config/plans'; import type { Json } from '@/types/database'; import Stripe from 'stripe'; +import { enforceRateLimit, webhookLimiter } from '@/lib/arcjet/limiters'; export const dynamic = 'force-dynamic'; type ServiceRoleClient = ReturnType; @@ -172,6 +173,10 @@ async function handleSubscriptionDeleted( export async function POST(request: NextRequest) { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + const limitResponse = await enforceRateLimit(webhookLimiter, request, { + tags: ["webhook:stripe"], + }); + if (limitResponse) return limitResponse; if (!webhookSecret) { console.error('STRIPE_WEBHOOK_SECRET not set'); diff --git a/src/features/animation/enhanced-share-dialog.tsx b/src/features/animation/enhanced-share-dialog.tsx index b98cfe3..b3bd6f0 100644 --- a/src/features/animation/enhanced-share-dialog.tsx +++ b/src/features/animation/enhanced-share-dialog.tsx @@ -279,7 +279,11 @@ export const EnhancedAnimationShareDialog = () => { } const publicShares = usage?.publicShares; - if (publicShares?.max !== null && publicShares.current >= publicShares.max) { + if ( + publicShares && + publicShares.max !== null && + publicShares.current >= publicShares.max + ) { openUpgradeForShares(publicShares.current, publicShares.max); toast.error("You’ve reached your public view limit. Upgrade for more views."); return undefined; diff --git a/src/features/animation/video-exporter.tsx b/src/features/animation/video-exporter.tsx index 0b373cf..5034b9a 100644 --- a/src/features/animation/video-exporter.tsx +++ b/src/features/animation/video-exporter.tsx @@ -46,6 +46,7 @@ export const VideoExporter = ({ const frameRenderedResolver = useRef<(() => void) | null>(null); const processingRef = useRef(false); const cancelRef = useRef(cancelled); + const { width, height } = getResolutionDimensions(settings.resolution); useEffect(() => { cancelRef.current = cancelled; @@ -227,8 +228,6 @@ export const VideoExporter = ({ processAnimation(); }, [settings, slides, editorSettings, onProgress, onComplete, onError, cancelled, onCancelled, width, height]); // Added editorSettings to deps - const { width, height } = getResolutionDimensions(settings.resolution); - // Render content similar to UnifiedAnimationCanvas but scaled/fixed const renderContent = () => { if (!currentFrame) return null; diff --git a/src/lib/arcjet/limiters.ts b/src/lib/arcjet/limiters.ts new file mode 100644 index 0000000..859a503 --- /dev/null +++ b/src/lib/arcjet/limiters.ts @@ -0,0 +1,89 @@ +import arcjet, { shield, tokenBucket } from "@arcjet/next"; +import { NextResponse } from "next/server"; + +type LimiterConfig = { + capacity: number; + refillRate: number; + interval: number; + characteristics?: string[]; + mode?: "LIVE" | "DRY_RUN"; +}; + +const key = process.env.ARCJET_KEY; + +if (!key) { + // Fail fast so we don't silently run without protection in prod. + console.warn("ARCJET_KEY is not set; rate limiting will be skipped."); +} + +const baseShield = shield({ mode: process.env.NODE_ENV === "production" ? "LIVE" : "DRY_RUN" }); + +type ArcjetInstance = ReturnType; + +const createLimiter = (config: LimiterConfig): ArcjetInstance | null => { + if (!key) return null; + + return arcjet({ + key, + rules: [ + baseShield, + tokenBucket({ + mode: config.mode ?? (process.env.NODE_ENV === "production" ? "LIVE" : "DRY_RUN"), + characteristics: config.characteristics ?? ["ip.src"], + capacity: config.capacity, + refillRate: config.refillRate, + interval: config.interval, + }), + ], + }); +}; + +export const publicLimiter = createLimiter({ + capacity: 40, + refillRate: 20, + interval: 60, + characteristics: ["ip.src"], +}); + +export const authedLimiter = createLimiter({ + capacity: 60, + refillRate: 30, + interval: 60, + characteristics: ["user.id", "ip.src"], +}); + +export const strictLimiter = createLimiter({ + capacity: 10, + refillRate: 5, + interval: 60, + characteristics: ["user.id", "ip.src"], +}); + +export const webhookLimiter = createLimiter({ + capacity: 100, + refillRate: 50, + interval: 60, + characteristics: ["ip.src"], +}); + +export async function enforceRateLimit( + limiter: ArcjetInstance | null, + req: Request, + options?: { requested?: number; userId?: string; tags?: string[] }, +) { + if (!limiter) return null; + + const decision = await (limiter as any).protect(req, { + requested: options?.requested ?? 1, + user: options?.userId, + tags: options?.tags, + }); + + if (decision.isDenied()) { + const status = decision.reason.isRateLimit() ? 429 : 403; + const message = decision.reason.isRateLimit() ? "Too many requests" : "Forbidden"; + return NextResponse.json({ error: message }, { status }); + } + + return null; +} diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index 0baf50e..ba52dfe 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -165,7 +165,7 @@ const callLimitRpc = async ( userId: string, kind: UsageLimitKind ): Promise => { - const { data, error } = await supabase.rpc(fn, { p_user_id: userId }); + const { data, error } = await supabase.rpc(fn as any, { p_user_id: userId }); if (error) { console.error(`RPC ${fn} failed`, error); From eee1439d6b2c6e414f56c5ec8cc8a2e971cbe001 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 13:46:22 -0300 Subject: [PATCH 015/102] :sparkles: feat: Implement Zod-based input validation for snippet and animation actions by adding a new validation utility and integrating it into existing actions. --- src/actions/animations/create-animation.ts | 177 +++++++++++---------- src/actions/animations/update-animation.ts | 95 +++++------ src/actions/snippets/create-snippet.ts | 108 +++++++------ src/actions/snippets/update-snippet.ts | 89 ++++++----- src/actions/utils/validation.ts | 112 +++++++++++++ 5 files changed, 352 insertions(+), 229 deletions(-) create mode 100644 src/actions/utils/validation.ts diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index 380e471..176c3c0 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -7,97 +7,100 @@ import { success, error, type ActionResult } from '@/actions/utils/action-result import { insertAnimation } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' +import { createAnimationInputSchema, formatZodError } from '@/actions/utils/validation' export type CreateAnimationInput = { - id: string - title: string - slides: AnimationSlide[] - settings: AnimationSettings - url?: string | null + id: string + title: string + slides: AnimationSlide[] + settings: AnimationSettings + url?: string | null } export async function createAnimation( - input: CreateAnimationInput + input: CreateAnimationInput ): Promise> { - try { - const { id, title, slides, settings, url } = input - - if (!id || !Array.isArray(slides) || slides.length === 0) { - return error('Missing required fields: id and slides are required') - } - - if (slides.length < 2) { - return error('Add at least two slides to save an animation') - } - - const { user, supabase } = await requireAuth() - - // Check animation save limit - const { data: animationLimitCheck, error: animationLimitError } = await supabase.rpc('check_animation_limit', { - p_user_id: user.id - }) - - if (animationLimitError) { - console.error('Error checking animation limit:', animationLimitError) - return error('Failed to verify save limit. Please try again.') - } - - if (!animationLimitCheck.canSave) { - const plan = animationLimitCheck.plan - if (plan === 'free') { - return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') - } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') - } - return error('Animation limit reached. Please upgrade your plan.') - } - - // Check slide count limit - const { data: slideLimitCheck, error: slideLimitError } = await supabase.rpc('check_slide_limit', { - p_user_id: user.id, - p_slide_count: slides.length - }) - - if (slideLimitError) { - console.error('Error checking slide limit:', slideLimitError) - return error('Failed to verify slide limit. Please try again.') - } - - if (!slideLimitCheck.canAdd) { - const plan = slideLimitCheck.plan - if (plan === 'free') { - return error('Free users can add up to 3 slides. Upgrade to Started for 10 slides per animation!') - } else if (plan === 'started') { - return error('Started users can add up to 10 slides. Upgrade to Pro for unlimited slides!') - } - return error('Slide limit exceeded. Please upgrade your plan.') - } - - const data = await insertAnimation({ - id, - user_id: user.id, - title: title || 'Untitled', - slides, - settings, - url: url || null, - supabase - }) - - if (!data || data.length === 0) { - return error('Failed to create animation') - } - - revalidatePath('/animate') - revalidatePath('/animations') - - return success(data[0] as Animation) - } catch (err) { - console.error('Error creating animation:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to create animation. Please try again later.') - } + try { + const parsedInput = createAnimationInputSchema.safeParse(input) + + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } + + const { id, title, slides, settings, url } = parsedInput.data + + if (slides.length < 2) { + return error('Add at least two slides to save an animation') + } + + const { user, supabase } = await requireAuth() + + // Check animation save limit + const { data: animationLimitCheck, error: animationLimitError } = await supabase.rpc('check_animation_limit', { + p_user_id: user.id + }) + + if (animationLimitError) { + console.error('Error checking animation limit:', animationLimitError) + return error('Failed to verify save limit. Please try again.') + } + + if (!animationLimitCheck.canSave) { + const plan = animationLimitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') + } + return error('Animation limit reached. Please upgrade your plan.') + } + + // Check slide count limit + const { data: slideLimitCheck, error: slideLimitError } = await supabase.rpc('check_slide_limit', { + p_user_id: user.id, + p_slide_count: slides.length + }) + + if (slideLimitError) { + console.error('Error checking slide limit:', slideLimitError) + return error('Failed to verify slide limit. Please try again.') + } + + if (!slideLimitCheck.canAdd) { + const plan = slideLimitCheck.plan + if (plan === 'free') { + return error('Free users can add up to 3 slides. Upgrade to Started for 10 slides per animation!') + } else if (plan === 'started') { + return error('Started users can add up to 10 slides. Upgrade to Pro for unlimited slides!') + } + return error('Slide limit exceeded. Please upgrade your plan.') + } + + const data = await insertAnimation({ + id, + user_id: user.id, + title: title || 'Untitled', + slides, + settings, + url: url || null, + supabase + }) + + if (!data || data.length === 0) { + return error('Failed to create animation') + } + + revalidatePath('/animate') + revalidatePath('/animations') + + return success(data[0] as Animation) + } catch (err) { + console.error('Error creating animation:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to create animation. Please try again later.') + } } diff --git a/src/actions/animations/update-animation.ts b/src/actions/animations/update-animation.ts index 83d1031..68bd1bc 100644 --- a/src/actions/animations/update-animation.ts +++ b/src/actions/animations/update-animation.ts @@ -9,66 +9,69 @@ import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' import { checkSlideLimit } from '@/lib/services/usage-limits' import type { PlanId } from '@/lib/config/plans' +import { formatZodError, updateAnimationInputSchema } from '@/actions/utils/validation' export type UpdateAnimationInput = { - id: string - title?: string - slides?: AnimationSlide[] - settings?: AnimationSettings - url?: string | null + id: string + title?: string + slides?: AnimationSlide[] + settings?: AnimationSettings + url?: string | null } export async function updateAnimation( - input: UpdateAnimationInput + input: UpdateAnimationInput ): Promise> { - try { - const { id, title, slides, settings, url } = input + try { + const parsedInput = updateAnimationInputSchema.safeParse(input) - if (!id) { - return error('Animation id is required') - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } - const { user, supabase } = await requireAuth() - const { data: profile } = await supabase - .from('profiles') - .select('plan') - .eq('id', user.id) - .single() + const { id, title, slides, settings, url } = parsedInput.data - const plan = (profile?.plan as PlanId | null) ?? 'free' + const { user, supabase } = await requireAuth() + const { data: profile } = await supabase + .from('profiles') + .select('plan') + .eq('id', user.id) + .single() - if (slides && slides.length > 0) { - const slideLimit = checkSlideLimit(slides.length, plan) - if (!slideLimit.canSave) { - return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) - } - } + const plan = (profile?.plan as PlanId | null) ?? 'free' - const data = await updateAnimationDb({ - id, - user_id: user.id, - title: title || 'Untitled', - slides: slides || [], - settings: settings || ({} as AnimationSettings), - url: url || null, - supabase - }) + if (slides && slides.length > 0) { + const slideLimit = checkSlideLimit(slides.length, plan) + if (!slideLimit.canSave) { + return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) + } + } - if (!data || data.length === 0) { - return error('Failed to update animation') - } + const data = await updateAnimationDb({ + id, + user_id: user.id, + title: title || 'Untitled', + slides: slides || [], + settings: settings || ({} as AnimationSettings), + url: url || null, + supabase + }) - revalidatePath('/animate') - revalidatePath('/animations') + if (!data || data.length === 0) { + return error('Failed to update animation') + } - return success(data[0] as Animation) - } catch (err) { - console.error('Error updating animation:', err) + revalidatePath('/animate') + revalidatePath('/animations') - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + return success(data[0] as Animation) + } catch (err) { + console.error('Error updating animation:', err) - return error('Failed to update animation. Please try again later.') - } + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to update animation. Please try again later.') + } } diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index 568fbce..93dcc8d 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -5,12 +5,13 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertSnippet } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' +import { createSnippetInputSchema, formatZodError } from '@/actions/utils/validation' export type CreateSnippetInput = { - id: string - title?: string - code: string - language: string - url?: string + id: string + title?: string + code: string + language: string + url?: string } /** @@ -20,64 +21,65 @@ export type CreateSnippetInput = { * @returns ActionResult with created snippet or error message */ export async function createSnippet( - input: CreateSnippetInput + input: CreateSnippetInput ): Promise> { - try { - const { id, title, code, language, url } = input + try { + const parsedInput = createSnippetInputSchema.safeParse(input) - // Validation - if (!id || !code || !language) { - return error('Missing required fields: id, code, and language are required') - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') + } - const { user, supabase } = await requireAuth() + const { id, title, code, language, url } = parsedInput.data - // Check snippet limit before allowing save - const { data: limitCheck, error: limitError } = await supabase.rpc('check_snippet_limit', { - p_user_id: user.id - }) + const { user, supabase } = await requireAuth() - if (limitError) { - console.error('Error checking snippet limit:', limitError) - return error('Failed to verify save limit. Please try again.') - } + // Check snippet limit before allowing save + const { data: limitCheck, error: limitError } = await supabase.rpc('check_snippet_limit', { + p_user_id: user.id + }) - if (!limitCheck.canSave) { - const plan = limitCheck.plan - if (plan === 'free') { - return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') - } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') - } - return error('Snippet limit reached. Please upgrade your plan.') - } + if (limitError) { + console.error('Error checking snippet limit:', limitError) + return error('Failed to verify save limit. Please try again.') + } - const data = await insertSnippet({ - id, - user_id: user.id, - title: title || 'Untitled', - code, - language, - url: url || null, - supabase - }) + if (!limitCheck.canSave) { + const plan = limitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') + } + return error('Snippet limit reached. Please upgrade your plan.') + } - if (!data || data.length === 0) { - return error('Failed to create snippet') - } + const data = await insertSnippet({ + id, + user_id: user.id, + title: title || 'Untitled', + code, + language, + url: url || null, + supabase + }) - // Revalidate the snippets list - revalidatePath('/snippets') - revalidatePath('/') + if (!data || data.length === 0) { + return error('Failed to create snippet') + } - return success(data[0]) - } catch (err) { - console.error('Error creating snippet:', err) + // Revalidate the snippets list + revalidatePath('/snippets') + revalidatePath('/') - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + return success(data[0]) + } catch (err) { + console.error('Error creating snippet:', err) - return error('Failed to create snippet. Please try again later.') - } + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to create snippet. Please try again later.') + } } diff --git a/src/actions/snippets/update-snippet.ts b/src/actions/snippets/update-snippet.ts index 1252d3c..a0e95b4 100644 --- a/src/actions/snippets/update-snippet.ts +++ b/src/actions/snippets/update-snippet.ts @@ -5,13 +5,14 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { updateSnippet as updateSnippetInDb } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' +import { formatZodError, updateSnippetInputSchema } from '@/actions/utils/validation' export type UpdateSnippetInput = { - id: string - title?: string - code?: string - language?: string - url?: string + id: string + title?: string + code?: string + language?: string + url?: string } /** @@ -21,43 +22,45 @@ export type UpdateSnippetInput = { * @returns ActionResult with updated snippet or error message */ export async function updateSnippet( - input: UpdateSnippetInput + input: UpdateSnippetInput ): Promise> { - try { - const { id, title, code, language, url } = input - - if (!id) { - return error('Snippet ID is required') - } - - const { user, supabase } = await requireAuth() - - const data = await updateSnippetInDb({ - id, - user_id: user.id, - title, - code, - language, - url, - supabase - } as any) - - if (!data || data.length === 0) { - return error('Failed to update snippet') - } - - // Revalidate relevant paths - revalidatePath('/snippets') - revalidatePath('/') - - return success(data[0]) - } catch (err) { - console.error('Error updating snippet:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to update snippet. Please try again later.') - } + try { + const parsedInput = updateSnippetInputSchema.safeParse(input) + + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') + } + + const { id, title, code, language, url } = parsedInput.data + + const { user, supabase } = await requireAuth() + + const data = await updateSnippetInDb({ + id, + user_id: user.id, + title, + code, + language, + url, + supabase + } as any) + + if (!data || data.length === 0) { + return error('Failed to update snippet') + } + + // Revalidate relevant paths + revalidatePath('/snippets') + revalidatePath('/') + + return success(data[0]) + } catch (err) { + console.error('Error updating snippet:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to update snippet. Please try again later.') + } } diff --git a/src/actions/utils/validation.ts b/src/actions/utils/validation.ts new file mode 100644 index 0000000..a1018c6 --- /dev/null +++ b/src/actions/utils/validation.ts @@ -0,0 +1,112 @@ +import { z, ZodError } from 'zod' + +import { languages } from '@/lib/language-options' + +const MAX_TITLE_LENGTH = 200 +const MAX_CODE_BYTES = 100 * 1024 // 100KB +const scriptTagPattern = /]*>[\s\S]*?<\/script>/gi + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const stripDangerousContent = (value: string) => value.replace(scriptTagPattern, match => escapeHtml(match)) + +export const sanitizeTextInput = (value: string) => + stripDangerousContent(value) + .replace(/<[^>]*>/g, '') + .replace(/\0/g, '') + .trim() + +export const sanitizeCodeInput = (value: string) => + stripDangerousContent(value) + .replace(/\0/g, '') + +export const languageSchema = z + .string() + .min(1, 'Language is required') + .refine(lang => Object.keys(languages).includes(lang), { message: 'Unsupported language' }) + +const codeSizeValidator = (code: string) => Buffer.byteLength(code, 'utf8') <= MAX_CODE_BYTES + +export const codeSchema = z + .string() + .min(1, 'Code is required') + .refine(codeSizeValidator, 'Code exceeds 100KB limit') + .transform(sanitizeCodeInput) + +const baseTitleSchema = z.string().max(MAX_TITLE_LENGTH, `Title must be ${MAX_TITLE_LENGTH} characters or less`) + +export const titleSchema = baseTitleSchema + .min(1, 'Title is required') + .transform(sanitizeTextInput) + +export const optionalTitleSchema = baseTitleSchema + .transform(sanitizeTextInput) + .optional() + +export const optionalTitleToStringSchema = optionalTitleSchema.transform(value => value ?? 'Untitled') + +export const idSchema = z.string().trim().min(1, 'ID is required') + +export const urlSchema = z + .string() + .trim() + .max(2048, 'URL is too long') + .transform(sanitizeTextInput) + .optional() + +export const animationSettingsSchema = z.object({ + fps: z.union([z.literal(24), z.literal(30), z.literal(60)]), + resolution: z.enum(['720p', '1080p']), + transitionType: z.enum(['fade', 'diff']), + exportFormat: z.enum(['mp4', 'webm', 'gif']), + quality: z.enum(['fast', 'balanced', 'high']) +}) + +export const animationSlideSchema = z.object({ + id: idSchema, + code: codeSchema, + title: optionalTitleToStringSchema, + language: languageSchema, + autoDetectLanguage: z.boolean().optional(), + duration: z.number().positive('Duration must be positive') +}) + +export const createSnippetInputSchema = z.object({ + id: idSchema, + title: optionalTitleSchema, + code: codeSchema, + language: languageSchema, + url: urlSchema +}) + +export const updateSnippetInputSchema = z.object({ + id: idSchema, + title: optionalTitleSchema, + code: codeSchema.optional(), + language: languageSchema.optional(), + url: urlSchema +}) + +export const createAnimationInputSchema = z.object({ + id: idSchema, + title: optionalTitleToStringSchema, + slides: z.array(animationSlideSchema).min(1, 'At least one slide is required'), + settings: animationSettingsSchema, + url: urlSchema.nullish() +}) + +export const updateAnimationInputSchema = z.object({ + id: idSchema, + title: optionalTitleToStringSchema, + slides: z.array(animationSlideSchema).optional(), + settings: animationSettingsSchema.optional(), + url: urlSchema.nullish() +}) + +export const formatZodError = (err: unknown) => { + if (err instanceof ZodError) { + return err.issues[0]?.message || 'Invalid input' + } + return null +} From c73c90b3354951d40244b4e73c066dabde51eefa Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 14:16:05 -0300 Subject: [PATCH 016/102] :sparkles: feat: introduce `withAuth` higher-order functions to centralize authentication for routes and actions. --- src/actions/animations/create-animation.ts | 164 ++++++++++---------- src/actions/animations/update-animation.ts | 85 +++++----- src/actions/snippets/create-snippet.ts | 98 ++++++------ src/actions/snippets/update-snippet.ts | 78 +++++----- src/actions/utils/auth.ts | 62 ++++---- src/actions/utils/with-auth.ts | 9 ++ src/app/api/save-shared-url-visits/route.ts | 10 +- src/app/api/shorten-url/route.ts | 18 +-- src/lib/auth/with-auth-route.ts | 28 ++++ 9 files changed, 285 insertions(+), 267 deletions(-) create mode 100644 src/actions/utils/with-auth.ts create mode 100644 src/lib/auth/with-auth-route.ts diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index 176c3c0..0856d0d 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -2,12 +2,12 @@ import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertAnimation } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' import { createAnimationInputSchema, formatZodError } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' export type CreateAnimationInput = { id: string @@ -18,89 +18,83 @@ export type CreateAnimationInput = { } export async function createAnimation( - input: CreateAnimationInput + input: CreateAnimationInput ): Promise> { - try { - const parsedInput = createAnimationInputSchema.safeParse(input) - - if (!parsedInput.success) { - return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') - } - - const { id, title, slides, settings, url } = parsedInput.data - - if (slides.length < 2) { - return error('Add at least two slides to save an animation') - } - - const { user, supabase } = await requireAuth() - - // Check animation save limit - const { data: animationLimitCheck, error: animationLimitError } = await supabase.rpc('check_animation_limit', { - p_user_id: user.id - }) - - if (animationLimitError) { - console.error('Error checking animation limit:', animationLimitError) - return error('Failed to verify save limit. Please try again.') - } - - if (!animationLimitCheck.canSave) { - const plan = animationLimitCheck.plan - if (plan === 'free') { - return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') - } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') - } - return error('Animation limit reached. Please upgrade your plan.') - } - - // Check slide count limit - const { data: slideLimitCheck, error: slideLimitError } = await supabase.rpc('check_slide_limit', { - p_user_id: user.id, - p_slide_count: slides.length - }) - - if (slideLimitError) { - console.error('Error checking slide limit:', slideLimitError) - return error('Failed to verify slide limit. Please try again.') - } - - if (!slideLimitCheck.canAdd) { - const plan = slideLimitCheck.plan - if (plan === 'free') { - return error('Free users can add up to 3 slides. Upgrade to Started for 10 slides per animation!') - } else if (plan === 'started') { - return error('Started users can add up to 10 slides. Upgrade to Pro for unlimited slides!') - } - return error('Slide limit exceeded. Please upgrade your plan.') - } - - const data = await insertAnimation({ - id, - user_id: user.id, - title: title || 'Untitled', - slides, - settings, - url: url || null, - supabase - }) - - if (!data || data.length === 0) { - return error('Failed to create animation') - } - - revalidatePath('/animate') - revalidatePath('/animations') - - return success(data[0] as Animation) - } catch (err) { - console.error('Error creating animation:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to create animation. Please try again later.') - } + try { + const parsedInput = createAnimationInputSchema.safeParse(input) + + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } + + const payload = parsedInput.data + + if (payload.slides.length < 2) { + return error('Add at least two slides to save an animation') + } + + return withAuthAction(payload, async ({ id, title, slides, settings, url }, { user, supabase }) => { + const { data: animationLimitCheck, error: animationLimitError } = await supabase.rpc('check_animation_limit', { + p_user_id: user.id + }) + + if (animationLimitError) { + console.error('Error checking animation limit:', animationLimitError) + return error('Failed to verify save limit. Please try again.') + } + + if (!animationLimitCheck.canSave) { + const plan = animationLimitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') + } + return error('Animation limit reached. Please upgrade your plan.') + } + + const { data: slideLimitCheck, error: slideLimitError } = await supabase.rpc('check_slide_limit', { + p_user_id: user.id, + p_slide_count: slides.length + }) + + if (slideLimitError) { + console.error('Error checking slide limit:', slideLimitError) + return error('Failed to verify slide limit. Please try again.') + } + + if (!slideLimitCheck.canAdd) { + const plan = slideLimitCheck.plan + if (plan === 'free') { + return error('Free users can add up to 3 slides. Upgrade to Started for 10 slides per animation!') + } else if (plan === 'started') { + return error('Started users can add up to 10 slides. Upgrade to Pro for unlimited slides!') + } + return error('Slide limit exceeded. Please upgrade your plan.') + } + + const data = await insertAnimation({ + id, + user_id: user.id, + title: title || 'Untitled', + slides, + settings, + url: url || null, + supabase + }) + + if (!data || data.length === 0) { + return error('Failed to create animation') + } + + revalidatePath('/animate') + revalidatePath('/animations') + + return success(data[0] as Animation) + }) + } catch (err) { + console.error('Error creating animation:', err) + + return error('Failed to create animation. Please try again later.') + } } diff --git a/src/actions/animations/update-animation.ts b/src/actions/animations/update-animation.ts index 68bd1bc..e605f69 100644 --- a/src/actions/animations/update-animation.ts +++ b/src/actions/animations/update-animation.ts @@ -2,7 +2,6 @@ import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { updateAnimation as updateAnimationDb } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' @@ -10,6 +9,7 @@ import type { AnimationSettings, AnimationSlide } from '@/types/animation' import { checkSlideLimit } from '@/lib/services/usage-limits' import type { PlanId } from '@/lib/config/plans' import { formatZodError, updateAnimationInputSchema } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' export type UpdateAnimationInput = { id: string @@ -20,58 +20,55 @@ export type UpdateAnimationInput = { } export async function updateAnimation( - input: UpdateAnimationInput + input: UpdateAnimationInput ): Promise> { - try { - const parsedInput = updateAnimationInputSchema.safeParse(input) + try { + const parsedInput = updateAnimationInputSchema.safeParse(input) - if (!parsedInput.success) { - return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } - const { id, title, slides, settings, url } = parsedInput.data + const payload = parsedInput.data - const { user, supabase } = await requireAuth() - const { data: profile } = await supabase - .from('profiles') - .select('plan') - .eq('id', user.id) - .single() + return withAuthAction(payload, async ({ id, title, slides, settings, url }, { user, supabase }) => { + const { data: profile } = await supabase + .from('profiles') + .select('plan') + .eq('id', user.id) + .single() - const plan = (profile?.plan as PlanId | null) ?? 'free' + const plan = (profile?.plan as PlanId | null) ?? 'free' - if (slides && slides.length > 0) { - const slideLimit = checkSlideLimit(slides.length, plan) - if (!slideLimit.canSave) { - return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) - } - } + if (slides && slides.length > 0) { + const slideLimit = checkSlideLimit(slides.length, plan) + if (!slideLimit.canSave) { + return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) + } + } - const data = await updateAnimationDb({ - id, - user_id: user.id, - title: title || 'Untitled', - slides: slides || [], - settings: settings || ({} as AnimationSettings), - url: url || null, - supabase - }) + const data = await updateAnimationDb({ + id, + user_id: user.id, + title: title || 'Untitled', + slides: slides || [], + settings: settings || ({} as AnimationSettings), + url: url || null, + supabase + }) - if (!data || data.length === 0) { - return error('Failed to update animation') - } + if (!data || data.length === 0) { + return error('Failed to update animation') + } - revalidatePath('/animate') - revalidatePath('/animations') + revalidatePath('/animate') + revalidatePath('/animations') - return success(data[0] as Animation) - } catch (err) { - console.error('Error updating animation:', err) + return success(data[0] as Animation) + }) + } catch (err) { + console.error('Error updating animation:', err) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to update animation. Please try again later.') - } + return error('Failed to update animation. Please try again later.') + } } diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index 93dcc8d..58d512e 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -1,11 +1,11 @@ 'use server' import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertSnippet } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' import { createSnippetInputSchema, formatZodError } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' export type CreateSnippetInput = { id: string title?: string @@ -21,65 +21,61 @@ export type CreateSnippetInput = { * @returns ActionResult with created snippet or error message */ export async function createSnippet( - input: CreateSnippetInput + input: CreateSnippetInput ): Promise> { - try { - const parsedInput = createSnippetInputSchema.safeParse(input) + try { + const parsedInput = createSnippetInputSchema.safeParse(input) - if (!parsedInput.success) { - return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') + } - const { id, title, code, language, url } = parsedInput.data + const payload = parsedInput.data - const { user, supabase } = await requireAuth() + return withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => { + // Check snippet limit before allowing save + const { data: limitCheck, error: limitError } = await supabase.rpc('check_snippet_limit', { + p_user_id: user.id + }) - // Check snippet limit before allowing save - const { data: limitCheck, error: limitError } = await supabase.rpc('check_snippet_limit', { - p_user_id: user.id - }) + if (limitError) { + console.error('Error checking snippet limit:', limitError) + return error('Failed to verify save limit. Please try again.') + } - if (limitError) { - console.error('Error checking snippet limit:', limitError) - return error('Failed to verify save limit. Please try again.') - } + if (!limitCheck.canSave) { + const plan = limitCheck.plan + if (plan === 'free') { + return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') + } else if (plan === 'started') { + return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') + } + return error('Snippet limit reached. Please upgrade your plan.') + } - if (!limitCheck.canSave) { - const plan = limitCheck.plan - if (plan === 'free') { - return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') - } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') - } - return error('Snippet limit reached. Please upgrade your plan.') - } + const data = await insertSnippet({ + id, + user_id: user.id, + title: title || 'Untitled', + code, + language, + url: url || null, + supabase + }) - const data = await insertSnippet({ - id, - user_id: user.id, - title: title || 'Untitled', - code, - language, - url: url || null, - supabase - }) + if (!data || data.length === 0) { + return error('Failed to create snippet') + } - if (!data || data.length === 0) { - return error('Failed to create snippet') - } + // Revalidate the snippets list + revalidatePath('/snippets') + revalidatePath('/') - // Revalidate the snippets list - revalidatePath('/snippets') - revalidatePath('/') + return success(data[0]) + }) + } catch (err) { + console.error('Error creating snippet:', err) - return success(data[0]) - } catch (err) { - console.error('Error creating snippet:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to create snippet. Please try again later.') - } + return error('Failed to create snippet. Please try again later.') + } } diff --git a/src/actions/snippets/update-snippet.ts b/src/actions/snippets/update-snippet.ts index a0e95b4..a8023b1 100644 --- a/src/actions/snippets/update-snippet.ts +++ b/src/actions/snippets/update-snippet.ts @@ -1,11 +1,11 @@ 'use server' import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { updateSnippet as updateSnippetInDb } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' import { formatZodError, updateSnippetInputSchema } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' export type UpdateSnippetInput = { id: string @@ -22,45 +22,41 @@ export type UpdateSnippetInput = { * @returns ActionResult with updated snippet or error message */ export async function updateSnippet( - input: UpdateSnippetInput + input: UpdateSnippetInput ): Promise> { - try { - const parsedInput = updateSnippetInputSchema.safeParse(input) - - if (!parsedInput.success) { - return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') - } - - const { id, title, code, language, url } = parsedInput.data - - const { user, supabase } = await requireAuth() - - const data = await updateSnippetInDb({ - id, - user_id: user.id, - title, - code, - language, - url, - supabase - } as any) - - if (!data || data.length === 0) { - return error('Failed to update snippet') - } - - // Revalidate relevant paths - revalidatePath('/snippets') - revalidatePath('/') - - return success(data[0]) - } catch (err) { - console.error('Error updating snippet:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - return error('Failed to update snippet. Please try again later.') - } + try { + const parsedInput = updateSnippetInputSchema.safeParse(input) + + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') + } + + const payload = parsedInput.data + + const data = await withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => + updateSnippetInDb({ + id, + user_id: user.id, + title, + code, + language, + url, + supabase + } as any) + ) + + if (!data || data.length === 0) { + return error('Failed to update snippet') + } + + // Revalidate relevant paths + revalidatePath('/snippets') + revalidatePath('/') + + return success(data[0]) + } catch (err) { + console.error('Error updating snippet:', err) + + return error('Failed to update snippet. Please try again later.') + } } diff --git a/src/actions/utils/auth.ts b/src/actions/utils/auth.ts index 321faf5..7448106 100644 --- a/src/actions/utils/auth.ts +++ b/src/actions/utils/auth.ts @@ -3,12 +3,12 @@ import { createClient } from '@/utils/supabase/server' import type { SupabaseClient } from '@supabase/supabase-js' -type AuthResult = { - user: { - id: string - email?: string - } - supabase: SupabaseClient +export type AuthResult = { + user: { + id: string + email?: string + } + supabase: SupabaseClient } /** @@ -16,35 +16,35 @@ type AuthResult = { * Throws an error if user is not authenticated */ export async function requireAuth(): Promise { - const supabase = await createClient() - - const { data: { user }, error } = await supabase.auth.getUser() - - if (error) { - console.error('Auth error:', error) - throw new Error(`Authentication failed: ${error.message}`) - } - - if (!user) { - throw new Error('User must be authenticated') - } - - return { - user: { - id: user.id, - email: user.email - }, - supabase - } + const supabase = await createClient() + + const { data: { user }, error } = await supabase.auth.getUser() + + if (error) { + console.error('Auth error:', error) + throw new Error(`Authentication failed: ${error.message}`) + } + + if (!user) { + throw new Error('User must be authenticated') + } + + return { + user: { + id: user.id, + email: user.email + }, + supabase + } } /** * Gets the current user if authenticated, returns null otherwise */ export async function getAuthUser(): Promise { - try { - return await requireAuth() - } catch { - return null - } + try { + return await requireAuth() + } catch { + return null + } } diff --git a/src/actions/utils/with-auth.ts b/src/actions/utils/with-auth.ts new file mode 100644 index 0000000..74bab1e --- /dev/null +++ b/src/actions/utils/with-auth.ts @@ -0,0 +1,9 @@ +import { requireAuth, type AuthResult } from '@/actions/utils/auth' + +export async function withAuthAction( + input: Input, + handler: (input: Input, ctx: AuthResult) => Promise +): Promise { + const ctx = await requireAuth() + return handler(input, ctx) +} diff --git a/src/app/api/save-shared-url-visits/route.ts b/src/app/api/save-shared-url-visits/route.ts index a056888..11097eb 100644 --- a/src/app/api/save-shared-url-visits/route.ts +++ b/src/app/api/save-shared-url-visits/route.ts @@ -5,9 +5,17 @@ import { SupabaseClient, createClient } from "@supabase/supabase-js"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; +export const runtime = "nodejs"; + const supabase: SupabaseClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, ); /** diff --git a/src/app/api/shorten-url/route.ts b/src/app/api/shorten-url/route.ts index 400a007..8f67a25 100644 --- a/src/app/api/shorten-url/route.ts +++ b/src/app/api/shorten-url/route.ts @@ -4,12 +4,13 @@ import { } from "@/lib/sentry-context"; import { isValidURL } from "@/lib/utils/is-valid-url"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; -import { createClient } from "@/utils/supabase/server"; import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { nanoid } from "nanoid"; import { enforceRateLimit, publicLimiter, strictLimiter } from "@/lib/arcjet/limiters"; +import { withAuthRoute } from "@/lib/auth/with-auth-route"; +import { createClient } from "@/utils/supabase/server"; export const runtime = "edge"; @@ -148,14 +149,13 @@ export const GET = wrapRouteHandlerWithSentry( ); export const POST = wrapRouteHandlerWithSentry( - async function POST(request: NextRequest) { + withAuthRoute(async function POST({ request, supabase, user }) { applyRequestContextToSentry({ request }); const limitResponse = await enforceRateLimit(strictLimiter, request, { tags: ["shorten-url:create"], }); if (limitResponse) return limitResponse; - const supabase = await createClient(); try { const contentType = await request.headers.get("content-type"); if (contentType !== "application/json") { @@ -180,16 +180,6 @@ export const POST = wrapRouteHandlerWithSentry( ); } - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser(); - - if (authError || !user) { - applyResponseContextToSentry(401); - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - const userLimited = await enforceRateLimit(strictLimiter, request, { userId: user.id, tags: ["shorten-url:create", "user"], @@ -282,7 +272,7 @@ export const POST = wrapRouteHandlerWithSentry( applyResponseContextToSentry(500); return NextResponse.json({ error: error.message }, { status: 500 }); } - }, + }), { method: "POST", parameterizedRoute: "/api/shorten-url", diff --git a/src/lib/auth/with-auth-route.ts b/src/lib/auth/with-auth-route.ts new file mode 100644 index 0000000..f73b9d5 --- /dev/null +++ b/src/lib/auth/with-auth-route.ts @@ -0,0 +1,28 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { createClient } from '@/utils/supabase/server' +import type { User, SupabaseClient } from '@supabase/supabase-js' + +type RouteAuthContext = { + request: NextRequest + supabase: SupabaseClient + user: User +} + +/** + * Wrap a Next.js route handler to enforce Supabase auth before executing. + * Returns 401 when the request is unauthenticated. + */ +export function withAuthRoute( + handler: (ctx: RouteAuthContext) => Promise +) { + return async (request: NextRequest): Promise => { + const supabase = await createClient() + const { data: { user }, error } = await supabase.auth.getUser() + + if (error || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + return handler({ request, supabase, user }) + } +} From 2bbc74588a5a591e8041195d5bb02fe132d855e0 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 14:23:50 -0300 Subject: [PATCH 017/102] :sparkles: feat: Add Stripe checkout success and canceled pages, update API redirects, and refine CORS configurations. --- next.config.mjs | 38 +++++++++++--- src/app/api/checkout/route.ts | 4 +- src/app/checkout/canceled/page.tsx | 22 ++++++++ src/app/checkout/success/page.tsx | 81 ++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 src/app/checkout/canceled/page.tsx create mode 100644 src/app/checkout/success/page.tsx diff --git a/next.config.mjs b/next.config.mjs index fc93a88..0604f3c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,36 +1,58 @@ import { withSentryConfig } from '@sentry/nextjs'; +const appOrigin = + process.env.CORS_ALLOWED_ORIGIN || + process.env.NEXT_PUBLIC_APP_URL || + "http://localhost:3000"; + /** @type {import('next').NextConfig} */ const nextConfig = { async headers() { return [ + // Default API CORS: lock to configured origin for authenticated/private APIs { - source: "/:path*", + source: "/api/:path*", headers: [ - { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: appOrigin }, { key: "Access-Control-Allow-Methods", value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", }, { key: "Access-Control-Allow-Headers", - value: "Content-Type, Authorization", + value: + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization", }, ], }, + // Public oEmbed endpoint remains open for external consumers { - source: "/api/:path*", + source: "/api/oembed", headers: [ - { key: "Access-Control-Allow-Credentials", value: "true" }, { key: "Access-Control-Allow-Origin", value: "*" }, { key: "Access-Control-Allow-Methods", - value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", + value: "GET,OPTIONS", }, { key: "Access-Control-Allow-Headers", - value: - "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", + value: "Content-Type", + }, + ], + }, + // Public share visit tracking for embeds + { + source: "/api/save-shared-url-visits", + headers: [ + { key: "Access-Control-Allow-Origin", value: "*" }, + { + key: "Access-Control-Allow-Methods", + value: "POST,OPTIONS", + }, + { + key: "Access-Control-Allow-Headers", + value: "Content-Type", }, ], }, diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index c51c273..4c76109 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -94,8 +94,8 @@ export async function POST(request: NextRequest) { const session = await createCheckoutSession({ customerId: customer.id, priceId, - successUrl: `${appUrl}/?checkout=success`, - cancelUrl: `${appUrl}/?checkout=canceled`, + successUrl: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appUrl}/checkout/canceled?session_id={CHECKOUT_SESSION_ID}`, metadata: { userId: user.id, plan, diff --git a/src/app/checkout/canceled/page.tsx b/src/app/checkout/canceled/page.tsx new file mode 100644 index 0000000..1ebec7e --- /dev/null +++ b/src/app/checkout/canceled/page.tsx @@ -0,0 +1,22 @@ +type Props = { + searchParams: Promise<{ session_id?: string }> +} + +export default async function CheckoutCanceledPage({ searchParams }: Props) { + const params = await searchParams + const sessionId = params?.session_id + + return ( +
+
+

Checkout canceled

+

+ Your subscription checkout was canceled. No charges were made. +

+ {sessionId ? ( +

Session: {sessionId}

+ ) : null} +
+
+ ) +} diff --git a/src/app/checkout/success/page.tsx b/src/app/checkout/success/page.tsx new file mode 100644 index 0000000..7cb56c7 --- /dev/null +++ b/src/app/checkout/success/page.tsx @@ -0,0 +1,81 @@ +import { createClient } from '@/utils/supabase/server' +import { stripe } from '@/lib/services/stripe' +import { redirect } from 'next/navigation' + +type Props = { + searchParams: Promise<{ session_id?: string }> +} + +export default async function CheckoutSuccessPage({ searchParams }: Props) { + const params = await searchParams + const sessionId = params?.session_id + + if (!sessionId) { + return ( +
+

Missing checkout session

+

We couldn't verify your payment without a session id.

+
+ ) + } + + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + redirect(`/login?redirect=/checkout/success&session_id=${encodeURIComponent(sessionId)}`) + } + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['subscription'], + }) + + const belongsToUser = session.metadata?.userId === user.id || session.customer_email === user.email + + if (!belongsToUser) { + return ( +
+

Unable to verify payment

+

This checkout session doesn't belong to your account.

+
+ ) + } + + const paymentStatus = session.payment_status + const subscriptionStatus = (session.subscription as any)?.status as string | undefined + const plan = session.metadata?.plan ?? 'your plan' + + const isPaid = paymentStatus === 'paid' || paymentStatus === 'no_payment_required' + + return ( +
+
+

Checkout {isPaid ? 'confirmed' : 'processing'}

+

+ {isPaid + ? `You’re all set! Your ${plan} subscription is active.` + : `We’re finalizing your ${plan} subscription. Current payment status: ${paymentStatus}.`} +

+ {subscriptionStatus && ( +

+ Subscription status: {subscriptionStatus} +

+ )} +
+

Session ID: {sessionId}

+

Amount: {session.amount_total ? `$${(session.amount_total / 100).toFixed(2)}` : '—'}

+
+
+
+ ) + } catch (err) { + console.error('Failed to verify checkout session', err) + return ( +
+

Couldn't verify checkout

+

Please refresh or check your email for confirmation.

+
+ ) + } +} From 36cfd59e6c8486288f113fbded1e82fa0d05db5f Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 14:56:54 -0300 Subject: [PATCH 018/102] :sparkles: feat: Implement 'over limit' tracking for user content with enhanced upgrade prompts and backend reconciliation. --- src/actions/animations/create-animation.ts | 8 +++- src/actions/snippets/create-snippet.ts | 20 +++++---- src/app/api/webhooks/stripe/route.ts | 17 ++++++++ src/components/usage-stats-widget/index.tsx | 9 ++++ .../animations/hooks/use-animation-limits.ts | 7 ++-- .../hooks/use-animation-persistence.ts | 15 ++++++- src/features/code-editor/editor.tsx | 21 +++++++--- src/features/user/queries.ts | 1 - src/lib/services/usage-limits.ts | 41 +++++++++++++++---- 9 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index 0856d0d..684a933 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -45,10 +45,14 @@ export async function createAnimation( if (!animationLimitCheck.canSave) { const plan = animationLimitCheck.plan + const current = animationLimitCheck.current ?? 0 + const max = animationLimitCheck.max ?? 0 + const overLimit = animationLimitCheck.over_limit ?? Math.max(current - max, 0) + if (plan === 'free') { - return error('Free plan doesn\'t allow saving animations. Upgrade to Started to save up to 50 animations!') + return error(`You have ${current} animations but the Free plan allows ${max}. Delete items or upgrade to save again. Over limit: ${overLimit}.`) } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 animations). Upgrade to Pro for unlimited animations!') + return error(`You\'ve reached your Started limit (${current}/${max}). Upgrade to Pro for unlimited animations!`) } return error('Animation limit reached. Please upgrade your plan.') } diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index 58d512e..4617694 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -43,15 +43,19 @@ export async function createSnippet( return error('Failed to verify save limit. Please try again.') } - if (!limitCheck.canSave) { - const plan = limitCheck.plan - if (plan === 'free') { - return error('Free plan doesn\'t allow saving snippets. Upgrade to Started to save up to 50 snippets!') - } else if (plan === 'started') { - return error('You\'ve reached your limit (50/50 snippets). Upgrade to Pro for unlimited snippets!') - } - return error('Snippet limit reached. Please upgrade your plan.') + if (!limitCheck.canSave) { + const plan = limitCheck.plan + const current = limitCheck.current ?? 0 + const max = limitCheck.max ?? 0 + const overLimit = limitCheck.over_limit ?? Math.max(current - max, 0) + + if (plan === 'free') { + return error(`You have ${current} snippets but the Free plan allows ${max}. Delete items or upgrade to save again. Over limit: ${overLimit}.`) + } else if (plan === 'started') { + return error(`You\'ve reached your Started limit (${current}/${max}). Upgrade to Pro for unlimited snippets!`) } + return error('Snippet limit reached. Please upgrade your plan.') + } const data = await insertSnippet({ id, diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 8a0d7a4..1baaf94 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -139,6 +139,15 @@ async function handleSubscriptionChange( throw error; } + // Reconcile over-limit flags when plan changes (upgrades or downgrades) + const { error: reconcileError } = await (supabase as any).rpc('reconcile_over_limit_content', { + p_user_id: userId, + }); + + if (reconcileError) { + console.error('Failed to reconcile over-limit content', reconcileError); + } + console.log(`Updated user ${userId} to plan ${planId}`); return userId; } @@ -167,6 +176,14 @@ async function handleSubscriptionDeleted( throw error; } + const { error: reconcileError } = await (supabase as any).rpc('reconcile_over_limit_content', { + p_user_id: userId, + }); + + if (reconcileError) { + console.error('Failed to reconcile over-limit content after cancellation', reconcileError); + } + console.log(`Downgraded user ${userId} to free plan`); return userId; } diff --git a/src/components/usage-stats-widget/index.tsx b/src/components/usage-stats-widget/index.tsx index 68498ae..a76650a 100644 --- a/src/components/usage-stats-widget/index.tsx +++ b/src/components/usage-stats-widget/index.tsx @@ -28,10 +28,12 @@ const UsageRow = ({ label, current, max, + overLimit, }: { label: string; current: number; max: number | null; + overLimit?: number; }) => { const ratio = getRatio(current, max); const maxLabel = max === null ? "Unlimited" : `${max}`; @@ -51,6 +53,11 @@ const UsageRow = ({ style={{ width: progressWidth }} />
+ {overLimit && overLimit > 0 ? ( +

+ {overLimit} item{overLimit > 1 ? "s" : ""} over plan limit. Delete or upgrade to resume saving. +

+ ) : null}
); }; @@ -94,11 +101,13 @@ export function UsageStatsWidget({ label="Snippets" current={usage.snippets.current} max={usage.snippets.max} + overLimit={usage.snippets.overLimit} /> = animationLimit.max; + (animationLimit?.max !== null && + typeof animationLimit?.max !== "undefined" && + animationLimit.current >= animationLimit.max) || + (animationLimit?.overLimit ?? 0) > 0; const plan = usage?.plan ?? "free"; const planConfig = getPlanConfig(plan); diff --git a/src/features/animations/hooks/use-animation-persistence.ts b/src/features/animations/hooks/use-animation-persistence.ts index 8bfb625..58e5b49 100644 --- a/src/features/animations/hooks/use-animation-persistence.ts +++ b/src/features/animations/hooks/use-animation-persistence.ts @@ -12,6 +12,8 @@ import { USAGE_QUERY_KEY } from "@/features/user/queries"; import { AnimationSlide, AnimationSettings } from "@/types/animation"; import { User } from "@supabase/supabase-js"; import { calculateTotalDuration } from "@/features/animation"; +import { toast } from "sonner"; +import { useAnimationLimits } from "./use-animation-limits"; type UseAnimationPersistenceProps = { user: User | null; @@ -39,11 +41,18 @@ export function useAnimationPersistence({ const getTimestamp = () => new Date().getTime(); + const { setIsUpgradeOpen } = useAnimationLimits({ user, slidesCount: slides.length }); + // --- Mutations --- const { mutate: handleCreateAnimation, isPending: isCreating } = useMutation({ mutationFn: createAnimation, - onSuccess: (result) => { + onSuccess: (result: any) => { + if (result?.error) { + toast.error(result.error); + setIsUpgradeOpen(true); + return; + } if (result?.data) { updateActiveAnimation(result.data); trackAnimationEvent("create_animation", user, { @@ -60,6 +69,10 @@ export function useAnimationPersistence({ queryClient.invalidateQueries({ queryKey: [USAGE_QUERY_KEY, user_id] }); } }, + onError: (err: any) => { + toast.error(err?.message ?? "Failed to save animation."); + setIsUpgradeOpen(true); + }, }); const { mutate: handleUpdateAnimation } = useMutation({ diff --git a/src/features/code-editor/editor.tsx b/src/features/code-editor/editor.tsx index 3b25434..4f3e290 100644 --- a/src/features/code-editor/editor.tsx +++ b/src/features/code-editor/editor.tsx @@ -203,9 +203,10 @@ export const Editor = forwardRef( const { data: usage, isLoading: isUsageLoading } = useUserUsage(user_id); const snippetLimit = usage?.snippets; const snippetLimitReached = - snippetLimit?.max !== null && - typeof snippetLimit?.max !== "undefined" && - snippetLimit.current >= snippetLimit.max; + (snippetLimit?.max !== null && + typeof snippetLimit?.max !== "undefined" && + snippetLimit.current >= snippetLimit.max) || + (snippetLimit?.overLimit ?? 0) > 0; const [isUpgradeOpen, setIsUpgradeOpen] = useState(false); useHotkeys(focusEditor[0].hotKey, () => { @@ -251,12 +252,22 @@ export const Editor = forwardRef( if (context) { queryClient.setQueryData(queryKey, context.previousSnippets); } + if (err instanceof Error && err.message.toLowerCase().includes("upgrade")) { + setIsUpgradeOpen(true); + } }, onSettled: (data, error, variables, context) => { - if (data) { + const actionResult: any = data as any; + + if (actionResult?.error) { + toast.error(actionResult.error); + setIsUpgradeOpen(true); + } + + if (actionResult && !actionResult.error && actionResult.data) { analytics.track("create_snippet", { - snippet_id: data.data.id, + snippet_id: actionResult.data.id, language: variables.language, }); } diff --git a/src/features/user/queries.ts b/src/features/user/queries.ts index 4d6486f..38e18b6 100644 --- a/src/features/user/queries.ts +++ b/src/features/user/queries.ts @@ -43,4 +43,3 @@ export const useUserPlan = (userId?: string) => { enabled: Boolean(userId), }); }; - diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index ba52dfe..bc84549 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -18,6 +18,7 @@ export type UsageLimitCheck = { current: number; max: number | null; plan: PlanId; + overLimit?: number; error?: string; }; @@ -26,10 +27,12 @@ export type UsageSummary = { snippets: { current: number; max: number | null; + overLimit?: number; }; animations: { current: number; max: number | null; + overLimit?: number; }; folders: { current: number; @@ -142,6 +145,13 @@ const normalizeLimitPayload = ( ? defaultMax : Number(payload.max); + const overLimit = + typeof payload?.over_limit !== "undefined" + ? Number(payload.over_limit) + : typeof payload?.overLimit !== "undefined" + ? Number(payload.overLimit) + : 0; + return { canSave: Boolean( payload?.can_save ?? @@ -155,6 +165,7 @@ const normalizeLimitPayload = ( current: Number(payload?.current ?? 0), max, plan, + overLimit, error: payload?.error as string | undefined, }; }; @@ -249,7 +260,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const { data: usage, error: usageError } = await supabase .from("usage_limits") .select( - "snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at" + "snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at, over_limit_snippets, over_limit_animations" ) .eq("user_id", userId) .maybeSingle(); @@ -259,6 +270,8 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< throw new Error("Unable to load usage"); } + const usageRow: any = usage ?? {}; + // Reconcile against actual table counts to avoid stale counters in usage_limits/profiles const [ { count: actualSnippetCount, error: snippetCountError }, @@ -290,11 +303,13 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< const plan = (profile.plan as PlanId | null) ?? "free"; const planConfig = getPlanConfig(plan); - const snippetCountFromLimits = usage?.snippet_count ?? 0; - const animationCountFromLimits = usage?.animation_count ?? 0; - const folderCountFromLimits = usage?.folder_count ?? 0; - const videoExportCountFromLimits = usage?.video_export_count ?? 0; - const publicShareCountFromLimits = usage?.public_share_count ?? 0; + const snippetCountFromLimits = usageRow.snippet_count ?? 0; + const animationCountFromLimits = usageRow.animation_count ?? 0; + const overLimitSnippets = usageRow.over_limit_snippets ?? null; + const overLimitAnimations = usageRow.over_limit_animations ?? null; + const folderCountFromLimits = usageRow.folder_count ?? 0; + const videoExportCountFromLimits = usageRow.video_export_count ?? 0; + const publicShareCountFromLimits = usageRow.public_share_count ?? 0; const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); const folderCount = Math.max( @@ -302,16 +317,28 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< (actualFolderCount ?? 0) + (actualAnimationFolderCount ?? 0) ); const publicShareCount = publicShareCountFromLimits; + const computedSnippetOverLimit = + overLimitSnippets ?? + (planConfig.maxSnippets === Infinity + ? 0 + : Math.max(snippetCount - (planConfig.maxSnippets ?? 0), 0)); + const computedAnimationOverLimit = + overLimitAnimations ?? + (planConfig.maxAnimations === Infinity + ? 0 + : Math.max(animationCount - (planConfig.maxAnimations ?? 0), 0)); return { plan, snippets: { current: snippetCount, max: planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets, + overLimit: computedSnippetOverLimit ?? 0, }, animations: { current: animationCount, max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, + overLimit: computedAnimationOverLimit ?? 0, }, folders: { current: folderCount, @@ -325,7 +352,7 @@ export const getUserUsage = async (supabase: Supabase, userId: string): Promise< current: publicShareCount, max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, }, - lastResetAt: usage?.last_reset_at ?? undefined, + lastResetAt: usageRow.last_reset_at ?? undefined, }; }; From 4cd460d9527ad860de0d82f950121f05508df362 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 15:16:41 -0300 Subject: [PATCH 019/102] :sparkles: feat: Introduce `@t3-oss/env-nextjs` for environment variable validation and update friendly error component UI. --- .env.example | 75 +++++---- README.md | 17 ++ next.config.mjs | 1 + package.json | 5 + pnpm-lock.yaml | 49 ++++++ scripts/check-env.mjs | 9 + src/components/errors/friendly-error.tsx | 200 ++++++++++++++++++++--- src/env.mjs | 105 ++++++++++++ 8 files changed, 406 insertions(+), 55 deletions(-) create mode 100644 scripts/check-env.mjs create mode 100644 src/env.mjs diff --git a/.env.example b/.env.example index 9d4b4ab..d229429 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,55 @@ NODE_ENV=development -# GENERAL -NEXT_PUBLIC_VERCEL_ENV=development -NEXT_PUBLIC_VERCEL_URL=localhost:3000 - -NEXT_PUBLIC_SENTRY_TUNNEL=/monitoring -SENTRY_DSN= - -#SUPABASE +# Required: core platform NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= - -#CANNY -CANNY_API_KEY= - -NEXT_PUBLIC_HOTJAR_SITE_ID= - -#LIVEBLOCKS +SUPABASE_SERVICE_ROLE_KEY= LIVEBLOCKS_SECRET_KEY= -LIVEBLOCKS_PUCLIC_API_KEY= - -KEEPALIVE_ENDPOINT= -KEEPALIVE_API_KEY= - -#ANALYTICS -NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com -NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL=false -POSTHOG_API_KEY= - -#SECURITY -ARCJET_KEY= -#STRIPE +# Required: app + billing +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_VERCEL_ENV=development +NEXT_PUBLIC_VERCEL_URL=localhost:3000 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... - -# Stripe Price IDs (from dashboard) NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID=price_... NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID=price_... NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID=price_... NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID=price_... -# App URL for redirects -NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Optional: observability +NEXT_PUBLIC_SENTRY_TUNNEL=/monitoring +SENTRY_DSN= +SENTRY_ENVIRONMENT= +SENTRY_TRACES_SAMPLE_RATE= +SENTRY_PROFILES_SAMPLE_RATE= +SENTRY_TRACE_PROPAGATION_TARGETS= +SENTRY_RELEASE= +SENTRY_REPLAYS_SESSION_SAMPLE_RATE= +SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE= + +# Optional: analytics + marketing +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com +NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL=false +POSTHOG_API_KEY= +NEXT_PUBLIC_HOTJAR_SITE_ID= +NEXT_PUBLIC_SITE_URL= +CANNY_API_KEY= + +# Optional: feature flags + experiments +NEXT_PUBLIC_EXPORT_EXPERIMENT=control +NEXT_PUBLIC_TRANSITION_EXPERIMENT=control + +# Optional: collaboration / security +ARCJET_KEY= +NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY= +CORS_ALLOWED_ORIGIN= +NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA= +VERCEL_GIT_COMMIT_SHA= + +# Optional: maintenance +KEEPALIVE_ENDPOINT= +KEEPALIVE_API_KEY= +SKIP_ENV_VALIDATION=false diff --git a/README.md b/README.md index 3283ad0..45410b8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,23 @@ Save your code snippets for future reference directly in the tool. Group your sa - Supabase - TailwindCSS +## Environment Variables + +We validate configuration with `@t3-oss/env-nextjs`; `pnpm env:check` runs automatically before `pnpm dev`, `pnpm build`, and `pnpm start`. Copy `.env.example` to `.env.local` and fill in the required values. + +**Required** +- Supabase access: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` +- Liveblocks auth: `LIVEBLOCKS_SECRET_KEY` +- Stripe billing: `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID` +- App domains: `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_VERCEL_ENV`, `NEXT_PUBLIC_VERCEL_URL` + +**Optional** +- Observability: `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_TRACES_SAMPLE_RATE`, `SENTRY_PROFILES_SAMPLE_RATE`, `SENTRY_TRACE_PROPAGATION_TARGETS`, `SENTRY_RELEASE`, `SENTRY_REPLAYS_SESSION_SAMPLE_RATE`, `SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE`, `NEXT_PUBLIC_SENTRY_TUNNEL` +- Analytics/marketing: `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST`, `NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL`, `POSTHOG_API_KEY`, `NEXT_PUBLIC_HOTJAR_SITE_ID`, `NEXT_PUBLIC_SITE_URL`, `CANNY_API_KEY` +- Feature flags & experiments: `NEXT_PUBLIC_EXPORT_EXPERIMENT`, `NEXT_PUBLIC_TRANSITION_EXPERIMENT` +- Security & rate limiting: `ARCJET_KEY`, `CORS_ALLOWED_ORIGIN`, `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY`, `NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA`, `VERCEL_GIT_COMMIT_SHA` +- Maintenance helpers: `KEEPALIVE_ENDPOINT`, `KEEPALIVE_API_KEY`, `SKIP_ENV_VALIDATION` + ## Roadmap For any additional feature request 👉: [Check it out](https://jollycode.canny.io/feature-requests) diff --git a/next.config.mjs b/next.config.mjs index 0604f3c..3a80d00 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,4 @@ +import "./src/env.mjs"; import { withSentryConfig } from '@sentry/nextjs'; const appOrigin = diff --git a/package.json b/package.json index 5025776..04b39e8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "private": true, "author": "Dana Rocha", "scripts": { + "env:check": "node --enable-source-maps scripts/check-env.mjs", + "predev": "pnpm env:check", + "prestart": "pnpm env:check", + "prebuild": "pnpm env:check", "dev": "next dev", "build": "next build", "start": "next start", @@ -43,6 +47,7 @@ "@stripe/stripe-js": "^8.5.3", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.84.0", + "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-query": "^5.90.10", "@vercel/analytics": "^1.1.0", "@vercel/og": "^0.5.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbf75c5..cf02812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@supabase/supabase-js': specifier: ^2.84.0 version: 2.84.0 + '@t3-oss/env-nextjs': + specifier: ^0.13.8 + version: 0.13.8(typescript@5.9.3)(zod@4.1.13) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.1) @@ -1881,6 +1884,40 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3-oss/env-core@0.13.8': + resolution: {integrity: sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.13.8': + resolution: {integrity: sha512-QmTLnsdQJ8BiQad2W2nvV6oUpH4oMZMqnFEjhVpzU0h3sI9hn8zb8crjWJ1Amq453mGZs6A4v4ihIeBFDOrLeQ==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tailwindcss/node@4.1.17': resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} @@ -6501,6 +6538,18 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3-oss/env-core@0.13.8(typescript@5.9.3)(zod@4.1.13)': + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.13 + + '@t3-oss/env-nextjs@0.13.8(typescript@5.9.3)(zod@4.1.13)': + dependencies: + '@t3-oss/env-core': 0.13.8(typescript@5.9.3)(zod@4.1.13) + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.13 + '@tailwindcss/node@4.1.17': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/scripts/check-env.mjs b/scripts/check-env.mjs new file mode 100644 index 0000000..5766fca --- /dev/null +++ b/scripts/check-env.mjs @@ -0,0 +1,9 @@ +import nextEnv from "@next/env"; + +// Load .env.local and friends before running validation. +const { loadEnvConfig } = nextEnv; +loadEnvConfig(process.cwd()); + +await import("../src/env.mjs"); + +console.log("✅ Environment variables look good."); diff --git a/src/components/errors/friendly-error.tsx b/src/components/errors/friendly-error.tsx index 516e4e9..2477927 100644 --- a/src/components/errors/friendly-error.tsx +++ b/src/components/errors/friendly-error.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; import { cn } from "@/lib/utils"; type Props = { @@ -22,27 +21,186 @@ export function FriendlyError({ className, }: Props) { return ( -
- -
-
- - ! - - Unexpected error -
-

{title}

-

{description}

-
- {reset ? ( - - ) : null} - -
+
+
+
+
- +
+

{title}

+

{description}

+
+
+ {reset ? ( + + ) : null} + +
+
); } + +const NotFoundIllustration = () => ( + +); diff --git a/src/env.mjs b/src/env.mjs new file mode 100644 index 0000000..853a727 --- /dev/null +++ b/src/env.mjs @@ -0,0 +1,105 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1, "SUPABASE_SERVICE_ROLE_KEY is required"), + STRIPE_SECRET_KEY: z.string().min(1, "STRIPE_SECRET_KEY is required"), + STRIPE_WEBHOOK_SECRET: z.string().min(1, "STRIPE_WEBHOOK_SECRET is required"), + LIVEBLOCKS_SECRET_KEY: z.string().min(1, "LIVEBLOCKS_SECRET_KEY is required"), + ARCJET_KEY: z.string().optional(), + CANNY_API_KEY: z.string().optional(), + POSTHOG_API_KEY: z.string().optional(), + KEEPALIVE_ENDPOINT: z.string().optional(), + KEEPALIVE_API_KEY: z.string().optional(), + CORS_ALLOWED_ORIGIN: z.string().url().optional(), + SENTRY_DSN: z.string().url().optional(), + SENTRY_ENVIRONMENT: z.string().optional(), + SENTRY_TRACES_SAMPLE_RATE: z.string().optional(), + SENTRY_PROFILES_SAMPLE_RATE: z.string().optional(), + SENTRY_TRACE_PROPAGATION_TARGETS: z.string().optional(), + SENTRY_RELEASE: z.string().optional(), + SENTRY_REPLAYS_SESSION_SAMPLE_RATE: z.string().optional(), + SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: z.string().optional(), + VERCEL_GIT_COMMIT_SHA: z.string().optional(), + }, + client: { + NEXT_PUBLIC_SUPABASE_URL: z.string().url(), + NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1, "NEXT_PUBLIC_SUPABASE_ANON_KEY is required"), + NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"), + NEXT_PUBLIC_VERCEL_ENV: z.enum(["development", "preview", "production"]).default("development"), + NEXT_PUBLIC_VERCEL_URL: z.string().default("localhost:3000"), + NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: z.string().optional(), + NEXT_PUBLIC_SENTRY_TUNNEL: z.string().default("/monitoring"), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z + .string() + .min(1, "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required"), + NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID: z + .string() + .min(1, "NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID is required"), + NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID: z + .string() + .min(1, "NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID is required"), + NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: z + .string() + .min(1, "NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID is required"), + NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: z + .string() + .min(1, "NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID is required"), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().url().default("https://app.posthog.com"), + NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL: z.enum(["true", "false"]).default("false"), + NEXT_PUBLIC_SITE_URL: z.string().url().optional(), + NEXT_PUBLIC_HOTJAR_SITE_ID: z.string().optional(), + NEXT_PUBLIC_EXPORT_EXPERIMENT: z.string().default("control"), + NEXT_PUBLIC_TRANSITION_EXPERIMENT: z.string().default("control"), + NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY: z.string().optional(), + }, + runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + LIVEBLOCKS_SECRET_KEY: process.env.LIVEBLOCKS_SECRET_KEY, + ARCJET_KEY: process.env.ARCJET_KEY, + CANNY_API_KEY: process.env.CANNY_API_KEY, + POSTHOG_API_KEY: process.env.POSTHOG_API_KEY, + KEEPALIVE_ENDPOINT: process.env.KEEPALIVE_ENDPOINT, + KEEPALIVE_API_KEY: process.env.KEEPALIVE_API_KEY, + CORS_ALLOWED_ORIGIN: process.env.CORS_ALLOWED_ORIGIN, + SENTRY_DSN: process.env.SENTRY_DSN, + SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT, + SENTRY_TRACES_SAMPLE_RATE: process.env.SENTRY_TRACES_SAMPLE_RATE, + SENTRY_PROFILES_SAMPLE_RATE: process.env.SENTRY_PROFILES_SAMPLE_RATE, + SENTRY_TRACE_PROPAGATION_TARGETS: process.env.SENTRY_TRACE_PROPAGATION_TARGETS, + SENTRY_RELEASE: process.env.SENTRY_RELEASE, + SENTRY_REPLAYS_SESSION_SAMPLE_RATE: process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE, + SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE, + VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA, + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, + NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + NEXT_PUBLIC_VERCEL_ENV: process.env.NEXT_PUBLIC_VERCEL_ENV, + NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL, + NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, + NEXT_PUBLIC_SENTRY_TUNNEL: process.env.NEXT_PUBLIC_SENTRY_TUNNEL, + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID: + process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID, + NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID: + process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID, + NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID, + NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID: process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL: process.env.NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL, + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, + NEXT_PUBLIC_HOTJAR_SITE_ID: process.env.NEXT_PUBLIC_HOTJAR_SITE_ID, + NEXT_PUBLIC_EXPORT_EXPERIMENT: process.env.NEXT_PUBLIC_EXPORT_EXPERIMENT, + NEXT_PUBLIC_TRANSITION_EXPERIMENT: process.env.NEXT_PUBLIC_TRANSITION_EXPERIMENT, + NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY, + }, + skipValidation: !!process.env.SKIP_ENV_VALIDATION, + emptyStringAsUndefined: true, +}); From e970d82474340d4d3a49de74cf53038ee0e17081 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 15:27:26 -0300 Subject: [PATCH 020/102] :sparkles: feat: add CSRF protection to middleware and remove environment variable documentation from README. --- README.md | 13 --------- src/proxy.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 45410b8..904fdf7 100644 --- a/README.md +++ b/README.md @@ -43,19 +43,6 @@ Save your code snippets for future reference directly in the tool. Group your sa We validate configuration with `@t3-oss/env-nextjs`; `pnpm env:check` runs automatically before `pnpm dev`, `pnpm build`, and `pnpm start`. Copy `.env.example` to `.env.local` and fill in the required values. -**Required** -- Supabase access: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` -- Liveblocks auth: `LIVEBLOCKS_SECRET_KEY` -- Stripe billing: `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID`, `NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID` -- App domains: `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_VERCEL_ENV`, `NEXT_PUBLIC_VERCEL_URL` - -**Optional** -- Observability: `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, `SENTRY_TRACES_SAMPLE_RATE`, `SENTRY_PROFILES_SAMPLE_RATE`, `SENTRY_TRACE_PROPAGATION_TARGETS`, `SENTRY_RELEASE`, `SENTRY_REPLAYS_SESSION_SAMPLE_RATE`, `SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE`, `NEXT_PUBLIC_SENTRY_TUNNEL` -- Analytics/marketing: `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST`, `NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL`, `POSTHOG_API_KEY`, `NEXT_PUBLIC_HOTJAR_SITE_ID`, `NEXT_PUBLIC_SITE_URL`, `CANNY_API_KEY` -- Feature flags & experiments: `NEXT_PUBLIC_EXPORT_EXPERIMENT`, `NEXT_PUBLIC_TRANSITION_EXPERIMENT` -- Security & rate limiting: `ARCJET_KEY`, `CORS_ALLOWED_ORIGIN`, `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY`, `NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA`, `VERCEL_GIT_COMMIT_SHA` -- Maintenance helpers: `KEEPALIVE_ENDPOINT`, `KEEPALIVE_API_KEY`, `SKIP_ENV_VALIDATION` - ## Roadmap For any additional feature request 👉: [Check it out](https://jollycode.canny.io/feature-requests) diff --git a/src/proxy.ts b/src/proxy.ts index 5e0d20a..1a8a869 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,12 +1,71 @@ -import { updateSession } from '@/utils/supabase/middleware' -import { wrapMiddlewareWithSentry } from '@sentry/nextjs' -import { type NextRequest } from 'next/server' - -export const proxy = wrapMiddlewareWithSentry(async function proxy( - request: NextRequest, -) { - return await updateSession(request) -}) +import { updateSession } from "@/utils/supabase/middleware"; +import { wrapMiddlewareWithSentry } from "@sentry/nextjs"; +import { NextResponse, type NextRequest } from "next/server"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); +const CSRF_EXEMPT_PATHS = ["/api/webhooks/stripe"]; + +const isCsrfExempt = (pathname: string) => + CSRF_EXEMPT_PATHS.some((path) => pathname.startsWith(path)); + +const getRequestOrigin = (request: NextRequest) => { + const originHeader = request.headers.get("origin"); + if (originHeader) return originHeader; + + const forwardedHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https"; + + if (!forwardedHost) return null; + return `${forwardedProto}://${forwardedHost}`; +}; + +const enforceCsrf = (request: NextRequest) => { + const { pathname } = request.nextUrl; + + if (!pathname.startsWith("/api/") || SAFE_METHODS.has(request.method)) { + return null; + } + + if (isCsrfExempt(pathname)) { + return null; + } + + const requestOrigin = getRequestOrigin(request); + const refererOrigin = (() => { + const referer = request.headers.get("referer"); + if (!referer) return null; + try { + return new URL(referer).origin; + } catch { + return null; + } + })(); + + const allowedOrigins = [ + process.env.CORS_ALLOWED_ORIGIN ?? "", + process.env.NEXT_PUBLIC_APP_URL ?? "", + requestOrigin ?? "", + ].filter(Boolean); + + const candidate = requestOrigin ?? refererOrigin; + + if (!candidate) { + return null; + } + + if (!allowedOrigins.includes(candidate)) { + return NextResponse.json({ error: "CSRF validation failed" }, { status: 403 }); + } + + return null; +}; + +export const proxy = wrapMiddlewareWithSentry(async function proxy(request: NextRequest) { + const csrfResult = enforceCsrf(request); + if (csrfResult) return csrfResult; + + return await updateSession(request); +}); export const config = { matcher: [ From b527e37bd93cc3baf5e0383eee521b2a0c44e0cd Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 16:21:14 -0300 Subject: [PATCH 021/102] :sparkles: feat: Implement user usage RPC with fallback and Sentry error reporting --- scripts/verify-rpc-function.sql | 21 + src/app/api/liveblocks-auth/route.ts | 1 + src/features/user/queries.ts | 45 +- src/lib/react-query/query-client.ts | 51 ++ src/lib/services/usage-limits.ts | 488 ++++++++++++++---- src/types/database.ts | 6 + .../20260524103000_get_user_usage_rpc.sql | 89 ++++ ...60524104500_update_get_user_usage_auth.sql | 95 ++++ .../20260524113000_get_user_usage_v2.sql | 120 +++++ 9 files changed, 809 insertions(+), 107 deletions(-) create mode 100644 scripts/verify-rpc-function.sql create mode 100644 supabase/migrations/20260524103000_get_user_usage_rpc.sql create mode 100644 supabase/migrations/20260524104500_update_get_user_usage_auth.sql create mode 100644 supabase/migrations/20260524113000_get_user_usage_v2.sql diff --git a/scripts/verify-rpc-function.sql b/scripts/verify-rpc-function.sql new file mode 100644 index 0000000..09403e3 --- /dev/null +++ b/scripts/verify-rpc-function.sql @@ -0,0 +1,21 @@ +-- Verify that get_user_usage_v2 function exists +-- Run this in your Supabase SQL Editor to check if the function was created + +SELECT + p.proname as function_name, + pg_get_function_arguments(p.oid) as arguments, + pg_get_function_result(p.oid) as return_type, + p.proacl as permissions +FROM pg_proc p +JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE n.nspname = 'public' + AND p.proname = 'get_user_usage_v2'; + +-- Also check grants +SELECT + grantee, + privilege_type +FROM information_schema.routine_privileges +WHERE routine_schema = 'public' + AND routine_name = 'get_user_usage_v2'; + diff --git a/src/app/api/liveblocks-auth/route.ts b/src/app/api/liveblocks-auth/route.ts index daa1d6d..9d1cb88 100644 --- a/src/app/api/liveblocks-auth/route.ts +++ b/src/app/api/liveblocks-auth/route.ts @@ -56,6 +56,7 @@ export const POST = wrapRouteHandlerWithSentry( } catch (e) { console.error("Error authorizing session:", e); applyResponseContextToSentry(500); + return new NextResponse("Internal Server Error", { status: 500 }); } }, { diff --git a/src/features/user/queries.ts b/src/features/user/queries.ts index 38e18b6..831c871 100644 --- a/src/features/user/queries.ts +++ b/src/features/user/queries.ts @@ -1,10 +1,12 @@ import { useQuery } from "@tanstack/react-query"; +import * as Sentry from "@sentry/nextjs"; import { createClient } from "@/utils/supabase/client"; import { getUserUsage, type UsageSummary } from "@/lib/services/usage-limits"; import type { PlanId } from "@/lib/config/plans"; export const USAGE_QUERY_KEY = "user-usage"; +const USER_USAGE_STALE_TIME_MS = 5 * 60 * 1000; export const fetchUserUsage = async (userId?: string): Promise => { const supabase = createClient(); @@ -27,8 +29,28 @@ export const useUserUsage = (userId?: string) => { return useQuery({ queryKey: [USAGE_QUERY_KEY, userId], queryFn: () => fetchUserUsage(userId), - staleTime: 30_000, + staleTime: USER_USAGE_STALE_TIME_MS, enabled: Boolean(userId), + onError: (error) => { + // Additional error handler specific to user usage queries + // This ensures RPC errors are caught even if they're handled gracefully + if (error instanceof Error && typeof window !== "undefined" && Sentry.getCurrentHub) { + Sentry.withScope((scope) => { + scope.setTag("query_type", "user_usage"); + scope.setTag("user_id", userId || "unknown"); + scope.setContext("user_usage_error", { + user_id: userId, + error_message: error.message, + error_name: error.name, + }); + Sentry.captureException(error); + Sentry.flush(2000).catch((flushError) => { + console.warn("[useUserUsage] Sentry flush failed:", flushError); + }); + }); + } + console.error("[useUserUsage] Query error:", error); + }, }); }; @@ -39,7 +61,26 @@ export const useUserPlan = (userId?: string) => { const usage = await fetchUserUsage(userId); return usage.plan; }, - staleTime: 30_000, + staleTime: USER_USAGE_STALE_TIME_MS, enabled: Boolean(userId), + onError: (error) => { + // Additional error handler specific to user plan queries + if (error instanceof Error && typeof window !== "undefined" && Sentry.getCurrentHub) { + Sentry.withScope((scope) => { + scope.setTag("query_type", "user_plan"); + scope.setTag("user_id", userId || "unknown"); + scope.setContext("user_plan_error", { + user_id: userId, + error_message: error.message, + error_name: error.name, + }); + Sentry.captureException(error); + Sentry.flush(2000).catch((flushError) => { + console.warn("[useUserPlan] Sentry flush failed:", flushError); + }); + }); + } + console.error("[useUserPlan] Query error:", error); + }, }); }; diff --git a/src/lib/react-query/query-client.ts b/src/lib/react-query/query-client.ts index 720b482..db35641 100644 --- a/src/lib/react-query/query-client.ts +++ b/src/lib/react-query/query-client.ts @@ -3,12 +3,63 @@ import { QueryClient, defaultShouldDehydrateQuery, } from '@tanstack/react-query' +import * as Sentry from '@sentry/nextjs' function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, + onError: (error, query) => { + // Report React Query errors to Sentry + // This catches errors that might be swallowed by React Query + if (error instanceof Error) { + const isClient = typeof window !== 'undefined'; + + if (isClient && Sentry.getCurrentHub) { + // Client-side: use withScope and flush + Sentry.withScope((scope) => { + scope.setLevel('error'); + scope.setTag('error_source', 'react_query'); + scope.setTag('query_key', JSON.stringify(query.queryKey)); + scope.setContext('react_query_error', { + query_key: query.queryKey, + query_hash: query.queryHash, + error_message: error.message, + error_name: error.name, + }); + scope.setExtra('query_key', query.queryKey); + scope.setExtra('query_hash', query.queryHash); + + Sentry.captureException(error); + + // Flush to ensure event is sent immediately on client-side + Sentry.flush(2000).catch((flushError) => { + console.warn('[React Query] Sentry flush failed:', flushError); + }); + }); + } else { + // Server-side: simpler capture + Sentry.captureException(error, { + level: 'error', + tags: { + error_source: 'react_query', + query_key: JSON.stringify(query.queryKey), + }, + extra: { + query_key: query.queryKey, + query_hash: query.queryHash, + }, + }); + } + } + + // Also log to console for debugging + console.error('[React Query] Query error:', { + query_key: query.queryKey, + error: error, + }); + }, }, dehydrate: { // include pending queries in dehydration diff --git a/src/lib/services/usage-limits.ts b/src/lib/services/usage-limits.ts index bc84549..1aa0e9c 100644 --- a/src/lib/services/usage-limits.ts +++ b/src/lib/services/usage-limits.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from "@supabase/supabase-js"; +import * as Sentry from "@sentry/nextjs"; import type { Database } from "@/types/database"; import { getPlanConfig, type PlanId } from "@/lib/config/plans"; @@ -49,6 +50,188 @@ export type UsageSummary = { lastResetAt?: string; }; +type UsageRpcPayload = { + plan: PlanId | null; + snippet_count?: number | null; + animation_count?: number | null; + folder_count?: number | null; + video_export_count?: number | null; + public_share_count?: number | null; + last_reset_at?: string | null; + over_limit_snippets?: number | null; + over_limit_animations?: number | null; +}; + +const USER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000; +const USER_USAGE_RPC_BACKOFF_MS = 5 * 60 * 1000; +const USER_USAGE_RPC_NAME = "get_user_usage_v2"; +const USER_USAGE_RPC_FALLBACK_NAME = "get_user_usage"; // Fallback to old function if v2 not available + +const userUsageCache = new Map< + string, + { + expiresAt: number; + value: Promise; + } +>(); + +let userUsageRpcBackoffUntil = 0; + +const normalizeCount = (value: unknown): number => Number(value ?? 0); +const normalizeOverLimit = (value: unknown): number | null => + typeof value === "undefined" || value === null ? null : Number(value); + +const buildUsageSummary = ({ + plan, + planConfig, + snippetCount, + animationCount, + folderCount, + videoExportCount, + publicShareCount, + overLimitSnippets, + overLimitAnimations, + lastResetAt, +}: { + plan: PlanId; + planConfig: ReturnType; + snippetCount: number; + animationCount: number; + folderCount: number; + videoExportCount: number; + publicShareCount: number; + overLimitSnippets: number; + overLimitAnimations: number; + lastResetAt?: string | null; +}): UsageSummary => { + return { + plan, + snippets: { + current: snippetCount, + max: planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets, + overLimit: overLimitSnippets ?? 0, + }, + animations: { + current: animationCount, + max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, + overLimit: overLimitAnimations ?? 0, + }, + folders: { + current: folderCount, + max: planConfig.maxSnippetsFolder === Infinity ? null : planConfig.maxSnippetsFolder, + }, + videoExports: { + current: videoExportCount, + max: planConfig.maxVideoExportCount === Infinity ? null : planConfig.maxVideoExportCount, + }, + publicShares: { + current: publicShareCount, + max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, + }, + lastResetAt: lastResetAt ?? undefined, + }; +}; + +const getUserUsageFallback = async ( + supabase: Supabase, + userId: string +): Promise => { + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("plan") + .eq("id", userId) + .single(); + + if (profileError || !profile) { + console.error("Failed to load profile usage (fallback)", profileError); + throw new Error("Unable to load usage"); + } + + const { data: usage, error: usageError } = await supabase + .from("usage_limits") + .select( + "snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at, over_limit_snippets, over_limit_animations" + ) + .eq("user_id", userId) + .maybeSingle(); + + if (usageError && usageError.code !== "PGRST116") { + console.error("Failed to load usage limits (fallback)", usageError); + throw new Error("Unable to load usage"); + } + + const usageRow: any = usage ?? {}; + + const [ + { count: actualSnippetCount, error: snippetCountError }, + { count: actualAnimationCount, error: animationCountError }, + { count: actualFolderCount, error: folderCountError }, + { count: actualAnimationFolderCount, error: animationFolderCountError }, + ] = await Promise.all([ + supabase.from("snippet").select("id", { count: "exact", head: true }).eq("user_id", userId), + supabase.from("animation").select("id", { count: "exact", head: true }).eq("user_id", userId), + supabase.from("collection").select("id", { count: "exact", head: true }).eq("user_id", userId), + supabase + .from("animation_collection") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId), + ]); + + if (snippetCountError) { + console.error("Failed to count snippets for usage (fallback)", snippetCountError); + } + if (animationCountError) { + console.error("Failed to count animations for usage (fallback)", animationCountError); + } + if (folderCountError) { + console.error("Failed to count snippet folders for usage (fallback)", folderCountError); + } + if (animationFolderCountError) { + console.error("Failed to count animation folders for usage (fallback)", animationFolderCountError); + } + + const plan = (profile.plan as PlanId | null) ?? "free"; + const planConfig = getPlanConfig(plan); + + const snippetCountFromLimits = usageRow.snippet_count ?? 0; + const animationCountFromLimits = usageRow.animation_count ?? 0; + const overLimitSnippets = usageRow.over_limit_snippets ?? null; + const overLimitAnimations = usageRow.over_limit_animations ?? null; + const folderCountFromLimits = usageRow.folder_count ?? 0; + const videoExportCountFromLimits = usageRow.video_export_count ?? 0; + const publicShareCountFromLimits = usageRow.public_share_count ?? 0; + const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); + const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); + const folderCount = Math.max( + folderCountFromLimits, + (actualFolderCount ?? 0) + (actualAnimationFolderCount ?? 0) + ); + const publicShareCount = publicShareCountFromLimits; + const computedSnippetOverLimit = + overLimitSnippets ?? + (planConfig.maxSnippets === Infinity + ? 0 + : Math.max(snippetCount - (planConfig.maxSnippets ?? 0), 0)); + const computedAnimationOverLimit = + overLimitAnimations ?? + (planConfig.maxAnimations === Infinity + ? 0 + : Math.max(animationCount - (planConfig.maxAnimations ?? 0), 0)); + + return buildUsageSummary({ + plan, + planConfig, + snippetCount, + animationCount, + folderCount, + videoExportCount: videoExportCountFromLimits, + publicShareCount, + overLimitSnippets: computedSnippetOverLimit ?? 0, + overLimitAnimations: computedAnimationOverLimit ?? 0, + lastResetAt: usageRow.last_reset_at, + }); +}; + const RPC_MAP: Record< UsageLimitKind, { @@ -246,114 +429,209 @@ export const decrementUsageCount = async ( }; export const getUserUsage = async (supabase: Supabase, userId: string): Promise => { - const { data: profile, error: profileError } = await supabase - .from("profiles") - .select("plan") - .eq("id", userId) - .single(); + const cached = userUsageCache.get(userId); + const now = Date.now(); - if (profileError || !profile) { - console.error("Failed to load profile usage", profileError); - throw new Error("Unable to load usage"); - } - - const { data: usage, error: usageError } = await supabase - .from("usage_limits") - .select( - "snippet_count, animation_count, folder_count, video_export_count, public_share_count, last_reset_at, over_limit_snippets, over_limit_animations" - ) - .eq("user_id", userId) - .maybeSingle(); - - if (usageError && usageError.code !== "PGRST116") { - console.error("Failed to load usage limits", usageError); - throw new Error("Unable to load usage"); - } - - const usageRow: any = usage ?? {}; - - // Reconcile against actual table counts to avoid stale counters in usage_limits/profiles - const [ - { count: actualSnippetCount, error: snippetCountError }, - { count: actualAnimationCount, error: animationCountError }, - { count: actualFolderCount, error: folderCountError }, - { count: actualAnimationFolderCount, error: animationFolderCountError }, - ] = await Promise.all([ - supabase.from("snippet").select("id", { count: "exact", head: true }).eq("user_id", userId), - supabase.from("animation").select("id", { count: "exact", head: true }).eq("user_id", userId), - supabase.from("collection").select("id", { count: "exact", head: true }).eq("user_id", userId), - supabase - .from("animation_collection") - .select("id", { count: "exact", head: true }) - .eq("user_id", userId), - ]); - - if (snippetCountError) { - console.error("Failed to count snippets for usage", snippetCountError); - } - if (animationCountError) { - console.error("Failed to count animations for usage", animationCountError); - } - if (folderCountError) { - console.error("Failed to count snippet folders for usage", folderCountError); - } - if (animationFolderCountError) { - console.error("Failed to count animation folders for usage", animationFolderCountError); + if (cached && cached.expiresAt > now) { + return cached.value; } - const plan = (profile.plan as PlanId | null) ?? "free"; - const planConfig = getPlanConfig(plan); - const snippetCountFromLimits = usageRow.snippet_count ?? 0; - const animationCountFromLimits = usageRow.animation_count ?? 0; - const overLimitSnippets = usageRow.over_limit_snippets ?? null; - const overLimitAnimations = usageRow.over_limit_animations ?? null; - const folderCountFromLimits = usageRow.folder_count ?? 0; - const videoExportCountFromLimits = usageRow.video_export_count ?? 0; - const publicShareCountFromLimits = usageRow.public_share_count ?? 0; - const snippetCount = Math.max(snippetCountFromLimits, actualSnippetCount ?? 0); - const animationCount = Math.max(animationCountFromLimits, actualAnimationCount ?? 0); - const folderCount = Math.max( - folderCountFromLimits, - (actualFolderCount ?? 0) + (actualAnimationFolderCount ?? 0) - ); - const publicShareCount = publicShareCountFromLimits; - const computedSnippetOverLimit = - overLimitSnippets ?? - (planConfig.maxSnippets === Infinity - ? 0 - : Math.max(snippetCount - (planConfig.maxSnippets ?? 0), 0)); - const computedAnimationOverLimit = - overLimitAnimations ?? - (planConfig.maxAnimations === Infinity - ? 0 - : Math.max(animationCount - (planConfig.maxAnimations ?? 0), 0)); - - return { - plan, - snippets: { - current: snippetCount, - max: planConfig.maxSnippets === Infinity ? null : planConfig.maxSnippets, - overLimit: computedSnippetOverLimit ?? 0, - }, - animations: { - current: animationCount, - max: planConfig.maxAnimations === Infinity ? null : planConfig.maxAnimations, - overLimit: computedAnimationOverLimit ?? 0, - }, - folders: { - current: folderCount, - max: planConfig.maxSnippetsFolder === Infinity ? null : planConfig.maxSnippetsFolder, - }, - videoExports: { - current: videoExportCountFromLimits, - max: planConfig.maxVideoExportCount === Infinity ? null : planConfig.maxVideoExportCount, - }, - publicShares: { - current: publicShareCount, - max: planConfig.shareAsPublicURL === Infinity ? null : planConfig.shareAsPublicURL, - }, - lastResetAt: usageRow.last_reset_at ?? undefined, - }; + const usagePromise = (async (): Promise => { + const nowInner = Date.now(); + const shouldSkipRpc = userUsageRpcBackoffUntil > nowInner; + + if (shouldSkipRpc) { + return getUserUsageFallback(supabase, userId); + } + + // Try the new v2 function first + let { data, error } = await supabase.rpc(USER_USAGE_RPC_NAME, { p_user_id: userId }); + + // If v2 returns 404, try the fallback function (get_user_usage) + if (error || !data) { + const isNotFound = + (typeof error?.code === "string" && + (error.code === "404" || + error.code === "PGRST302" || + error.message?.toLowerCase().includes("not found"))) || + (typeof (error as any)?.status === "number" && (error as any).status === 404); + + if (isNotFound) { + // Report 404 errors to Sentry as they indicate a deployment/configuration issue + const errorMessage = `RPC function ${USER_USAGE_RPC_NAME} not found (404)`; + console.warn("[getUserUsage] RPC function v2 not found, trying fallback:", { + rpc_name: USER_USAGE_RPC_NAME, + fallback_rpc: USER_USAGE_RPC_FALLBACK_NAME, + error_code: error?.code, + error_message: error?.message, + user_id: userId, + }); + + // Use withScope to ensure proper context and check if Sentry is available + if (typeof window !== "undefined" && Sentry.getCurrentHub) { + // Client-side: use withScope and flush + Sentry.withScope((scope) => { + scope.setLevel("warning"); // Warning since we have a fallback + scope.setTag("rpc_function", USER_USAGE_RPC_NAME); + scope.setTag("error_type", "rpc_not_found_fallback"); + scope.setTag("user_id", userId); + scope.setContext("rpc_error", { + rpc_name: USER_USAGE_RPC_NAME, + fallback_rpc: USER_USAGE_RPC_FALLBACK_NAME, + error_code: error?.code, + error_message: error?.message, + error_details: error, + }); + scope.setExtra("error_code", error?.code); + scope.setExtra("error_message", error?.message); + scope.setExtra("fallback_rpc", USER_USAGE_RPC_FALLBACK_NAME); + + const notFoundError = new Error(errorMessage); + notFoundError.name = "RPCNotFoundError"; + Sentry.captureException(notFoundError); + + // Flush to ensure event is sent immediately on client-side + Sentry.flush(2000).catch((flushError) => { + console.warn("[getUserUsage] Sentry flush failed:", flushError); + }); + }); + } else { + // Server-side: simpler capture + Sentry.captureException(new Error(errorMessage), { + level: "warning", // Warning since we have a fallback + tags: { + rpc_function: USER_USAGE_RPC_NAME, + error_type: "rpc_not_found_fallback", + user_id: userId, + }, + extra: { + error_code: error?.code, + error_message: error?.message, + error_details: error, + fallback_rpc: USER_USAGE_RPC_FALLBACK_NAME, + }, + }); + } + + // Try the fallback function (get_user_usage) which should be available + const fallbackResult = await supabase.rpc(USER_USAGE_RPC_FALLBACK_NAME, { p_user_id: userId }); + + if (fallbackResult.error || !fallbackResult.data) { + // Both functions failed, use the full fallback method + console.error("[getUserUsage] Both RPC functions failed, using full fallback method"); + userUsageRpcBackoffUntil = Date.now() + USER_USAGE_RPC_BACKOFF_MS; + return getUserUsageFallback(supabase, userId); + } + + // Fallback function worked, use its data + data = fallbackResult.data; + error = null; + } + } + + // If we still have an error (non-404), handle it + if (error || !data) { + + // Report other RPC errors to Sentry + const rpcError = error instanceof Error ? error : new Error("RPC call failed"); + console.error("[getUserUsage] RPC call failed:", { + rpc_name: USER_USAGE_RPC_NAME, + error_code: error?.code, + error_message: error?.message, + error_details: error, + user_id: userId, + }); + + if (typeof window !== "undefined" && Sentry.getCurrentHub) { + // Client-side: use withScope and flush + Sentry.withScope((scope) => { + scope.setLevel("error"); + scope.setTag("rpc_function", USER_USAGE_RPC_NAME); + scope.setTag("error_type", "rpc_error"); + scope.setTag("user_id", userId); + scope.setContext("rpc_error", { + rpc_name: USER_USAGE_RPC_NAME, + error_code: error?.code, + error_message: error?.message, + error_details: error, + }); + scope.setExtra("error_code", error?.code); + scope.setExtra("error_message", error?.message); + scope.setExtra("error_details", error); + + Sentry.captureException(rpcError); + + // Flush to ensure event is sent immediately on client-side + Sentry.flush(2000).catch((flushError) => { + console.warn("[getUserUsage] Sentry flush failed:", flushError); + }); + }); + } else { + // Server-side: simpler capture + Sentry.captureException(rpcError, { + level: "error", + tags: { + rpc_function: USER_USAGE_RPC_NAME, + error_type: "rpc_error", + user_id: userId, + }, + extra: { + error_code: error?.code, + error_message: error?.message, + error_details: error, + }, + }); + } + + console.error("Failed to load usage via RPC", error); + throw new Error("Unable to load usage"); + } + + const usagePayload = data as UsageRpcPayload; + const plan = usagePayload.plan ?? "free"; + const planConfig = getPlanConfig(plan); + + const snippetCount = normalizeCount(usagePayload.snippet_count); + const animationCount = normalizeCount(usagePayload.animation_count); + const folderCount = normalizeCount(usagePayload.folder_count); + const videoExportCount = normalizeCount(usagePayload.video_export_count); + const publicShareCount = normalizeCount(usagePayload.public_share_count); + const overLimitSnippets = normalizeOverLimit(usagePayload.over_limit_snippets); + const overLimitAnimations = normalizeOverLimit(usagePayload.over_limit_animations); + const computedSnippetOverLimit = + overLimitSnippets !== null + ? overLimitSnippets + : planConfig.maxSnippets === Infinity + ? 0 + : Math.max(snippetCount - (planConfig.maxSnippets ?? 0), 0); + const computedAnimationOverLimit = + overLimitAnimations !== null + ? overLimitAnimations + : planConfig.maxAnimations === Infinity + ? 0 + : Math.max(animationCount - (planConfig.maxAnimations ?? 0), 0); + + return buildUsageSummary({ + plan, + planConfig, + snippetCount, + animationCount, + folderCount, + videoExportCount, + publicShareCount, + overLimitSnippets: computedSnippetOverLimit ?? 0, + overLimitAnimations: computedAnimationOverLimit ?? 0, + lastResetAt: usagePayload.last_reset_at, + }); + })(); + + userUsageCache.set(userId, { expiresAt: now + USER_USAGE_CACHE_TTL_MS, value: usagePromise }); + + usagePromise.catch(() => { + userUsageCache.delete(userId); + }); + + return usagePromise; }; export const checkSlideLimit = (slideCount: number, plan: PlanId): UsageLimitCheck => { diff --git a/src/types/database.ts b/src/types/database.ts index 3bae1f8..4e58858 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -592,6 +592,12 @@ export type Database = { } Returns: Json } + get_user_usage_v2: { + Args: { + p_user_id: string + } + Returns: Json + } increment_animation_count: { Args: { p_user_id: string diff --git a/supabase/migrations/20260524103000_get_user_usage_rpc.sql b/supabase/migrations/20260524103000_get_user_usage_rpc.sql new file mode 100644 index 0000000..c65bc52 --- /dev/null +++ b/supabase/migrations/20260524103000_get_user_usage_rpc.sql @@ -0,0 +1,89 @@ +-- Consolidate usage lookups into a single RPC to avoid multiple round trips from the client. + +create or replace function public.get_user_usage(p_user_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + v_usage record; + v_plan_limits json; + v_over_limit_snippets integer := 0; + v_over_limit_animations integer := 0; +begin + if auth.uid() is distinct from p_user_id then + raise exception 'Unauthorized'; + end if; + + perform ensure_usage_limits_row(p_user_id); + + with profile as ( + select id, plan + from public.profiles + where id = p_user_id + ), + counts as ( + select + p.plan, + greatest( + coalesce(ul.snippet_count, 0), + (select count(*) from public.snippet s where s.user_id = p_user_id) + ) as snippet_count, + greatest( + coalesce(ul.animation_count, 0), + (select count(*) from public.animation a where a.user_id = p_user_id) + ) as animation_count, + greatest( + coalesce(ul.folder_count, 0), + ( + select count(*) from ( + select 1 from public.collection c where c.user_id = p_user_id + union all + select 1 from public.animation_collection ac where ac.user_id = p_user_id + ) folders + ) + ) as folder_count, + coalesce(ul.video_export_count, 0) as video_export_count, + coalesce(ul.public_share_count, 0) as public_share_count, + ul.last_reset_at + from profile p + left join public.usage_limits ul on ul.user_id = p.id + ) + select * into v_usage from counts; + + if v_usage.plan is null then + raise exception 'User not found'; + end if; + + v_plan_limits := get_plan_limits(v_usage.plan); + + if (v_plan_limits ->> 'maxSnippets') is not null then + v_over_limit_snippets := greatest( + v_usage.snippet_count - (v_plan_limits ->> 'maxSnippets')::integer, + 0 + ); + end if; + + if (v_plan_limits ->> 'maxAnimations') is not null then + v_over_limit_animations := greatest( + v_usage.animation_count - (v_plan_limits ->> 'maxAnimations')::integer, + 0 + ); + end if; + + return jsonb_build_object( + 'plan', v_usage.plan, + 'snippet_count', v_usage.snippet_count, + 'animation_count', v_usage.animation_count, + 'folder_count', v_usage.folder_count, + 'video_export_count', v_usage.video_export_count, + 'public_share_count', v_usage.public_share_count, + 'last_reset_at', v_usage.last_reset_at, + 'over_limit_snippets', v_over_limit_snippets, + 'over_limit_animations', v_over_limit_animations + ); +end; +$$; + +grant execute on function public.get_user_usage(uuid) to authenticated; diff --git a/supabase/migrations/20260524104500_update_get_user_usage_auth.sql b/supabase/migrations/20260524104500_update_get_user_usage_auth.sql new file mode 100644 index 0000000..8d9c062 --- /dev/null +++ b/supabase/migrations/20260524104500_update_get_user_usage_auth.sql @@ -0,0 +1,95 @@ +-- Allow service role calls while keeping user-level authorization on get_user_usage. + +drop function if exists public.get_user_usage(uuid); + +create or replace function public.get_user_usage(p_user_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + v_usage record; + v_plan_limits json; + v_over_limit_snippets integer := 0; + v_over_limit_animations integer := 0; + v_role text := current_setting('request.jwt.claim.role', true); +begin + -- Permit service_role to query any user; otherwise require the caller to be the user. + if coalesce(v_role, '') <> 'service_role' then + if auth.uid() is null or auth.uid() is distinct from p_user_id then + raise exception 'Unauthorized'; + end if; + end if; + + perform ensure_usage_limits_row(p_user_id); + + with profile as ( + select id, plan + from public.profiles + where id = p_user_id + ), + counts as ( + select + p.plan, + greatest( + coalesce(ul.snippet_count, 0), + (select count(*) from public.snippet s where s.user_id = p_user_id) + ) as snippet_count, + greatest( + coalesce(ul.animation_count, 0), + (select count(*) from public.animation a where a.user_id = p_user_id) + ) as animation_count, + greatest( + coalesce(ul.folder_count, 0), + ( + select count(*) from ( + select 1 from public.collection c where c.user_id = p_user_id + union all + select 1 from public.animation_collection ac where ac.user_id = p_user_id + ) folders + ) + ) as folder_count, + coalesce(ul.video_export_count, 0) as video_export_count, + coalesce(ul.public_share_count, 0) as public_share_count, + ul.last_reset_at + from profile p + left join public.usage_limits ul on ul.user_id = p.id + ) + select * into v_usage from counts; + + if v_usage.plan is null then + raise exception 'User not found'; + end if; + + v_plan_limits := get_plan_limits(v_usage.plan); + + if (v_plan_limits ->> 'maxSnippets') is not null then + v_over_limit_snippets := greatest( + v_usage.snippet_count - (v_plan_limits ->> 'maxSnippets')::integer, + 0 + ); + end if; + + if (v_plan_limits ->> 'maxAnimations') is not null then + v_over_limit_animations := greatest( + v_usage.animation_count - (v_plan_limits ->> 'maxAnimations')::integer, + 0 + ); + end if; + + return jsonb_build_object( + 'plan', v_usage.plan, + 'snippet_count', v_usage.snippet_count, + 'animation_count', v_usage.animation_count, + 'folder_count', v_usage.folder_count, + 'video_export_count', v_usage.video_export_count, + 'public_share_count', v_usage.public_share_count, + 'last_reset_at', v_usage.last_reset_at, + 'over_limit_snippets', v_over_limit_snippets, + 'over_limit_animations', v_over_limit_animations + ); +end; +$$; + +grant execute on function public.get_user_usage(uuid) to authenticated; diff --git a/supabase/migrations/20260524113000_get_user_usage_v2.sql b/supabase/migrations/20260524113000_get_user_usage_v2.sql new file mode 100644 index 0000000..8ddcd6b --- /dev/null +++ b/supabase/migrations/20260524113000_get_user_usage_v2.sql @@ -0,0 +1,120 @@ +-- Provide a new RPC name to avoid stale PostgREST cache issues with the previous function name. + +create or replace function public.get_user_usage_v2(p_user_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = public, extensions, pg_temp +as $$ +declare + v_usage record; + v_plan_limits json; + v_over_limit_snippets integer := 0; + v_over_limit_animations integer := 0; + v_role text := current_setting('request.jwt.claim.role', true); +begin + -- Permit service_role to query any user; otherwise require the caller to be the user. + if coalesce(v_role, '') <> 'service_role' then + if auth.uid() is null or auth.uid() is distinct from p_user_id then + raise exception 'Unauthorized'; + end if; + end if; + + perform ensure_usage_limits_row(p_user_id); + + with profile as ( + select id, plan + from public.profiles + where id = p_user_id + ), + counts as ( + select + p.plan, + greatest( + coalesce(ul.snippet_count, 0), + (select count(*) from public.snippet s where s.user_id = p_user_id) + ) as snippet_count, + greatest( + coalesce(ul.animation_count, 0), + (select count(*) from public.animation a where a.user_id = p_user_id) + ) as animation_count, + greatest( + coalesce(ul.folder_count, 0), + ( + select count(*) from ( + select 1 from public.collection c where c.user_id = p_user_id + union all + select 1 from public.animation_collection ac where ac.user_id = p_user_id + ) folders + ) + ) as folder_count, + coalesce(ul.video_export_count, 0) as video_export_count, + coalesce(ul.public_share_count, 0) as public_share_count, + ul.last_reset_at + from profile p + left join public.usage_limits ul on ul.user_id = p.id + ) + select * into v_usage from counts; + + if v_usage.plan is null then + raise exception 'User not found'; + end if; + + -- Convert user_plan enum to plan_type enum via text (profiles.plan is user_plan, get_plan_limits expects plan_type) + -- user_plan has ('free', 'pro'), plan_type has ('free', 'started', 'pro') + v_plan_limits := get_plan_limits(v_usage.plan::text::plan_type); + + if (v_plan_limits ->> 'maxSnippets') is not null then + v_over_limit_snippets := greatest( + v_usage.snippet_count - (v_plan_limits ->> 'maxSnippets')::integer, + 0 + ); + end if; + + if (v_plan_limits ->> 'maxAnimations') is not null then + v_over_limit_animations := greatest( + v_usage.animation_count - (v_plan_limits ->> 'maxAnimations')::integer, + 0 + ); + end if; + + return jsonb_build_object( + 'plan', v_usage.plan, + 'snippet_count', v_usage.snippet_count, + 'animation_count', v_usage.animation_count, + 'folder_count', v_usage.folder_count, + 'video_export_count', v_usage.video_export_count, + 'public_share_count', v_usage.public_share_count, + 'last_reset_at', v_usage.last_reset_at, + 'over_limit_snippets', v_over_limit_snippets, + 'over_limit_animations', v_over_limit_animations + ); +end; +$$; + +grant execute on function public.get_user_usage_v2(uuid) to authenticated; +grant execute on function public.get_user_usage_v2(uuid) to anon; + +-- PostgREST Schema Cache Refresh +-- In Supabase hosted, PostgREST schema cache refreshes automatically, but it may take 1-5 minutes. +-- If you're still getting 404 errors after applying this migration: +-- +-- 1. Verify the function exists: +-- SELECT proname, pg_get_function_arguments(oid) +-- FROM pg_proc +-- WHERE proname = 'get_user_usage_v2'; +-- +-- 2. Verify grants are correct: +-- SELECT grantee, privilege_type +-- FROM information_schema.routine_privileges +-- WHERE routine_name = 'get_user_usage_v2'; +-- +-- 3. Wait 2-5 minutes for PostgREST cache to refresh automatically +-- +-- 4. If using Supabase CLI locally, restart PostgREST: +-- supabase stop && supabase start +-- +-- 5. Check Supabase Dashboard > Database > Functions to confirm visibility +-- +-- 6. As a workaround, the application will fall back to getUserUsageFallback() +-- if the RPC returns 404, so functionality should continue to work. From 76761fe6096b430085ec0ea35a4effe17b28db83 Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 16:26:15 -0300 Subject: [PATCH 022/102] :sparkles: feat: add database migration for new foreign key and composite indexes, and a script to verify index usage. --- scripts/verify-indexes.sql | 136 ++++++++++++++++++ ...20260125000000_add_foreign_key_indexes.sql | 102 +++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 scripts/verify-indexes.sql create mode 100644 supabase/migrations/20260125000000_add_foreign_key_indexes.sql diff --git a/scripts/verify-indexes.sql b/scripts/verify-indexes.sql new file mode 100644 index 0000000..a4fbac7 --- /dev/null +++ b/scripts/verify-indexes.sql @@ -0,0 +1,136 @@ +-- Verification script for database indexes +-- Run this script to verify that indexes are being used in query plans +-- Usage: Connect to your Supabase database and run these queries + +-- ============================================================================ +-- CHECK EXISTING INDEXES +-- ============================================================================ +-- List all indexes on tables with foreign keys + +SELECT + schemaname, + tablename, + indexname, + indexdef +FROM pg_indexes +WHERE schemaname = 'public' + AND ( + tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') + OR indexname LIKE '%user_id%' + OR indexname LIKE '%created_at%' + OR indexname LIKE '%snippet_id%' + OR indexname LIKE '%link_id%' + ) +ORDER BY tablename, indexname; + +-- ============================================================================ +-- VERIFY INDEX USAGE WITH EXPLAIN ANALYZE +-- ============================================================================ +-- Replace 'YOUR_USER_ID_HERE' with an actual user_id from your database +-- These queries should show "Index Scan" or "Bitmap Index Scan" in the plan + +-- 1. Snippet queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.snippet +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 2. Animation queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.animation +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 3. Collection queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.collection +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 4. Links lookup by short_url +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.links +WHERE short_url = 'YOUR_SHORT_URL_HERE'; + +-- 5. Links by snippet_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.links +WHERE snippet_id = 'YOUR_SNIPPET_ID_HERE'; + +-- 6. Share view events by owner +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.share_view_events +WHERE owner_id = 'YOUR_USER_ID_HERE' +ORDER BY viewed_on DESC +LIMIT 20; + +-- ============================================================================ +-- CHECK INDEX STATISTICS +-- ============================================================================ +-- View index usage statistics (requires pg_stat_statements extension) +-- This shows which indexes are actually being used + +SELECT + schemaname, + tablename, + indexname, + idx_scan as index_scans, + idx_tup_read as tuples_read, + idx_tup_fetch as tuples_fetched +FROM pg_stat_user_indexes +WHERE schemaname = 'public' + AND tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') +ORDER BY idx_scan DESC; + +-- ============================================================================ +-- CHECK FOR MISSING INDEXES ON FOREIGN KEYS +-- ============================================================================ +-- This query identifies foreign key columns that don't have indexes + +SELECT + tc.table_schema, + tc.constraint_name, + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name, + CASE + WHEN EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = tc.table_schema + AND tablename = tc.table_name + AND indexdef LIKE '%' || kcu.column_name || '%' + ) THEN 'HAS INDEX' + ELSE 'MISSING INDEX' + END AS index_status +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' +ORDER BY tc.table_name, kcu.column_name; + +-- ============================================================================ +-- CHECK TABLE SIZES AND INDEX SIZES +-- ============================================================================ +-- Monitor index sizes to ensure they're not growing too large + +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size, + pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS indexes_size +FROM pg_tables +WHERE schemaname = 'public' + AND tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; + diff --git a/supabase/migrations/20260125000000_add_foreign_key_indexes.sql b/supabase/migrations/20260125000000_add_foreign_key_indexes.sql new file mode 100644 index 0000000..256f15d --- /dev/null +++ b/supabase/migrations/20260125000000_add_foreign_key_indexes.sql @@ -0,0 +1,102 @@ +-- Add indexes on foreign keys and composite indexes for common query patterns +-- This migration addresses missing indexes on foreign key columns and optimizes +-- queries that filter by user_id and order by created_at + +-- ============================================================================ +-- FOREIGN KEY INDEXES +-- ============================================================================ +-- Add indexes on foreign key columns that don't already have them + +-- Links table: snippet_id foreign key +CREATE INDEX IF NOT EXISTS idx_links_snippet_id ON public.links(snippet_id); + +-- Share view events: link_id foreign key (already has unique composite, but add single column for lookups) +CREATE INDEX IF NOT EXISTS idx_share_view_events_link_id ON public.share_view_events(link_id); + +-- ============================================================================ +-- COMPOSITE INDEXES FOR COMMON QUERY PATTERNS +-- ============================================================================ +-- These indexes optimize queries that filter by user_id and order by created_at +-- which is a very common pattern in the application + +-- Snippet table: user_id + created_at (for listing user's snippets by date) +CREATE INDEX IF NOT EXISTS idx_snippet_user_id_created_at ON public.snippet(user_id, created_at DESC); + +-- Collection table: user_id + created_at (for listing user's collections by date) +CREATE INDEX IF NOT EXISTS idx_collection_user_id_created_at ON public.collection(user_id, created_at DESC); + +-- Animation table: user_id + created_at (for listing user's animations by date) +CREATE INDEX IF NOT EXISTS idx_animation_user_id_created_at ON public.animation(user_id, created_at DESC); + +-- Animation collection table: user_id + created_at (for listing user's animation collections by date) +CREATE INDEX IF NOT EXISTS idx_animation_collection_user_id_created_at ON public.animation_collection(user_id, created_at DESC); + +-- Links table: user_id + created_at (for listing user's links by date) +CREATE INDEX IF NOT EXISTS idx_links_user_id_created_at ON public.links(user_id, created_at DESC); + +-- Share view events: owner_id + viewed_on (already exists, but ensure it's optimal) +-- The existing index share_view_events_owner_idx covers (owner_id, viewed_on) +-- This is good, but we can add a DESC version if needed for recent-first queries +CREATE INDEX IF NOT EXISTS idx_share_view_events_owner_viewed_on_desc ON public.share_view_events(owner_id, viewed_on DESC); + +-- ============================================================================ +-- ADDITIONAL TIME-BASED INDEXES +-- ============================================================================ +-- Indexes on created_at columns for time-based queries and analytics + +-- Snippet table: created_at (for global time-based queries) +CREATE INDEX IF NOT EXISTS idx_snippet_created_at ON public.snippet(created_at DESC); + +-- Animation table: created_at (for global time-based queries) +CREATE INDEX IF NOT EXISTS idx_animation_created_at ON public.animation(created_at DESC); + +-- Links table: created_at (for time-based link analytics) +CREATE INDEX IF NOT EXISTS idx_links_created_at ON public.links(created_at DESC); + +-- Collection table: created_at (for time-based queries) +CREATE INDEX IF NOT EXISTS idx_collection_created_at ON public.collection(created_at DESC); + +-- Animation collection table: created_at (for time-based queries) +CREATE INDEX IF NOT EXISTS idx_animation_collection_created_at ON public.animation_collection(created_at DESC); + +-- ============================================================================ +-- ADDITIONAL QUERY OPTIMIZATION INDEXES +-- ============================================================================ +-- Indexes for other common query patterns + +-- Links table: short_url (for quick lookups when resolving shared links) +CREATE INDEX IF NOT EXISTS idx_links_short_url ON public.links(short_url); + +-- Links table: snippet_id + user_id (for queries that filter by both) +CREATE INDEX IF NOT EXISTS idx_links_snippet_user ON public.links(snippet_id, user_id) WHERE snippet_id IS NOT NULL; + +-- Stripe webhook audit: user_id (for filtering webhooks by user) +CREATE INDEX IF NOT EXISTS idx_stripe_webhook_audit_user_id ON public.stripe_webhook_audit(user_id) WHERE user_id IS NOT NULL; + +-- Usage drift alerts: user_id + created_at (for listing alerts by user and date) +CREATE INDEX IF NOT EXISTS idx_usage_drift_alerts_user_created_at ON public.usage_drift_alerts(user_id, created_at DESC) WHERE user_id IS NOT NULL; + +-- ============================================================================ +-- VERIFICATION QUERIES (commented out - run manually to verify) +-- ============================================================================ +-- Uncomment and run these queries to verify index usage: +-- +-- EXPLAIN ANALYZE +-- SELECT * FROM public.snippet +-- WHERE user_id = 'some-uuid' +-- ORDER BY created_at DESC; +-- +-- EXPLAIN ANALYZE +-- SELECT * FROM public.animation +-- WHERE user_id = 'some-uuid' +-- ORDER BY created_at DESC; +-- +-- EXPLAIN ANALYZE +-- SELECT * FROM public.links +-- WHERE short_url = 'some-short-url'; +-- +-- EXPLAIN ANALYZE +-- SELECT * FROM public.share_view_events +-- WHERE owner_id = 'some-uuid' +-- ORDER BY viewed_on DESC; + From 8267e942f39a03875027f315809fba665c9e7bea Mon Sep 17 00:00:00 2001 From: Dana Rocha Date: Mon, 8 Dec 2025 16:31:22 -0300 Subject: [PATCH 023/102] :bug: refactor: Improve Stripe price ID mapping, use native disabled attribute for buttons, refine video export count logic, and correct snippet update action return. --- src/actions/snippets/update-snippet.ts | 20 ++-- src/app/api/save-shared-url-visits/route.ts | 2 +- src/app/api/webhooks/stripe/route.ts | 18 ++-- src/components/ui/add-slide-card/index.tsx | 4 +- .../animation/animation-download-menu.tsx | 98 ++++++++++++------- 5 files changed, 85 insertions(+), 57 deletions(-) diff --git a/src/actions/snippets/update-snippet.ts b/src/actions/snippets/update-snippet.ts index a8023b1..5f63323 100644 --- a/src/actions/snippets/update-snippet.ts +++ b/src/actions/snippets/update-snippet.ts @@ -33,8 +33,8 @@ export async function updateSnippet( const payload = parsedInput.data - const data = await withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => - updateSnippetInDb({ + return withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => { + const data = await updateSnippetInDb({ id, user_id: user.id, title, @@ -43,17 +43,17 @@ export async function updateSnippet( url, supabase } as any) - ) - if (!data || data.length === 0) { - return error('Failed to update snippet') - } + if (!data || data.length === 0) { + return error('Failed to update snippet') + } - // Revalidate relevant paths - revalidatePath('/snippets') - revalidatePath('/') + // Revalidate relevant paths + revalidatePath('/snippets') + revalidatePath('/') - return success(data[0]) + return success(data[0]) + }) } catch (err) { console.error('Error updating snippet:', err) diff --git a/src/app/api/save-shared-url-visits/route.ts b/src/app/api/save-shared-url-visits/route.ts index 11097eb..e7fb34d 100644 --- a/src/app/api/save-shared-url-visits/route.ts +++ b/src/app/api/save-shared-url-visits/route.ts @@ -33,7 +33,7 @@ export const POST = wrapRouteHandlerWithSentry( if (limitResponse) return limitResponse; try { - const { id } = await await validateContentType(request).json(); + const { id } = await validateContentType(request).json(); if (!id) { applyResponseContextToSentry(400); diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 1baaf94..ad20858 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -12,12 +12,18 @@ type ServiceRoleClient = ReturnType; // Map Stripe price IDs to plan IDs function getPlanIdFromPriceId(priceId: string): PlanId | null { - const priceIdMap: Record = { - [process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID || '']: 'started', - [process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID || '']: 'started', - [process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID || '']: 'pro', - [process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID || '']: 'pro', - }; + // Build map only from non-empty environment variables to prevent collisions + const priceIdMap: Record = {}; + + const startedMonthly = process.env.NEXT_PUBLIC_STRIPE_STARTED_MONTHLY_PRICE_ID; + const startedYearly = process.env.NEXT_PUBLIC_STRIPE_STARTED_YEARLY_PRICE_ID; + const proMonthly = process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID; + const proYearly = process.env.NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID; + + if (startedMonthly) priceIdMap[startedMonthly] = 'started'; + if (startedYearly) priceIdMap[startedYearly] = 'started'; + if (proMonthly) priceIdMap[proMonthly] = 'pro'; + if (proYearly) priceIdMap[proYearly] = 'pro'; return priceIdMap[priceId] || null; } diff --git a/src/components/ui/add-slide-card/index.tsx b/src/components/ui/add-slide-card/index.tsx index 32f12d9..ee6d91f 100644 --- a/src/components/ui/add-slide-card/index.tsx +++ b/src/components/ui/add-slide-card/index.tsx @@ -12,13 +12,13 @@ export const AddSlideCard = React.memo( + )} + {onDismiss && ( + + )} +
+
+ + + + ); +} + diff --git a/src/features/home/index.tsx b/src/features/home/index.tsx index 8697e9d..480b39e 100644 --- a/src/features/home/index.tsx +++ b/src/features/home/index.tsx @@ -13,6 +13,7 @@ import { fonts } from "@/lib/fonts-options"; import { Nav } from "@/components/ui/nav"; import { Sidebar } from "@/components/ui/sidebar"; import { Logo } from "@/components/ui/logo"; +import { UsageBannerWrapper } from "@/components/usage-banner-wrapper"; import { SettingsPanel } from "@/features/settings-panel"; import { UserTools } from "@/features/user-tools"; import { CodeEditor } from "@/features/code-editor"; @@ -152,6 +153,10 @@ export const Home = () => {