diff options
| -rw-r--r-- | apps/web/app/settings/layout.tsx | 132 | ||||
| -rw-r--r-- | apps/web/app/settings/subscription/page.tsx | 18 | ||||
| -rw-r--r-- | apps/web/components/settings/SubscriptionSettings.tsx | 233 | ||||
| -rw-r--r-- | apps/web/components/subscription/QuotaProgress.tsx | 177 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 25 | ||||
| -rw-r--r-- | packages/api/index.ts | 4 | ||||
| -rw-r--r-- | packages/api/routes/webhooks.ts | 44 | ||||
| -rw-r--r-- | packages/db/drizzle/0058_add_subscription.sql | 19 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0058_snapshot.json | 2338 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 53 | ||||
| -rw-r--r-- | packages/shared/config.ts | 28 | ||||
| -rw-r--r-- | packages/trpc/package.json | 1 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/subscriptions.test.ts | 881 | ||||
| -rw-r--r-- | packages/trpc/routers/subscriptions.ts | 427 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 20 |
18 files changed, 4351 insertions, 60 deletions
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 217e02ba..b9ba80a0 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -7,6 +7,7 @@ import { TFunction } from "i18next"; import { ArrowLeft, BarChart3, + CreditCard, Download, GitBranch, Image, @@ -18,69 +19,86 @@ import { Webhook, } from "lucide-react"; +import serverConfig from "@karakeep/shared/config"; + const settingsSidebarItems = ( t: TFunction, ): { name: string; icon: JSX.Element; path: string; -}[] => [ - { - name: t("settings.back_to_app"), - icon: <ArrowLeft size={18} />, - path: "/dashboard/bookmarks", - }, - { - name: t("settings.info.user_info"), - icon: <User size={18} />, - path: "/settings/info", - }, - { - name: t("settings.stats.usage_statistics"), - icon: <BarChart3 size={18} />, - path: "/settings/stats", - }, - { - name: t("settings.ai.ai_settings"), - icon: <Sparkles size={18} />, - path: "/settings/ai", - }, - { - name: t("settings.feeds.rss_subscriptions"), - icon: <Rss size={18} />, - path: "/settings/feeds", - }, - { - name: t("settings.import.import_export"), - icon: <Download size={18} />, - path: "/settings/import", - }, - { - name: t("settings.api_keys.api_keys"), - icon: <KeyRound size={18} />, - path: "/settings/api-keys", - }, - { - name: t("settings.broken_links.broken_links"), - icon: <Link size={18} />, - path: "/settings/broken-links", - }, - { - name: t("settings.webhooks.webhooks"), - icon: <Webhook size={18} />, - path: "/settings/webhooks", - }, - { - name: t("settings.rules.rules"), - icon: <GitBranch size={18} />, - path: "/settings/rules", - }, - { - name: t("settings.manage_assets.manage_assets"), - icon: <Image size={18} />, - path: "/settings/assets", - }, -]; +}[] => { + const baseItems = [ + { + name: t("settings.back_to_app"), + icon: <ArrowLeft size={18} />, + path: "/dashboard/bookmarks", + }, + { + name: t("settings.info.user_info"), + icon: <User size={18} />, + path: "/settings/info", + }, + { + name: t("settings.stats.usage_statistics"), + icon: <BarChart3 size={18} />, + path: "/settings/stats", + }, + ]; + + // Add subscription item if Stripe is configured + if (serverConfig.stripe.isConfigured) { + baseItems.push({ + name: t("settings.subscription.subscription"), + icon: <CreditCard size={18} />, + path: "/settings/subscription", + }); + } + + return [ + ...baseItems, + { + name: t("settings.ai.ai_settings"), + icon: <Sparkles size={18} />, + path: "/settings/ai", + }, + { + name: t("settings.feeds.rss_subscriptions"), + icon: <Rss size={18} />, + path: "/settings/feeds", + }, + { + name: t("settings.import.import_export"), + icon: <Download size={18} />, + path: "/settings/import", + }, + { + name: t("settings.api_keys.api_keys"), + icon: <KeyRound size={18} />, + path: "/settings/api-keys", + }, + { + name: t("settings.broken_links.broken_links"), + icon: <Link size={18} />, + path: "/settings/broken-links", + }, + { + name: t("settings.webhooks.webhooks"), + icon: <Webhook size={18} />, + path: "/settings/webhooks", + }, + { + name: t("settings.rules.rules"), + icon: <GitBranch size={18} />, + path: "/settings/rules", + }, + { + name: t("settings.manage_assets.manage_assets"), + icon: <Image size={18} />, + path: "/settings/assets", + }, + ]; +}; export default async function SettingsLayout({ children, diff --git a/apps/web/app/settings/subscription/page.tsx b/apps/web/app/settings/subscription/page.tsx new file mode 100644 index 00000000..e8c46460 --- /dev/null +++ b/apps/web/app/settings/subscription/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; +import SubscriptionSettings from "@/components/settings/SubscriptionSettings"; +import { QuotaProgress } from "@/components/subscription/QuotaProgress"; + +import serverConfig from "@karakeep/shared/config"; + +export default async function SubscriptionPage() { + if (!serverConfig.stripe.isConfigured) { + redirect("/settings"); + } + + return ( + <div className="flex flex-col gap-4"> + <SubscriptionSettings /> + <QuotaProgress /> + </div> + ); +} diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx new file mode 100644 index 00000000..53f1caf4 --- /dev/null +++ b/apps/web/components/settings/SubscriptionSettings.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { CreditCard, Loader2 } from "lucide-react"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Skeleton } from "../ui/skeleton"; +import { toast } from "../ui/use-toast"; + +export default function SubscriptionSettings() { + const { t } = useTranslation(); + const { + data: subscriptionStatus, + refetch, + isLoading: isQueryLoading, + } = api.subscriptions.getSubscriptionStatus.useQuery(); + + const { data: subscriptionPrice } = + api.subscriptions.getSubscriptionPrice.useQuery(); + + const { mutate: syncStripeState } = + api.subscriptions.syncWithStripe.useMutation({ + onSuccess: () => { + refetch(); + }, + }); + const createCheckoutSession = + api.subscriptions.createCheckoutSession.useMutation({ + onSuccess: (resp) => { + if (resp.url) { + window.location.href = resp.url; + } + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + const createPortalSession = api.subscriptions.createPortalSession.useMutation( + { + onSuccess: (resp) => { + if (resp.url) { + window.location.href = resp.url; + } + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }, + ); + + const isLoading = + createCheckoutSession.isPending || createPortalSession.isPending; + + useEffect(() => { + syncStripeState(); + }, []); + + const formatDate = (date: Date | null) => { + if (!date) return "N/A"; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }).format(date); + }; + + const getStatusBadge = (status: "free" | "paid") => { + switch (status) { + case "paid": + return ( + <Badge variant="default" className="bg-green-500"> + {t("settings.subscription.paid")} + </Badge> + ); + case "free": + return ( + <Badge variant="outline">{t("settings.subscription.free")}</Badge> + ); + } + }; + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <CreditCard className="h-5 w-5" /> + {t("settings.subscription.subscription")} + </CardTitle> + <CardDescription> + {t("settings.subscription.manage_subscription")} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {isQueryLoading ? ( + <div className="space-y-6"> + <div className="grid gap-4 md:grid-cols-2"> + <div className="space-y-2"> + <Skeleton className="h-4 w-24" /> + <div className="flex items-center gap-2"> + <Skeleton className="h-5 w-16" /> + <Skeleton className="h-5 w-12" /> + </div> + </div> + <div className="space-y-2"> + <Skeleton className="h-4 w-28" /> + <Skeleton className="h-4 w-40" /> + </div> + </div> + <div className="space-y-4"> + <div className="rounded-lg border p-4"> + <div className="flex items-center justify-between"> + <div className="space-y-2"> + <Skeleton className="h-6 w-24" /> + <Skeleton className="h-4 w-64" /> + <Skeleton className="h-6 w-20" /> + </div> + <Skeleton className="h-10 w-32" /> + </div> + </div> + </div> + </div> + ) : ( + <> + <div className="grid gap-4 md:grid-cols-2"> + <div className="space-y-2"> + <label className="text-sm font-medium"> + {t("settings.subscription.current_plan")} + </label> + <div className="flex items-center gap-2"> + {subscriptionStatus?.tier && + getStatusBadge(subscriptionStatus.tier)} + </div> + </div> + + {subscriptionStatus?.hasActiveSubscription && ( + <> + <div className="space-y-2"> + <label className="text-sm font-medium"> + {t("settings.subscription.billing_period")} + </label> + <div className="text-sm text-muted-foreground"> + {formatDate(subscriptionStatus.startDate)} -{" "} + {formatDate(subscriptionStatus.endDate)} + </div> + </div> + </> + )} + </div> + + <div className="space-y-4"> + {!subscriptionStatus?.hasActiveSubscription ? ( + <div className="space-y-4"> + <div className="rounded-lg border p-4"> + <div className="flex items-center justify-between"> + <div> + <h3 className="flex items-center gap-2 font-semibold"> + {t("settings.subscription.paid_plan")} + </h3> + <p className="text-sm text-muted-foreground"> + {t("settings.subscription.unlock_bigger_quota")} + </p> + {subscriptionPrice && subscriptionPrice.amount ? ( + <p className="mt-2 text-lg font-bold uppercase"> + {subscriptionPrice.amount / 100}{" "} + {subscriptionPrice.currency} + </p> + ) : ( + <Skeleton className="h-4 w-24" /> + )} + </div> + <Button + onClick={() => createCheckoutSession.mutate()} + disabled={isLoading} + className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700" + > + {isLoading && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + {t("settings.subscription.subscribe_now")} + </Button> + </div> + </div> + </div> + ) : ( + <div className="space-y-4"> + <div className="flex gap-2"> + <Button + onClick={() => createPortalSession.mutate()} + disabled={isLoading} + variant="outline" + > + {isLoading && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + {t("settings.subscription.manage_billing")} + </Button> + </div> + + {subscriptionStatus.cancelAtPeriodEnd && ( + <Alert> + <AlertDescription> + {t("settings.subscription.subscription_canceled", { + date: formatDate(subscriptionStatus.endDate), + })} + </AlertDescription> + </Alert> + )} + </div> + )} + </div> + </> + )} + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx new file mode 100644 index 00000000..525eae8f --- /dev/null +++ b/apps/web/components/subscription/QuotaProgress.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { Database, HardDrive } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Progress } from "../ui/progress"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + "K"; + } + return num.toString(); +} + +interface QuotaProgressItemProps { + title: string; + icon: React.ReactNode; + used: number; + quota: number | null; + unlimited: boolean; + formatter: (value: number) => string; + description: string; +} + +function QuotaProgressItem({ + title, + icon, + used, + quota, + unlimited, + formatter, + description, +}: QuotaProgressItemProps) { + const { t } = useTranslation(); + const percentage = + unlimited || !quota ? 0 : Math.min((used / quota) * 100, 100); + const isNearLimit = percentage > 80; + const isAtLimit = percentage >= 100; + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + {icon} + <h4 className="font-medium">{title}</h4> + </div> + + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">{description}</span> + <span className={isAtLimit ? "font-medium text-destructive" : ""}> + {formatter(used)}{" "} + {unlimited + ? "" + : `/ ${quota !== null && quota !== undefined ? formatter(quota) : "∞"}`} + </span> + </div> + + {!unlimited && quota && ( + <Progress + value={percentage} + className={`h-2 ${ + isAtLimit + ? "[&>div]:bg-destructive" + : isNearLimit + ? "[&>div]:bg-orange-500" + : "" + }`} + /> + )} + + {unlimited && ( + <div className="text-xs text-muted-foreground"> + {t("settings.subscription.unlimited_usage")} + </div> + )} + + {isAtLimit && ( + <div className="text-xs text-destructive"> + {t("settings.subscription.quota_limit_reached")} + </div> + )} + + {isNearLimit && !isAtLimit && ( + <div className="text-xs text-orange-600"> + {t("settings.subscription.approaching_quota_limit")} + </div> + )} + </div> + </div> + ); +} + +export function QuotaProgress() { + const { t } = useTranslation(); + const { data: quotaUsage, isLoading } = + api.subscriptions.getQuotaUsage.useQuery(); + + if (isLoading) { + return ( + <Card> + <CardHeader> + <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle> + <CardDescription> + {t("settings.subscription.loading_usage")} + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div className="animate-pulse space-y-2"> + <div className="h-4 w-1/3 rounded bg-muted"></div> + <div className="h-2 rounded bg-muted"></div> + </div> + <div className="animate-pulse space-y-2"> + <div className="h-4 w-1/3 rounded bg-muted"></div> + <div className="h-2 rounded bg-muted"></div> + </div> + </div> + </CardContent> + </Card> + ); + } + + if (!quotaUsage) { + return null; + } + + return ( + <Card> + <CardHeader> + <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle> + <CardDescription> + {t("settings.subscription.track_usage")} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + <QuotaProgressItem + title="Bookmarks" + icon={<Database className="h-4 w-4" />} + used={quotaUsage.bookmarks.used} + quota={quotaUsage.bookmarks.quota} + unlimited={quotaUsage.bookmarks.unlimited} + formatter={formatNumber} + description={t("settings.subscription.total_bookmarks_saved")} + /> + + <QuotaProgressItem + title="Storage" + icon={<HardDrive className="h-4 w-4" />} + used={quotaUsage.storage.used} + quota={quotaUsage.storage.quota} + unlimited={quotaUsage.storage.unlimited} + formatter={formatBytes} + description={t("settings.subscription.assets_file_storage")} + /> + </CardContent> + </Card> + ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33f4952c..f9e1d493 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -37,7 +37,9 @@ "text": "Text", "media": "Media" }, - "quota": "Quota" + "quota": "Quota", + "bookmarks": "Bookmarks", + "storage": "Storage" }, "layouts": { "masonry": "Masonry", @@ -283,6 +285,27 @@ "favourited": "A bookmark is favourited", "archived": "A bookmark is archived" } + }, + "subscription": { + "subscription": "Subscription", + "manage_subscription": "Manage your subscription and billing information", + "current_plan": "Current Plan", + "billing_period": "Billing Period", + "paid_plan": "Paid Plan", + "unlock_bigger_quota": "Unlock bigger quota and support the project", + "subscribe_now": "Subscribe Now", + "manage_billing": "Manage Billing", + "subscription_canceled": "Your subscription has been canceled and will end on {{date}}. You can resubscribe at any time.", + "usage_quotas": "Usage & Quotas", + "track_usage": "Track your current usage against your plan limits", + "total_bookmarks_saved": "Total bookmarks saved", + "assets_file_storage": "Assets and file storage", + "unlimited_usage": "Unlimited usage", + "quota_limit_reached": "Quota limit reached", + "approaching_quota_limit": "Approaching quota limit", + "loading_usage": "Loading usage information...", + "free": "Free", + "paid": "Paid" } }, "admin": { diff --git a/packages/api/index.ts b/packages/api/index.ts index 39075548..1e353f41 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -18,6 +18,7 @@ import rss from "./routes/rss"; import tags from "./routes/tags"; import trpc from "./routes/trpc"; import users from "./routes/users"; +import webhooks from "./routes/webhooks"; const v1 = new Hono<{ Variables: { @@ -62,6 +63,7 @@ const app = new Hono<{ .route("/admin", admin) .route("/assets", assets) .route("/public", publicRoute) - .route("/metrics", metrics); + .route("/metrics", metrics) + .route("/webhooks", webhooks); export default app; diff --git a/packages/api/routes/webhooks.ts b/packages/api/routes/webhooks.ts new file mode 100644 index 00000000..66ce96d3 --- /dev/null +++ b/packages/api/routes/webhooks.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono"; + +import { Context, createCallerFactory } from "@karakeep/trpc"; +import { appRouter } from "@karakeep/trpc/routers/_app"; + +const createCaller = createCallerFactory(appRouter); + +const app = new Hono<{ + Variables: { + ctx: Context; + }; +}>().post("/stripe", async (c) => { + const body = await c.req.text(); + const signature = c.req.header("stripe-signature"); + + if (!signature) { + return c.json({ error: "Missing stripe-signature header" }, 400); + } + + try { + const api = createCaller(c.get("ctx")); + const result = await api.subscriptions.handleWebhook({ + body, + signature, + }); + + return c.json(result); + } catch (error) { + console.error("Webhook processing failed:", error); + + if (error instanceof Error) { + if (error.message.includes("Invalid signature")) { + return c.json({ error: "Invalid signature" }, 400); + } + if (error.message.includes("not configured")) { + return c.json({ error: "Stripe is not configured" }, 400); + } + } + + return c.json({ error: "Internal server error" }, 500); + } +}); + +export default app; diff --git a/packages/db/drizzle/0058_add_subscription.sql b/packages/db/drizzle/0058_add_subscription.sql new file mode 100644 index 00000000..77260c58 --- /dev/null +++ b/packages/db/drizzle/0058_add_subscription.sql @@ -0,0 +1,19 @@ +CREATE TABLE `subscriptions` ( + `id` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, + `stripeCustomerId` text NOT NULL, + `stripeSubscriptionId` text, + `status` text NOT NULL, + `tier` text DEFAULT 'free' NOT NULL, + `priceId` text, + `cancelAtPeriodEnd` integer DEFAULT false, + `startDate` integer, + `endDate` integer, + `createdAt` integer NOT NULL, + `modifiedAt` integer, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `subscriptions_userId_unique` ON `subscriptions` (`userId`);--> statement-breakpoint +CREATE INDEX `subscriptions_userId_idx` ON `subscriptions` (`userId`);--> statement-breakpoint +CREATE INDEX `subscriptions_stripeCustomerId_idx` ON `subscriptions` (`stripeCustomerId`);
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0058_snapshot.json b/packages/db/drizzle/meta/0058_snapshot.json new file mode 100644 index 00000000..dce44f02 --- /dev/null +++ b/packages/db/drizzle/meta/0058_snapshot.json @@ -0,0 +1,2338 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "67200406-29ea-4aed-9d67-87b4f3d308f7", + "prevId": "b5e79604-adc2-4ad2-b2e2-96a871ec8f01", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentAssetId": { + "name": "contentAssetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invites_token_unique": { + "name": "invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invites_invitedBy_user_id_fk": { + "name": "invites_invitedBy_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passwordResetToken_token_unique": { + "name": "passwordResetToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "passwordResetTokens_userId_idx": { + "name": "passwordResetTokens_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_userId_user_id_fk": { + "name": "passwordResetToken_userId_user_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_userId_idx": { + "name": "subscriptions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "subscriptions_stripeCustomerId_idx": { + "name": "subscriptions_stripeCustomerId_idx", + "columns": [ + "stripeCustomerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + }, + "archiveDisplayBehaviour": { + "name": "archiveDisplayBehaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'show'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + } + }, + "indexes": {}, + "foreignKeys": { + "userSettings_userId_user_id_fk": { + "name": "userSettings_userId_user_id_fk", + "tableFrom": "userSettings", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "bookmarkQuota": { + "name": "bookmarkQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageQuota": { + "name": "storageQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d1fb1769..705fc1f4 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1752314617600, "tag": "0057_salty_carmella_unuscione", "breakpoints": true + }, + { + "idx": 58, + "version": "6", + "when": 1752436258865, + "tag": "0058_add_subscription", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 79cf2def..6dacdec6 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -584,6 +584,51 @@ export const invites = sqliteTable("invites", { .references(() => users.id, { onDelete: "cascade" }), }); +export const subscriptions = sqliteTable( + "subscriptions", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }) + .unique(), + stripeCustomerId: text("stripeCustomerId").notNull(), + stripeSubscriptionId: text("stripeSubscriptionId"), + status: text("status", { + enum: [ + "active", + "canceled", + "past_due", + "unpaid", + "incomplete", + "trialing", + "incomplete_expired", + "paused", + ], + }).notNull(), + tier: text("tier", { + enum: ["free", "paid"], + }) + .notNull() + .default("free"), + priceId: text("priceId"), + cancelAtPeriodEnd: integer("cancelAtPeriodEnd", { + mode: "boolean", + }).default(false), + startDate: integer("startDate", { mode: "timestamp" }), + endDate: integer("endDate", { mode: "timestamp" }), + createdAt: createdAtField(), + modifiedAt: modifiedAtField(), + }, + (s) => [ + index("subscriptions_userId_idx").on(s.userId), + index("subscriptions_stripeCustomerId_idx").on(s.stripeCustomerId), + ], +); + // Relations export const userRelations = relations(users, ({ many, one }) => ({ @@ -596,6 +641,7 @@ export const userRelations = relations(users, ({ many, one }) => ({ fields: [users.id], references: [userSettings.userId], }), + subscription: one(subscriptions), })); export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ @@ -745,6 +791,13 @@ export const invitesRelations = relations(invites, ({ one }) => ({ }), })); +export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ + user: one(users, { + fields: [subscriptions.userId], + references: [users.id], + }), +})); + export const passwordResetTokensRelations = relations( passwordResetTokens, ({ one }) => ({ diff --git a/packages/shared/config.ts b/packages/shared/config.ts index ed17bb90..634bf564 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -117,6 +117,17 @@ const allEnv = z.object({ // Rate limiting configuration RATE_LIMITING_ENABLED: stringBool("false"), + // Stripe configuration + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_PUBLISHABLE_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + STRIPE_PRICE_ID: z.string().optional(), + + FREE_QUOTA_BOOKMARK_LIMIT: z.coerce.number().optional(), + FREE_QUOTA_ASSET_SIZE_BYTES: z.coerce.number().optional(), + PAID_QUOTA_BOOKMARK_LIMIT: z.coerce.number().optional(), + PAID_QUOTA_ASSET_SIZE_BYTES: z.coerce.number().optional(), + // Proxy configuration HTTP_PROXY: z.string().optional(), HTTPS_PROXY: z.string().optional(), @@ -267,6 +278,23 @@ const serverConfigSchema = allEnv rateLimiting: { enabled: val.RATE_LIMITING_ENABLED, }, + stripe: { + secretKey: val.STRIPE_SECRET_KEY, + publishableKey: val.STRIPE_PUBLISHABLE_KEY, + webhookSecret: val.STRIPE_WEBHOOK_SECRET, + priceId: val.STRIPE_PRICE_ID, + isConfigured: !!val.STRIPE_SECRET_KEY && !!val.STRIPE_PUBLISHABLE_KEY, + }, + quotas: { + free: { + bookmarkLimit: val.FREE_QUOTA_BOOKMARK_LIMIT, + assetSizeBytes: val.FREE_QUOTA_ASSET_SIZE_BYTES, + }, + paid: { + bookmarkLimit: val.PAID_QUOTA_BOOKMARK_LIMIT, + assetSizeBytes: val.PAID_QUOTA_ASSET_SIZE_BYTES, + }, + }, }; }) .refine( diff --git a/packages/trpc/package.json b/packages/trpc/package.json index aa438dbe..df359f5d 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -21,6 +21,7 @@ "drizzle-orm": "^0.44.2", "nodemailer": "^7.0.4", "prom-client": "^15.1.3", + "stripe": "^18.3.0", "superjson": "^2.2.1", "tiny-invariant": "^1.3.3", "zod": "^3.24.2" diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 54335da3..651c8d88 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -10,6 +10,7 @@ import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; import { publicBookmarks } from "./publicBookmarks"; import { rulesAppRouter } from "./rules"; +import { subscriptionsRouter } from "./subscriptions"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; import { webhooksAppRouter } from "./webhooks"; @@ -29,6 +30,7 @@ export const appRouter = router({ rules: rulesAppRouter, invites: invitesAppRouter, publicBookmarks: publicBookmarks, + subscriptions: subscriptionsRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/subscriptions.test.ts b/packages/trpc/routers/subscriptions.test.ts new file mode 100644 index 00000000..b077c067 --- /dev/null +++ b/packages/trpc/routers/subscriptions.test.ts @@ -0,0 +1,881 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { assets, AssetTypes, subscriptions, users } from "@karakeep/db/schema"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach, getApiCaller } from "../testUtils"; + +// Mock Stripe using vi.hoisted to ensure it's available during module initialization +const mockStripeInstance = vi.hoisted(() => ({ + customers: { + create: vi.fn(), + }, + checkout: { + sessions: { + create: vi.fn(), + }, + }, + billingPortal: { + sessions: { + create: vi.fn(), + }, + }, + subscriptions: { + update: vi.fn(), + list: vi.fn(), + }, + webhooks: { + constructEvent: vi.fn(), + }, +})); + +vi.mock("stripe", () => { + return { + default: vi.fn(() => mockStripeInstance), + }; +}); + +// Mock server config with Stripe settings +vi.mock("@karakeep/shared/config", async (original) => { + const mod = (await original()) as typeof import("@karakeep/shared/config"); + return { + ...mod, + default: { + ...mod.default, + stripe: { + secretKey: "sk_test_123", + priceId: "price_123", + webhookSecret: "whsec_123", + isConfigured: true, + }, + publicUrl: "https://test.karakeep.com", + quotas: { + free: { + bookmarkLimit: 100, + assetSizeBytes: 1000000, // 1MB + }, + paid: { + bookmarkLimit: null, + assetSizeBytes: null, + }, + }, + }, + }; +}); + +beforeEach<CustomTestContext>(defaultBeforeEach(false)); + +describe("Subscription Routes", () => { + let mockCustomersCreate: ReturnType<typeof vi.fn>; + let mockCheckoutSessionsCreate: ReturnType<typeof vi.fn>; + let mockBillingPortalSessionsCreate: ReturnType<typeof vi.fn>; + let mockWebhooksConstructEvent: ReturnType<typeof vi.fn>; + let mockSubscriptionsList: ReturnType<typeof vi.fn>; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up mock functions using the global mock instance + mockCustomersCreate = mockStripeInstance.customers.create; + mockCheckoutSessionsCreate = mockStripeInstance.checkout.sessions.create; + mockBillingPortalSessionsCreate = + mockStripeInstance.billingPortal.sessions.create; + mockWebhooksConstructEvent = mockStripeInstance.webhooks.constructEvent; + mockSubscriptionsList = mockStripeInstance.subscriptions.list; + }); + + describe("getSubscriptionStatus", () => { + test<CustomTestContext>("returns free tier when no subscription exists", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + const status = await caller.subscriptions.getSubscriptionStatus(); + + expect(status).toEqual({ + tier: "free", + status: null, + startDate: null, + endDate: null, + hasActiveSubscription: false, + cancelAtPeriodEnd: false, + }); + }); + + test<CustomTestContext>("returns subscription data when subscription exists", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + const startDate = new Date("2024-01-01"); + const endDate = new Date("2024-02-01"); + + // Create subscription record + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + startDate, + endDate, + cancelAtPeriodEnd: true, + }); + + const status = await caller.subscriptions.getSubscriptionStatus(); + + expect(status).toEqual({ + tier: "paid", + status: "active", + startDate, + endDate, + hasActiveSubscription: true, + cancelAtPeriodEnd: true, + }); + }); + }); + + describe("createCheckoutSession", () => { + test<CustomTestContext>("creates checkout session for new customer", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + mockCustomersCreate.mockResolvedValue({ + id: "cus_new123", + }); + + mockCheckoutSessionsCreate.mockResolvedValue({ + id: "cs_123", + url: "https://checkout.stripe.com/pay/cs_123", + }); + + const result = await caller.subscriptions.createCheckoutSession(); + + expect(result).toEqual({ + sessionId: "cs_123", + url: "https://checkout.stripe.com/pay/cs_123", + }); + + expect(mockCustomersCreate).toHaveBeenCalledWith({ + email: "test@test.com", + metadata: { + userId: user.id, + }, + }); + + expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith({ + customer: "cus_new123", + payment_method_types: ["card"], + line_items: [ + { + price: "price_123", + quantity: 1, + }, + ], + mode: "subscription", + success_url: + "https://test.karakeep.com/settings/subscription?success=true", + cancel_url: + "https://test.karakeep.com/settings/subscription?canceled=true", + metadata: { + userId: user.id, + }, + automatic_tax: { + enabled: true, + }, + customer_update: { + address: "auto", + }, + }); + }); + + test<CustomTestContext>("throws error if user already has active subscription", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + await expect( + caller.subscriptions.createCheckoutSession(), + ).rejects.toThrow(/User already has an active subscription/); + }); + }); + + describe("createPortalSession", () => { + test<CustomTestContext>("creates portal session for user with subscription", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + mockBillingPortalSessionsCreate.mockResolvedValue({ + url: "https://billing.stripe.com/session/123", + }); + + const result = await caller.subscriptions.createPortalSession(); + + expect(result).toEqual({ + url: "https://billing.stripe.com/session/123", + }); + + expect(mockBillingPortalSessionsCreate).toHaveBeenCalledWith({ + customer: "cus_123", + return_url: "https://test.karakeep.com/settings/subscription", + }); + }); + + test<CustomTestContext>("throws error if user has no subscription", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + await expect(caller.subscriptions.createPortalSession()).rejects.toThrow( + /No Stripe customer found/, + ); + }); + }); + + describe("getQuotaUsage", () => { + test<CustomTestContext>("returns quota usage for user with no data", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + const usage = await caller.subscriptions.getQuotaUsage(); + + expect(usage).toEqual({ + bookmarks: { + used: 0, + quota: 100, + unlimited: false, + }, + storage: { + used: 0, + quota: 1000000, + unlimited: false, + }, + }); + }); + + test<CustomTestContext>("returns quota usage with bookmarks and assets", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + const caller = getApiCaller(db, user.id); + + // Set user quotas + await db + .update(users) + .set({ + bookmarkQuota: 100, + storageQuota: 1000000, // 1MB + }) + .where(eq(users.id, user.id)); + + // Create test bookmarks + const bookmark1 = await caller.bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + const bookmark2 = await caller.bookmarks.createBookmark({ + text: "Test note", + type: BookmarkTypes.TEXT, + }); + + // Create test assets + await db.insert(assets).values([ + { + id: "asset1", + assetType: AssetTypes.LINK_SCREENSHOT, + size: 50000, // 50KB + contentType: "image/png", + bookmarkId: bookmark1.id, + userId: user.id, + }, + { + id: "asset2", + assetType: AssetTypes.LINK_BANNER_IMAGE, + size: 75000, // 75KB + contentType: "image/jpeg", + bookmarkId: bookmark2.id, + userId: user.id, + }, + ]); + + const usage = await caller.subscriptions.getQuotaUsage(); + + expect(usage).toEqual({ + bookmarks: { + used: 2, + quota: 100, + unlimited: false, + }, + storage: { + used: 125000, // 50KB + 75KB + quota: 1000000, + unlimited: false, + }, + }); + }); + }); + + describe("handleWebhook", () => { + test<CustomTestContext>("handles customer.subscription.created event", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Create existing subscription record + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + status: "unpaid", + tier: "free", + }); + + const mockEvent = { + type: "customer.subscription.created", + data: { + object: { + id: "sub_123", + customer: "cus_123", + status: "active", + current_period_start: 1640995200, // 2022-01-01 + current_period_end: 1643673600, // 2022-02-01 + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response + mockSubscriptionsList.mockResolvedValue({ + data: [ + { + id: "sub_123", + status: "active", + cancel_at_period_end: false, + items: { + data: [ + { + price: { id: "price_123" }, + current_period_start: 1640995200, + current_period_end: 1643673600, + }, + ], + }, + }, + ], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + expect(result).toEqual({ received: true }); + + // Verify subscription was updated + const subscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, user.id), + }); + + expect(subscription).toBeTruthy(); + expect(subscription?.stripeCustomerId).toBe("cus_123"); + expect(subscription?.stripeSubscriptionId).toBe("sub_123"); + expect(subscription?.status).toBe("active"); + expect(subscription?.tier).toBe("paid"); + }); + + test<CustomTestContext>("handles customer.subscription.updated event", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Create existing subscription + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + const mockEvent = { + type: "customer.subscription.updated", + data: { + object: { + id: "sub_123", + customer: "cus_123", + status: "past_due", + current_period_start: 1640995200, + current_period_end: 1643673600, + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response + mockSubscriptionsList.mockResolvedValue({ + data: [ + { + id: "sub_123", + status: "past_due", + cancel_at_period_end: false, + items: { + data: [ + { + price: { id: "price_123" }, + current_period_start: 1640995200, + current_period_end: 1643673600, + }, + ], + }, + }, + ], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + expect(result).toEqual({ received: true }); + + // Verify subscription was updated + const subscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, user.id), + }); + + expect(subscription?.status).toBe("past_due"); + expect(subscription?.tier).toBe("free"); // past_due status should set tier to free + }); + + test<CustomTestContext>("handles customer.subscription.deleted event", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Create existing subscription + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + const mockEvent = { + type: "customer.subscription.deleted", + data: { + object: { + id: "sub_123", + customer: "cus_123", + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response for deleted subscription (empty list) + mockSubscriptionsList.mockResolvedValue({ + data: [], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + expect(result).toEqual({ received: true }); + + // Verify subscription was updated to canceled state + const subscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, user.id), + }); + + expect(subscription).toBeTruthy(); + expect(subscription?.status).toBe("canceled"); + expect(subscription?.tier).toBe("free"); + expect(subscription?.stripeSubscriptionId).toBeNull(); + expect(subscription?.priceId).toBeNull(); + expect(subscription?.cancelAtPeriodEnd).toBe(false); + expect(subscription?.startDate).toBeNull(); + expect(subscription?.endDate).toBeNull(); + }); + + test<CustomTestContext>("handles unknown webhook event type", async ({ + unauthedAPICaller, + }) => { + const mockEvent = { + type: "unknown.event.type", + data: { + object: {}, + }, + }; + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + const result = await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + expect(result).toEqual({ received: true }); + }); + + test<CustomTestContext>("handles invalid webhook signature", async ({ + unauthedAPICaller, + }) => { + mockWebhooksConstructEvent.mockImplementation(() => { + throw new Error("Invalid signature"); + }); + + await expect( + unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "invalid-signature", + }), + ).rejects.toThrow(/Invalid signature/); + }); + }); + + describe("quota updates on tier changes", () => { + test<CustomTestContext>("updates quotas to paid limits on tier promotion", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Set initial free tier quotas + await db + .update(users) + .set({ + bookmarkQuota: 100, + storageQuota: 1000000, // 1MB + }) + .where(eq(users.id, user.id)); + + // Create subscription record + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + status: "unpaid", + tier: "free", + }); + + const mockEvent = { + type: "customer.subscription.created", + data: { + object: { + id: "sub_123", + customer: "cus_123", + status: "active", + current_period_start: 1640995200, + current_period_end: 1643673600, + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response + mockSubscriptionsList.mockResolvedValue({ + data: [ + { + id: "sub_123", + status: "active", + cancel_at_period_end: false, + items: { + data: [ + { + price: { id: "price_123" }, + current_period_start: 1640995200, + current_period_end: 1643673600, + }, + ], + }, + }, + ], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + // Verify user quotas were updated to paid limits + const updatedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + expect(updatedUser?.bookmarkQuota).toBeNull(); // unlimited for paid + expect(updatedUser?.storageQuota).toBeNull(); // unlimited for paid + }); + + test<CustomTestContext>("updates quotas to free limits on tier demotion", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Set initial paid tier quotas (unlimited) + await db + .update(users) + .set({ + bookmarkQuota: null, + storageQuota: null, + }) + .where(eq(users.id, user.id)); + + // Create active subscription + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + const mockEvent = { + type: "customer.subscription.updated", + data: { + object: { + id: "sub_123", + customer: "cus_123", + status: "past_due", + current_period_start: 1640995200, + current_period_end: 1643673600, + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response for past_due status + mockSubscriptionsList.mockResolvedValue({ + data: [ + { + id: "sub_123", + status: "past_due", + cancel_at_period_end: false, + items: { + data: [ + { + price: { id: "price_123" }, + current_period_start: 1640995200, + current_period_end: 1643673600, + }, + ], + }, + }, + ], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + // Verify user quotas were updated to free limits + const updatedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + expect(updatedUser?.bookmarkQuota).toBe(100); // free tier limit + expect(updatedUser?.storageQuota).toBe(1000000); // free tier limit (1MB) + }); + + test<CustomTestContext>("updates quotas to free limits on subscription cancellation", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Set initial paid tier quotas (unlimited) + await db + .update(users) + .set({ + bookmarkQuota: null, + storageQuota: null, + }) + .where(eq(users.id, user.id)); + + // Create active subscription + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + const mockEvent = { + type: "customer.subscription.deleted", + data: { + object: { + id: "sub_123", + customer: "cus_123", + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response for deleted subscription (empty list) + mockSubscriptionsList.mockResolvedValue({ + data: [], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + // Verify user quotas were updated to free limits + const updatedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + expect(updatedUser?.bookmarkQuota).toBe(100); // free tier limit + expect(updatedUser?.storageQuota).toBe(1000000); // free tier limit (1MB) + }); + }); +}); diff --git a/packages/trpc/routers/subscriptions.ts b/packages/trpc/routers/subscriptions.ts new file mode 100644 index 00000000..4915a225 --- /dev/null +++ b/packages/trpc/routers/subscriptions.ts @@ -0,0 +1,427 @@ +// Thanks to @t3dotgg for the recommendations (https://github.com/t3dotgg/stripe-recommendations)! + +import { TRPCError } from "@trpc/server"; +import { count, eq, sum } from "drizzle-orm"; +import Stripe from "stripe"; +import { z } from "zod"; + +import { assets, bookmarks, subscriptions, users } from "@karakeep/db/schema"; +import serverConfig from "@karakeep/shared/config"; + +import { authedProcedure, Context, publicProcedure, router } from "../index"; + +const stripe = serverConfig.stripe.secretKey + ? new Stripe(serverConfig.stripe.secretKey, { + apiVersion: "2025-06-30.basil", + }) + : null; + +function requireStripeConfig() { + if (!stripe || !serverConfig.stripe.priceId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + return { stripe, priceId: serverConfig.stripe.priceId }; +} + +// Taken from https://github.com/t3dotgg/stripe-recommendations + +const allowedEvents: Stripe.Event.Type[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed", + "customer.subscription.pending_update_applied", + "customer.subscription.pending_update_expired", + "customer.subscription.trial_will_end", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "invoice.upcoming", + "invoice.marked_uncollectible", + "invoice.payment_succeeded", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +]; + +async function syncStripeDataToDatabase(customerId: string, db: Context["db"]) { + if (!stripe) { + throw new Error("Stripe is not configured"); + } + + const existingSubscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.stripeCustomerId, customerId), + }); + + if (!existingSubscription) { + console.error( + `ERROR: No subscription found for customer with this ID ${customerId}`, + ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No subscription found for customer with this ID", + }); + } + + try { + const subscriptionsList = await stripe.subscriptions.list({ + customer: customerId, + limit: 1, + status: "all", + }); + + if (subscriptionsList.data.length === 0) { + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set({ + status: "canceled", + tier: "free", + stripeSubscriptionId: null, + priceId: null, + cancelAtPeriodEnd: false, + startDate: null, + endDate: null, + }) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + // Update user quotas to free tier limits + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + }); + return; + } + + const subscription = subscriptionsList.data[0]; + const subscriptionItem = subscription.items.data[0]; + + const subData = { + stripeSubscriptionId: subscription.id, + status: subscription.status, + tier: + subscription.status === "active" || subscription.status === "trialing" + ? ("paid" as const) + : ("free" as const), + priceId: subscription.items.data[0]?.price.id || null, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + startDate: subscriptionItem.current_period_start + ? new Date(subscriptionItem.current_period_start * 1000) + : null, + endDate: subscriptionItem.current_period_end + ? new Date(subscriptionItem.current_period_end * 1000) + : null, + }; + + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set(subData) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + if (subData.status === "active" || subData.status === "trialing") { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.paid.bookmarkLimit, + storageQuota: serverConfig.quotas.paid.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } else { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } + }); + + return subData; + } catch (error) { + console.error("Error syncing Stripe data:", error); + throw error; + } +} + +async function processEvent(event: Stripe.Event, db: Context["db"]) { + if (!allowedEvents.includes(event.type)) { + return; + } + + const { customer: customerId } = event.data.object as { + customer: string; + }; + + if (typeof customerId !== "string") { + throw new Error( + `[STRIPE HOOK] Customer ID isn't string. Event type: ${event.type}`, + ); + } + + return await syncStripeDataToDatabase(customerId, db); +} + +export const subscriptionsRouter = router({ + getSubscriptionStatus: authedProcedure.query(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription) { + return { + tier: "free" as const, + status: null, + startDate: null, + endDate: null, + hasActiveSubscription: false, + cancelAtPeriodEnd: false, + }; + } + + return { + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + hasActiveSubscription: + subscription.status === "active" || subscription.status === "trialing", + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || false, + }; + }), + + getSubscriptionPrice: authedProcedure.query(async () => { + if (!stripe) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + + const { priceId } = requireStripeConfig(); + + const price = await stripe.prices.retrieve(priceId); + + return { + priceId: price.id, + currency: price.currency, + amount: price.unit_amount, + }; + }), + + createCheckoutSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe, priceId } = requireStripeConfig(); + + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + email: true, + }, + with: { + subscription: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const existingSubscription = user.subscription; + + if (existingSubscription?.status === "active") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User already has an active subscription", + }); + } + + let customerId = existingSubscription?.stripeCustomerId; + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: ctx.user.id, + }, + }); + customerId = customer.id; + + if (!existingSubscription) { + await ctx.db.insert(subscriptions).values({ + userId: ctx.user.id, + stripeCustomerId: customerId, + status: "unpaid", + }); + } else { + await ctx.db + .update(subscriptions) + .set({ stripeCustomerId: customerId }) + .where(eq(subscriptions.userId, ctx.user.id)); + } + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: `${serverConfig.publicUrl}/settings/subscription?success=true`, + cancel_url: `${serverConfig.publicUrl}/settings/subscription?canceled=true`, + metadata: { + userId: ctx.user.id, + }, + automatic_tax: { + enabled: true, + }, + customer_update: { + address: "auto", + }, + }); + + return { + sessionId: session.id, + url: session.url, + }; + }), + + syncWithStripe: authedProcedure.mutation(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + // No Stripe customer found for user + return { success: true }; + } + + await syncStripeDataToDatabase(subscription.stripeCustomerId, ctx.db); + return { success: true }; + }), + + createPortalSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe } = requireStripeConfig(); + + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No Stripe customer found", + }); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripeCustomerId, + return_url: `${serverConfig.publicUrl}/settings/subscription`, + }); + + return { + url: session.url, + }; + }), + + getQuotaUsage: authedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Get current bookmark count + const [{ bookmarkCount }] = await ctx.db + .select({ bookmarkCount: count() }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)); + + // Get current storage usage + const [{ storageUsed }] = await ctx.db + .select({ storageUsed: sum(assets.size) }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)); + + return { + bookmarks: { + used: bookmarkCount, + quota: user.bookmarkQuota, + unlimited: user.bookmarkQuota === null, + }, + storage: { + used: Number(storageUsed) || 0, + quota: user.storageQuota, + unlimited: user.storageQuota === null, + }, + }; + }), + + handleWebhook: publicProcedure + .input( + z.object({ + body: z.string(), + signature: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (!stripe || !serverConfig.stripe.webhookSecret) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured", + }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + input.body, + input.signature, + serverConfig.stripe.webhookSecret, + ); + } catch (err) { + console.error("Webhook signature verification failed:", err); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid signature", + }); + } + + try { + await processEvent(event, ctx.db); + return { received: true }; + } catch (error) { + console.error("Error processing webhook:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); + } + }), +}); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index 4531875c..97784901 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -124,6 +124,8 @@ export async function createUserRaw( salt: input.salt, role: userRole, emailVerified: input.emailVerified, + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, }) .returning({ id: users.id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 796b8a2d..a8500335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1219,6 +1219,9 @@ importers: prom-client: specifier: ^15.1.3 version: 15.1.3 + stripe: + specifier: ^18.3.0 + version: 18.3.0(@types/node@22.15.30) superjson: specifier: ^2.2.1 version: 2.2.1 @@ -13386,6 +13389,15 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + stripe@18.3.0: + resolution: {integrity: sha512-FkxrTUUcWB4CVN2yzgsfF/YHD6WgYHduaa7VmokCy5TLCgl5UNJkwortxcedrxSavQ8Qfa4Ir4JxcbIYiBsyLg==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -15543,7 +15555,7 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/types': 7.28.1 transitivePeerDependencies: - supports-color @@ -30915,6 +30927,12 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@18.3.0(@types/node@22.15.30): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 22.15.30 + strnum@1.1.2: {} structured-headers@0.4.1: {} |
