aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/settings/layout.tsx132
-rw-r--r--apps/web/app/settings/subscription/page.tsx18
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx233
-rw-r--r--apps/web/components/subscription/QuotaProgress.tsx177
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json25
-rw-r--r--packages/api/index.ts4
-rw-r--r--packages/api/routes/webhooks.ts44
-rw-r--r--packages/db/drizzle/0058_add_subscription.sql19
-rw-r--r--packages/db/drizzle/meta/0058_snapshot.json2338
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts53
-rw-r--r--packages/shared/config.ts28
-rw-r--r--packages/trpc/package.json1
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/subscriptions.test.ts881
-rw-r--r--packages/trpc/routers/subscriptions.ts427
-rw-r--r--packages/trpc/routers/users.ts2
-rw-r--r--pnpm-lock.yaml20
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: {}