From 0c7f37fd122e81e41af377fe038f4225ad57262e Mon Sep 17 00:00:00 2001 From: Mannu Date: Wed, 27 May 2026 23:21:11 +0530 Subject: [PATCH] feat(quota): storage quota + family-member limits for free tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature A — Storage quota (1 GiB per family): - src/lib/quota.ts: enforcement library with pure functions (fully unit-tested) and DB-bound helpers; isPaidFamily() is the single payment abstraction gate - src/lib/format-bytes.ts: extracted formatBytes() — safe for client imports - POST /api/upload: quota check before presigned URL issuance (HTTP 402 + reason code) - POST /api/memories/[id]/confirm: HeadObject reconciles actual R2 size; deletes over-quota objects and marks row failed rather than silently exceeding limit - GET /api/storage-usage: storage info endpoint for UI meter - src/components/StorageMeter.tsx: meter bar + StorageQuotaBanner + MemberLimitBanner - memories/page.tsx: quota banner, FAB disabled (⊘) when exceeded, compact meter in header - settings/page.tsx: always-visible StorageMeter + MemberLimitBanner in invite section Feature B — Member limit (2 per family, free tier): - invites/route.ts: replaced ad-hoc inline check with checkMemberLimit() from quota lib Structured 403 response: { reason, currentCount, limit } - Freeze rule: paid→free downgrade leaves all members intact; only new invites blocked Migration: - drizzle/0007_subscription_status.sql: ADD COLUMN subscription_status varchar(20) - debug-migration/route.ts: step added for hot-apply without full redeploy - src/db/schema/family.ts: subscriptionStatus field added to Drizzle schema Tests: 44 unit tests in src/__tests__/quota.test.ts, all passing - Pure function tests (no DB): isPaidFamily, wouldExceedQuota, isAtMemberLimit, formatBytes - DB-bound tests (mocked @/db): getFamilyStorageUsage, checkStorageQuota, checkMemberLimit, getStorageInfo, tenant isolation Co-Authored-By: Claude Sonnet 4.6 --- drizzle/0007_subscription_status.sql | 6 + drizzle/meta/_journal.json | 7 + src/__tests__/quota.test.ts | 391 +++++++++++++++++++++ src/app/(app)/memories/page.tsx | 104 +++++- src/app/(app)/settings/page.tsx | 18 +- src/app/api/debug-migration/route.ts | 2 + src/app/api/invites/route.ts | 30 +- src/app/api/memories/[id]/confirm/route.ts | 76 +++- src/app/api/storage-usage/route.ts | 24 ++ src/app/api/upload/route.ts | 19 + src/components/StorageMeter.tsx | 156 ++++++++ src/db/schema/family.ts | 6 +- src/lib/format-bytes.ts | 7 + src/lib/quota.ts | 243 +++++++++++++ src/middleware.ts | 1 + vitest.config.ts | 16 + 16 files changed, 1063 insertions(+), 43 deletions(-) create mode 100644 drizzle/0007_subscription_status.sql create mode 100644 src/__tests__/quota.test.ts create mode 100644 src/app/api/storage-usage/route.ts create mode 100644 src/components/StorageMeter.tsx create mode 100644 src/lib/format-bytes.ts create mode 100644 src/lib/quota.ts create mode 100644 vitest.config.ts diff --git a/drizzle/0007_subscription_status.sql b/drizzle/0007_subscription_status.sql new file mode 100644 index 0000000..25d1a33 --- /dev/null +++ b/drizzle/0007_subscription_status.sql @@ -0,0 +1,6 @@ +-- Add subscription_status to families for payment-provider abstraction. +-- The actual payment integration (Razorpay TBD) will only need to flip +-- families.tier and set subscription_status; all quota/member enforcement +-- reads from these two fields via isPaidFamily() in src/lib/quota.ts. +ALTER TABLE families + ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b452467..a53026b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1748394000000, "tag": "0006_family_invites_missing_cols", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1748480400000, + "tag": "0007_subscription_status", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/__tests__/quota.test.ts b/src/__tests__/quota.test.ts new file mode 100644 index 0000000..9d74979 --- /dev/null +++ b/src/__tests__/quota.test.ts @@ -0,0 +1,391 @@ +/** + * quota.test.ts — Unit tests for storage quota and member limit enforcement. + * + * Pure-function tests run without any DB. DB-bound function tests mock the + * @/db module so no real database connection is required. + * + * Run: pnpm test + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + isPaidFamily, + wouldExceedQuota, + isAtMemberLimit, + formatBytes, + FREE_STORAGE_LIMIT_BYTES, + FREE_MEMBER_LIMIT, + STORAGE_WARN_THRESHOLD, +} from "@/lib/quota"; + +// ─── Pure function tests (no DB, no mocks needed) ───────────────────────────── + +describe("isPaidFamily", () => { + it("returns false for 'free'", () => { + expect(isPaidFamily("free")).toBe(false); + }); + + it("returns false for null", () => { + expect(isPaidFamily(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isPaidFamily(undefined)).toBe(false); + }); + + it("returns true for 'pro'", () => { + expect(isPaidFamily("pro")).toBe(true); + }); + + it("returns true for any non-free string", () => { + expect(isPaidFamily("paid")).toBe(true); + expect(isPaidFamily("enterprise")).toBe(true); + }); +}); + +describe("wouldExceedQuota", () => { + const LIMIT = FREE_STORAGE_LIMIT_BYTES; // 1 GiB + + it("allows upload when usage + declared <= limit", () => { + expect(wouldExceedQuota(900_000_000, 100_000_000, LIMIT)).toBe(false); // exactly 1 GiB + }); + + it("blocks upload when usage + declared > limit", () => { + expect(wouldExceedQuota(900_000_000, 200_000_000, LIMIT)).toBe(true); // over by 100 MB + }); + + it("blocks when already at limit with any positive declared size", () => { + expect(wouldExceedQuota(LIMIT, 1, LIMIT)).toBe(true); + }); + + it("allows zero-byte declared upload when under limit", () => { + expect(wouldExceedQuota(500_000_000, 0, LIMIT)).toBe(false); + }); + + it("blocks zero-byte declared upload when already over limit (post-downgrade)", () => { + // Family was on paid, accumulated > 1 GiB, then downgraded. + // Even a 0-byte declared should report over quota. + expect(wouldExceedQuota(LIMIT + 1, 0, LIMIT)).toBe(true); + }); + + it("correctly handles large files within quota", () => { + // 950 MB used, 50 MB upload → 1000 MB total, under 1 GiB (1024 MB) + expect(wouldExceedQuota(950_000_000, 50_000_000, LIMIT)).toBe(false); + }); + + it("correctly handles combined memories + attachments sum", () => { + // SUM from memories (600 MB) + attachments (200 MB) = 800 MB, + 300 MB upload → over + const memoriesBytes = 600_000_000; + const attachmentsBytes = 200_000_000; + const combined = memoriesBytes + attachmentsBytes; + expect(wouldExceedQuota(combined, 300_000_000, LIMIT)).toBe(true); + }); +}); + +describe("isAtMemberLimit", () => { + it("blocks at limit on free tier", () => { + expect(isAtMemberLimit(2, FREE_MEMBER_LIMIT, false)).toBe(true); + }); + + it("blocks above limit on free tier", () => { + expect(isAtMemberLimit(5, FREE_MEMBER_LIMIT, false)).toBe(true); + }); + + it("allows below limit on free tier", () => { + expect(isAtMemberLimit(1, FREE_MEMBER_LIMIT, false)).toBe(false); + }); + + it("always allows on paid tier regardless of count", () => { + expect(isAtMemberLimit(100, FREE_MEMBER_LIMIT, true)).toBe(false); + }); + + it("freeze rule: paid → free downgrade with 4 members blocks new invites, not existing members", () => { + // After downgrade, all 4 members still exist (roles unchanged — enforced elsewhere). + // The limit check sees count=4, limit=2, paid=false → blocked for new invites. + expect(isAtMemberLimit(4, 2, false)).toBe(true); + // But if tier were still paid, it would be allowed: + expect(isAtMemberLimit(4, 2, true)).toBe(false); + }); +}); + +describe("formatBytes", () => { + it("formats bytes", () => { + expect(formatBytes(512)).toBe("512 B"); + }); + + it("formats kilobytes", () => { + expect(formatBytes(1024)).toBe("1.0 KB"); + }); + + it("formats megabytes", () => { + expect(formatBytes(10 * 1024 * 1024)).toBe("10.0 MB"); + }); + + it("formats gigabytes", () => { + expect(formatBytes(1_073_741_824)).toBe("1.00 GB"); + }); + + it("formats 0 bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + }); +}); + +describe("constants", () => { + it("FREE_STORAGE_LIMIT_BYTES is exactly 1 GiB", () => { + expect(FREE_STORAGE_LIMIT_BYTES).toBe(1_073_741_824); + }); + + it("FREE_MEMBER_LIMIT is 2", () => { + expect(FREE_MEMBER_LIMIT).toBe(2); + }); + + it("STORAGE_WARN_THRESHOLD is 0.8", () => { + expect(STORAGE_WARN_THRESHOLD).toBe(0.8); + }); +}); + +// ─── DB-bound function tests (mocked DB) ────────────────────────────────────── + +// We mock the @/db module so tests don't need a real Postgres connection. +vi.mock("@/db", () => ({ + sql: vi.fn(), +})); + +import { sql } from "@/db"; +import { + getFamilyStorageUsage, + checkStorageQuota, + checkMemberLimit, + getStorageInfo, +} from "@/lib/quota"; + +const mockSql = sql as unknown as ReturnType; + +// Helper: make sql return specific results for tagged template literals. +// The postgres.js `sql` is a tagged template function, not a regular function, +// but vi.fn() captures the call regardless. +function sqlReturns(...results: unknown[][]) { + let callIndex = 0; + mockSql.mockImplementation((..._args: unknown[]) => { + return Promise.resolve(results[callIndex++ % results.length]); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getFamilyStorageUsage", () => { + it("sums memories and attachments correctly", async () => { + // memories: 600 MB, attachments: 200 MB → 800 MB total + sqlReturns( + [{ bytes: "629145600" }], // memories: 600 MB + [{ bytes: "209715200" }] // attachments: 200 MB + ); + const usage = await getFamilyStorageUsage("family-a"); + expect(usage).toBe(629145600 + 209715200); + }); + + it("returns 0 when both tables are empty", async () => { + sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]); + expect(await getFamilyStorageUsage("family-empty")).toBe(0); + }); + + it("tenant isolation: queried with the correct familyId", async () => { + sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]); + await getFamilyStorageUsage("family-a"); + // Both SQL calls should have received family-a in their args + const calls = mockSql.mock.calls; + expect(calls).toHaveLength(2); + // The familyId is passed as a tagged template argument; verify it appears + const allArgs = calls.flatMap((c: unknown[]) => c); + expect(allArgs).toContain("family-a"); + }); + + it("excludes uploading-status memories (counted by WHERE clause in SQL)", async () => { + // The WHERE processing_status != 'uploading' is in the SQL template. + // Here we verify the SUM result reflects only confirmed uploads. + // Simulate: one confirmed memory (100 MB), pending stays excluded via SQL + sqlReturns([{ bytes: "104857600" }], [{ bytes: "0" }]); + const usage = await getFamilyStorageUsage("family-b"); + expect(usage).toBe(104857600); + }); +}); + +describe("checkStorageQuota", () => { + it("allows upload when family is under quota", async () => { + // family: free tier, 500 MB used + sqlReturns( + [{ tier: "free" }], // families query + [{ bytes: "524288000" }], // memories SUM (500 MB) + [{ bytes: "0" }] // attachments SUM + ); + const result = await checkStorageQuota("family-a", 100 * 1024 * 1024); // +100 MB + expect(result.allowed).toBe(true); + }); + + it("blocks upload when declared size would push over quota", async () => { + // family: free tier, 950 MB used; trying to upload 200 MB → would be 1150 MB > 1 GiB + sqlReturns( + [{ tier: "free" }], + [{ bytes: "996147200" }], // 950 MB + [{ bytes: "0" }] + ); + const result = await checkStorageQuota("family-a", 200 * 1024 * 1024); // 200 MB + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe("storage_quota_exceeded"); + expect(result.usedBytes).toBe(996147200); + expect(result.limitBytes).toBe(FREE_STORAGE_LIMIT_BYTES); + } + }); + + it("blocks upload when family is already over quota (post-downgrade)", async () => { + // Paid family accumulated 2 GB, then downgraded to free. + // Even a tiny upload (1 byte) should be blocked. + sqlReturns( + [{ tier: "free" }], + [{ bytes: "2147483648" }], // 2 GB in memories + [{ bytes: "0" }] + ); + const result = await checkStorageQuota("family-a", 1); + expect(result.allowed).toBe(false); + }); + + it("allows upload for paid family regardless of usage", async () => { + sqlReturns( + [{ tier: "pro" }], + [{ bytes: "10737418240" }], // 10 GB (way over free limit) + [{ bytes: "0" }] + ); + const result = await checkStorageQuota("family-paid", 500 * 1024 * 1024); + expect(result.allowed).toBe(true); + }); + + it("blocks upload when declared=0 but family is over quota", async () => { + sqlReturns( + [{ tier: "free" }], + [{ bytes: `${FREE_STORAGE_LIMIT_BYTES + 1}` }], + [{ bytes: "0" }] + ); + const result = await checkStorageQuota("family-a", 0); + expect(result.allowed).toBe(false); + }); +}); + +describe("checkMemberLimit", () => { + it("allows invite when count < limit on free tier", async () => { + sqlReturns([{ count: "1", max_members: 2, tier: "free" }]); + const result = await checkMemberLimit("family-a"); + expect(result.allowed).toBe(true); + if (result.allowed) expect(result.currentCount).toBe(1); + }); + + it("blocks invite when count = limit on free tier", async () => { + sqlReturns([{ count: "2", max_members: 2, tier: "free" }]); + const result = await checkMemberLimit("family-a"); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe("member_limit_reached"); + expect(result.currentCount).toBe(2); + expect(result.limit).toBe(2); + } + }); + + it("blocks invite when count > limit (post-downgrade freeze)", async () => { + // 4 members from paid tier, now free — new invites blocked, existing retained + sqlReturns([{ count: "4", max_members: 2, tier: "free" }]); + const result = await checkMemberLimit("family-downgraded"); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.currentCount).toBe(4); + expect(result.limit).toBe(2); + } + }); + + it("allows invite on paid tier regardless of count", async () => { + sqlReturns([{ count: "50", max_members: 2, tier: "pro" }]); + const result = await checkMemberLimit("family-paid"); + expect(result.allowed).toBe(true); + }); + + it("respects custom max_members from DB (configurable per family)", async () => { + // Admin could set max_members=5 for a special promo plan + sqlReturns([{ count: "4", max_members: 5, tier: "free" }]); + const result = await checkMemberLimit("family-promo"); + expect(result.allowed).toBe(true); + }); +}); + +describe("getStorageInfo", () => { + it("returns approaching=true near 80% usage", async () => { + const nearLimit = Math.floor(FREE_STORAGE_LIMIT_BYTES * 0.85); + sqlReturns( + [{ tier: "free" }], + [{ bytes: String(nearLimit) }], + [{ bytes: "0" }] + ); + const info = await getStorageInfo("family-a"); + expect(info.approaching).toBe(true); + expect(info.exceeded).toBe(false); + }); + + it("returns exceeded=true at 100%+ usage", async () => { + sqlReturns( + [{ tier: "free" }], + [{ bytes: String(FREE_STORAGE_LIMIT_BYTES + 1000) }], + [{ bytes: "0" }] + ); + const info = await getStorageInfo("family-a"); + expect(info.exceeded).toBe(true); + expect(info.approaching).toBe(false); // exceeded takes over + }); + + it("returns approaching=false and exceeded=false for paid family", async () => { + sqlReturns( + [{ tier: "pro" }], + [{ bytes: String(FREE_STORAGE_LIMIT_BYTES * 10) }], + [{ bytes: "0" }] + ); + const info = await getStorageInfo("family-paid"); + expect(info.approaching).toBe(false); + expect(info.exceeded).toBe(false); + expect(info.isPaid).toBe(true); + }); + + it("over-quota family can still derive usage (read path unblocked)", async () => { + // getStorageInfo is the read path for the UI meter — must work even over quota. + sqlReturns( + [{ tier: "free" }], + [{ bytes: "2000000000" }], + [{ bytes: "0" }] + ); + const info = await getStorageInfo("family-over"); + expect(info.usedBytes).toBe(2_000_000_000); + expect(info.exceeded).toBe(true); + // The read itself succeeds — memories can still be read (enforced at route level) + }); +}); + +// ─── RLS / tenant isolation ──────────────────────────────────────────────────── + +describe("tenant isolation", () => { + it("does not mix usage across families", async () => { + // Family A: 900 MB; Family B: 100 MB. Each call should see only its own usage. + const familyAUsage = 943718400; // 900 MB + const familyBUsage = 104857600; // 100 MB + + mockSql + .mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyAUsage) }])) + .mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }])) + .mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyBUsage) }])) + .mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }])); + + const usageA = await getFamilyStorageUsage("family-a"); + const usageB = await getFamilyStorageUsage("family-b"); + + expect(usageA).toBe(familyAUsage); + expect(usageB).toBe(familyBUsage); + expect(usageA).not.toBe(usageB); + }); +}); diff --git a/src/app/(app)/memories/page.tsx b/src/app/(app)/memories/page.tsx index 362fbce..8550393 100644 --- a/src/app/(app)/memories/page.tsx +++ b/src/app/(app)/memories/page.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useFamily } from "@/app/FamilyProvider"; import { Button, ConfirmDialog, Modal } from "@/components/ui"; +import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter"; +import { formatBytes } from "@/lib/format-bytes"; const PRESET_FOLDERS = [ { id: "", label: "All", emoji: "🌟" }, @@ -59,6 +61,10 @@ export default function MemoriesPage() { const [newFolderName, setNewFolderName] = useState(""); const [newFolderEmoji, setNewFolderEmoji] = useState("📁"); + // Quota state — fetched on mount, refreshed after any 402 response + const [quotaExceeded, setQuotaExceeded] = useState(false); + const [quotaBanner, setQuotaBanner] = useState<{ usedBytes: number; limitBytes: number } | null>(null); + const fileRef = useRef(null); const loaderRef = useRef(null); @@ -69,6 +75,20 @@ export default function MemoriesPage() { } catch {} }, []); + // Fetch storage quota on mount so we can disable the FAB before the user + // tries to upload and hits a 402 (better UX than a failed upload). + useEffect(() => { + fetch("/api/storage-usage") + .then(r => r.json()) + .then(d => { + if (d.exceeded) { + setQuotaExceeded(true); + setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }); + } + }) + .catch(() => {}); + }, []); + const allFolders: Folder[] = [...PRESET_FOLDERS, ...customFolders]; const handleCreateFolder = () => { @@ -125,23 +145,37 @@ export default function MemoriesPage() { if (!file || !childId) return; setUploading(true); try { + // Step 1: Get presigned URL (quota gate runs here server-side) const initRes = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, childId, sizeBytes: file.size }), }); - const { key, memoryId, publicUrl, error } = await initRes.json(); - if (error) { alert("Error: " + error); return; } + const initData = await initRes.json(); + if (!initRes.ok) { + if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") { + setQuotaExceeded(true); + setQuotaBanner({ usedBytes: initData.usedBytes, limitBytes: initData.limitBytes }); + } else { + alert("Error: " + (initData.error ?? "Upload failed")); + } + return; + } + + const { key, memoryId, publicUrl } = initData; + + // Step 2: Upload file to R2 via proxy const putParams = new URLSearchParams({ key, contentType: file.type }); const putRes = await fetch(`/api/upload?${putParams}`, { method: "PUT", body: file, headers: { "Content-Type": file.type }, }); if (!putRes.ok) { - const e = await putRes.json().catch(() => ({})) as { error?: string }; - throw new Error(e.error ?? `Upload failed (${putRes.status})`); + const putErr = await putRes.json().catch(() => ({})) as { error?: string }; + throw new Error(putErr.error ?? `Upload failed (${putRes.status})`); } + // Step 3: Assign folder if chosen if (pendingFolder) { await fetch(`/api/memories/${memoryId}`, { method: "PATCH", @@ -150,8 +184,20 @@ export default function MemoriesPage() { }); } - await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" }); + // Step 4: Confirm — server reconciles actual R2 size against quota + const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" }); + if (!confirmRes.ok && confirmRes.status === 402) { + // Actual file size exceeded quota; server deleted the R2 object and + // marked the row failed. Refresh quota state and skip optimistic update. + setQuotaExceeded(true); + fetch("/api/storage-usage") + .then(r => r.json()) + .then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes })) + .catch(() => {}); + return; + } + // Step 5: Optimistic update in UI const optimistic: Memory = { id: memoryId, key, url: publicUrl, thumbnailUrl: null, sizeBytes: file.size, mimeType: file.type, title: null, @@ -160,10 +206,13 @@ export default function MemoriesPage() { processingStatus: "processing", createdAt: new Date().toISOString(), }; setMemories(prev => [optimistic, ...prev]); - } catch (err) { alert("Upload failed: " + err); } - setUploading(false); - setPendingFolder(""); - if (fileRef.current) fileRef.current.value = ""; + } catch (err) { + alert("Upload failed: " + err); + } finally { + setUploading(false); + setPendingFolder(""); + if (fileRef.current) fileRef.current.value = ""; + } }; const handleDelete = async (id: string) => { @@ -203,13 +252,29 @@ export default function MemoriesPage() { return (
{/* Header */} -
-
- ← -

Memories 📸

+
+
+
+ ← +

Memories 📸

+
+ {/* Storage meter — only visible when approaching or exceeded */} +
+ {/* Storage quota banner — shown when upload is blocked */} + {quotaBanner && ( +
+ +
+ )} + {/* Search */}
- {/* Upload FAB */} + {/* Upload FAB — disabled when storage quota is exceeded */}
- +
{/* Folder picker modal */} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index d11683a..a56c14e 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useTheme } from "@/app/ThemeProvider"; import { useFamily } from "@/app/FamilyProvider"; import { Button, Card, Input, Select, Badge } from "@/components/ui"; +import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter"; interface Member { id: string; @@ -40,7 +41,7 @@ export default function SettingsPage() { const [pedPhone, setPedPhone] = useState(""); const [pedSaving, setPedSaving] = useState(false); - // Check if can invite more members + // Check if can invite more members (client-side pre-check; server enforces) const canInvite = tier === "pro" || memberCount < 2; // Family name from provider or fallback @@ -150,6 +151,9 @@ export default function SettingsPage() { if (data.success) { setInviteEmail(""); fetchInvites(); + } else if (data.reason === "member_limit_reached") { + // Trigger re-render of the limit banner with fresh data + fetchMembers(); } else { alert(data.error); } @@ -243,6 +247,12 @@ export default function SettingsPage() { )}
+ {/* Storage usage meter */} +
+
Storage
+ +
+ {/* Manage Children */} Manage Children @@ -270,10 +280,10 @@ export default function SettingsPage() { {inviteOpen && (
- {/* Pro upgrade prompt */} + {/* Member limit banner */} {tier === "free" && !canInvite && ( -
-

Upgrade to Pro for unlimited family members

+
+
)} diff --git a/src/app/api/debug-migration/route.ts b/src/app/api/debug-migration/route.ts index d3da6f2..2fdf5dd 100644 --- a/src/app/api/debug-migration/route.ts +++ b/src/app/api/debug-migration/route.ts @@ -33,6 +33,8 @@ export async function POST(req: Request) { // family_invites missing columns (0006) `ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS display_name text`, `ALTER TABLE family_invites ADD COLUMN IF NOT EXISTS accepted_at timestamp`, + // subscription_status on families (0007) — payment-provider abstraction + `ALTER TABLE families ADD COLUMN IF NOT EXISTS subscription_status varchar(20) DEFAULT NULL`, // circles tables (0003) `CREATE TABLE IF NOT EXISTS circles (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, created_by uuid NOT NULL REFERENCES families(id), created_at timestamptz NOT NULL DEFAULT now())`, `CREATE TABLE IF NOT EXISTS circle_members (circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, family_id uuid NOT NULL REFERENCES families(id), role text NOT NULL DEFAULT 'member', joined_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (circle_id, family_id))`, diff --git a/src/app/api/invites/route.ts b/src/app/api/invites/route.ts index df1e1d4..c8ac781 100644 --- a/src/app/api/invites/route.ts +++ b/src/app/api/invites/route.ts @@ -3,6 +3,7 @@ import { sql } from "@/db"; import { requireFamily } from "@/lib/auth"; import { randomBytes } from "crypto"; import { sendFamilyInviteEmail } from "@/lib/email"; +import { checkMemberLimit } from "@/lib/quota"; // GET - list invites for a family export async function GET(request: Request) { @@ -37,21 +38,20 @@ export async function POST(request: Request) { return NextResponse.json({ error: "email required" }, { status: 400 }); } - // Check member count limit - const memberCheck = await sql.unsafe( - `SELECT f.max_members, f.tier, COUNT(fm.id) as member_count - FROM families f - LEFT JOIN family_members fm ON fm.family_id = f.id - WHERE f.id = $1 - GROUP BY f.id`, - [auth.session!.familyId] - ); - - const family = memberCheck[0]; - const canAdd = family.tier === "pro" || (family.member_count || 0) < (family.max_members || 2); - - if (!canAdd) { - return NextResponse.json({ error: "Upgrade to Pro to add more members" }, { status: 403 }); + // Member limit gate — COUNT(family_members) is source of truth. + // Freeze rule: paid → free downgrade leaves existing members intact; + // only new invites are blocked until family is under limit or upgrades. + const limitCheck = await checkMemberLimit(auth.session!.familyId!); + if (!limitCheck.allowed) { + return NextResponse.json( + { + error: limitCheck.message, + reason: limitCheck.reason, + currentCount: limitCheck.currentCount, + limit: limitCheck.limit, + }, + { status: 403 } + ); } const token = randomBytes(32).toString("hex"); diff --git a/src/app/api/memories/[id]/confirm/route.ts b/src/app/api/memories/[id]/confirm/route.ts index f127fca..2262b04 100644 --- a/src/app/api/memories/[id]/confirm/route.ts +++ b/src/app/api/memories/[id]/confirm/route.ts @@ -3,6 +3,19 @@ import { requireFamily } from "@/lib/auth"; import { sql } from "@/db"; import { generateThumbnail } from "@/lib/media/thumbnail"; import { processMemoryVision } from "@/lib/ai/vision"; +import { S3Client, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { reconcileActualSize } from "@/lib/quota"; + +function makeR2Client() { + const accountId = process.env.R2_ACCOUNT_ID!; + const accessKeyId = process.env.R2_ACCESS_KEY_ID!; + const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY!; + return new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId, secretAccessKey }, + }); +} // POST /api/memories/[id]/confirm — called after client finishes uploading to R2 export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -12,16 +25,71 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const familyId = auth.session!.familyId!; - const rows = await sql`SELECT id, processing_status FROM memories WHERE id = ${id} AND family_id = ${familyId} LIMIT 1`; + const rows = await sql` + SELECT id, r2_key, processing_status, size_bytes + FROM memories + WHERE id = ${id} AND family_id = ${familyId} + LIMIT 1 + `; if (!rows[0]) return NextResponse.json({ error: "Not found" }, { status: 404 }); - // Mark as processing + const { r2_key: r2Key, size_bytes: declaredBytes } = rows[0]; + + // Fetch actual object size from R2 and reconcile against quota. + // This catches cases where the client lied about the file size. + let actualBytes: number | null = null; + try { + const client = makeR2Client(); + const head = await client.send( + new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: r2Key }) + ); + actualBytes = head.ContentLength ?? null; + } catch { + // If HeadObject fails the file didn't land — mark failed and return + await sql`UPDATE memories SET processing_status = 'failed', updated_at = now() WHERE id = ${id}`; + return NextResponse.json({ error: "File not found in storage after upload" }, { status: 422 }); + } + + // Reconcile actual size: updates size_bytes in DB and checks if family is over quota. + // reconcileActualSize() always writes the real byte count regardless of plan. + if (actualBytes !== null) { + const reconcile = await reconcileActualSize(familyId, id, actualBytes); + if (!reconcile.keep) { + // Actual size pushed family over quota — delete the object and fail the upload. + try { + const client = makeR2Client(); + await client.send( + new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: r2Key }) + ); + } catch { + // Best-effort cleanup; log but don't block the error response. + console.error(`[quota] failed to delete over-quota object key=${r2Key}`); + } + await sql`UPDATE memories SET processing_status = 'failed', updated_at = now() WHERE id = ${id}`; + return NextResponse.json( + { error: reconcile.reason, reason: "storage_quota_exceeded" }, + { status: 402 } + ); + } + } + + // Material size discrepancy check (client declared much less than actual). + // 20 % tolerance — flag in logs but don't block if within quota. + if (actualBytes !== null && declaredBytes !== null) { + const discrepancy = actualBytes - Number(declaredBytes); + if (discrepancy > Number(declaredBytes) * 0.2) { + console.warn( + `[quota] size discrepancy for memory ${id}: declared=${declaredBytes} actual=${actualBytes} delta=${discrepancy}` + ); + } + } + + // Mark as processing and start the media pipeline await sql`UPDATE memories SET processing_status = 'processing', updated_at = now() WHERE id = ${id}`; - // Fire-and-forget: thumbnail then vision generateThumbnail(id) .then(() => processMemoryVision(id)) .catch(e => console.error(`[memory pipeline] id=${id}`, e)); - return NextResponse.json({ success: true, message: "Processing started" }); + return NextResponse.json({ success: true, message: "Processing started", actualBytes }); } diff --git a/src/app/api/storage-usage/route.ts b/src/app/api/storage-usage/route.ts new file mode 100644 index 0000000..56f0bcb --- /dev/null +++ b/src/app/api/storage-usage/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { requireFamily } from "@/lib/auth"; +import { getStorageInfo, formatBytes } from "@/lib/quota"; + +// GET /api/storage-usage — returns storage stats for the UI meter. +// RLS-safe: requireFamily() scopes the query to the authenticated family. +export async function GET() { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const info = await getStorageInfo(familyId); + + return NextResponse.json({ + usedBytes: info.usedBytes, + limitBytes: info.isPaid ? null : info.limitBytes, + usedFormatted: formatBytes(info.usedBytes), + limitFormatted: info.isPaid ? "Unlimited" : formatBytes(info.limitBytes), + fraction: info.isPaid ? 0 : info.fraction, + approaching: info.approaching, + exceeded: info.exceeded, + isPaid: info.isPaid, + }); +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index dfc7f6b..07959e7 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; import { requireFamily, requireOwnership } from "@/lib/auth"; import { sql } from "@/db"; import { nanoid } from "nanoid"; +import { checkStorageQuota } from "@/lib/quota"; const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/gif"]; const MAX_BYTES = 20 * 1024 * 1024; // 20MB @@ -59,6 +60,24 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "File too large (max 20MB)" }, { status: 400 }); } + // Storage quota gate — must run before issuing the presigned URL. + // Client-declared sizeBytes is used for the pre-check; actual size is + // reconciled on confirm. Unknown size (0) still triggers a usage fetch + // so families already over quota are blocked even for zero-declared uploads. + const declaredBytes = sizeBytes ?? 0; + const quotaCheck = await checkStorageQuota(familyId, declaredBytes); + if (!quotaCheck.allowed) { + return NextResponse.json( + { + error: quotaCheck.message, + reason: quotaCheck.reason, + usedBytes: quotaCheck.usedBytes, + limitBytes: quotaCheck.limitBytes, + }, + { status: 402 } + ); + } + if (childId) { const ownership = await requireOwnership(childId, "children", "Child"); if (!ownership.success) { diff --git a/src/components/StorageMeter.tsx b/src/components/StorageMeter.tsx new file mode 100644 index 0000000..b16bb3e --- /dev/null +++ b/src/components/StorageMeter.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface StorageInfo { + usedBytes: number; + limitBytes: number | null; + usedFormatted: string; + limitFormatted: string; + fraction: number; + approaching: boolean; + exceeded: boolean; + isPaid: boolean; +} + +interface Props { + /** If provided, used directly instead of fetching. */ + info?: StorageInfo; + className?: string; + /** When true, renders nothing unless approaching or exceeded (for inline use in page headers). */ + compact?: boolean; +} + +export function StorageMeter({ info: propInfo, className = "", compact = false }: Props) { + const [info, setInfo] = useState(propInfo ?? null); + const [loading, setLoading] = useState(!propInfo); + + useEffect(() => { + if (propInfo) return; + fetch("/api/storage-usage") + .then(r => r.json()) + .then(setInfo) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [propInfo]); + + if (loading) { + return ( +
+ ); + } + + if (!info || info.isPaid) return null; + if (compact && !info.approaching && !info.exceeded) return null; + + const pct = Math.min(info.fraction * 100, 100); + const barColor = info.exceeded + ? "bg-red-500" + : info.approaching + ? "bg-amber-400" + : "bg-rose-400"; + + const textColor = info.exceeded + ? "text-red-600 dark:text-red-400" + : info.approaching + ? "text-amber-600 dark:text-amber-400" + : "text-gray-500 dark:text-gray-400"; + + return ( +
+ {/* Label */} +
+ + {info.exceeded + ? "Storage full" + : info.approaching + ? "Storage nearly full" + : "Storage"} + + + {info.usedFormatted} / {info.limitFormatted} + +
+ + {/* Bar */} +
+
+
+ + {/* Contextual message */} + {info.exceeded && ( +

+ New uploads are paused. Your existing memories are safe.{" "} + Upgrade or delete some memories to continue. +

+ )} + {info.approaching && !info.exceeded && ( +

+ You've used {info.usedFormatted} of {info.limitFormatted}. +

+ )} +
+ ); +} + +/** + * Banner shown inline when an upload is blocked by the quota. + * Drop this next to the upload button; it only renders when blocked. + */ +export function StorageQuotaBanner({ + usedBytes, + limitBytes, + usedFormatted, + limitFormatted, +}: { + usedBytes: number; + limitBytes: number; + usedFormatted: string; + limitFormatted: string; +}) { + return ( +
+

Storage full

+

+ You've used {usedFormatted} of your {limitFormatted} free-tier storage. + Your memories are safe — new uploads are paused until you free up space or upgrade. +

+ + Upgrade to continue uploading → + +
+ ); +} + +/** + * Banner for the member limit (shown when invite is blocked). + */ +export function MemberLimitBanner({ + currentCount, + limit, +}: { + currentCount: number; + limit: number; +}) { + return ( +
+

Member limit reached

+

+ Your family has {currentCount} of {limit} free-tier members. Upgrade to invite + more caregivers. +

+ + Upgrade to add more members → + +
+ ); +} diff --git a/src/db/schema/family.ts b/src/db/schema/family.ts index 7a276ed..ebebe12 100644 --- a/src/db/schema/family.ts +++ b/src/db/schema/family.ts @@ -40,8 +40,12 @@ export const childStageEnum = pgEnum("child_stage", [ export const families = pgTable("families", { id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), - // Tier system (legacy migration 0005_tier_system). + // Tier / subscription — payment abstraction (migration 0005 + 0007). + // tier: 'free' | 'pro' (or any non-'free' value = paid) + // subscriptionStatus: set by payment-provider webhook: 'active' | 'canceled' | 'past_due' | null + // All quota/member enforcement reads through isPaidFamily() in src/lib/quota.ts. tier: varchar("tier", { length: 20 }).default("free"), + subscriptionStatus: varchar("subscription_status", { length: 20 }), maxChildren: integer("max_children").default(1), maxMembers: integer("max_members").default(2), pediatricianPhone: text("pediatrician_phone"), diff --git a/src/lib/format-bytes.ts b/src/lib/format-bytes.ts new file mode 100644 index 0000000..c6f6eb9 --- /dev/null +++ b/src/lib/format-bytes.ts @@ -0,0 +1,7 @@ +/** Safe to import in client components — no server-only imports. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/src/lib/quota.ts b/src/lib/quota.ts new file mode 100644 index 0000000..60e56bd --- /dev/null +++ b/src/lib/quota.ts @@ -0,0 +1,243 @@ +/** + * quota.ts — Storage and member-limit enforcement for Tia's free tier. + * + * Architecture: + * Pure functions (no DB I/O) → fully unit-testable without a real database. + * DB-bound functions → thin wrappers that call the pure functions + * with data fetched from Postgres. + * + * Payment abstraction: + * All enforcement reads family plan state through isPaidFamily(). + * To upgrade a family, flip families.tier to any non-'free' value. + * The actual Razorpay (or other) integration only needs to call that one field. + */ + +import { sql } from "@/db"; +import { formatBytes } from "@/lib/format-bytes"; +export { formatBytes } from "@/lib/format-bytes"; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const FREE_STORAGE_LIMIT_BYTES = 1_073_741_824; // 1 GiB +export const FREE_MEMBER_LIMIT = 2; + +// Storage threshold at which we show a "approaching limit" nudge (80 %). +export const STORAGE_WARN_THRESHOLD = 0.8; + +// ─── Pure helpers (unit-testable, no DB) ───────────────────────────────────── + +/** + * Returns true if the family is on a paid plan. + * Any tier value other than 'free' (or null/undefined) is treated as paid. + * This is the single gate; swap payment providers by only touching this function. + */ +export function isPaidFamily( + tier: string | null | undefined +): boolean { + if (!tier) return false; + return tier !== "free"; +} + +/** + * Returns true if adding declaredBytes would push usage over the limit. + */ +export function wouldExceedQuota( + currentUsageBytes: number, + declaredBytes: number, + limitBytes: number +): boolean { + return currentUsageBytes + declaredBytes > limitBytes; +} + +/** + * Returns true if the member count is already at or over the free-tier limit. + * Paid families: always false (no enforcement). + */ +export function isAtMemberLimit( + currentCount: number, + maxMembers: number, + paid: boolean +): boolean { + if (paid) return false; + return currentCount >= maxMembers; +} + +// ─── Result types ───────────────────────────────────────────────────────────── + +export type StorageQuotaResult = + | { allowed: true; usedBytes: number; limitBytes: number } + | { + allowed: false; + reason: "storage_quota_exceeded"; + usedBytes: number; + limitBytes: number; + message: string; + }; + +export type MemberLimitResult = + | { allowed: true; currentCount: number; limit: number } + | { + allowed: false; + reason: "member_limit_reached"; + currentCount: number; + limit: number; + message: string; + }; + +export interface StorageUsageInfo { + usedBytes: number; + limitBytes: number; + /** 0–1 fraction. >1 means over quota (possible after downgrade). */ + fraction: number; + approaching: boolean; // fraction >= STORAGE_WARN_THRESHOLD + exceeded: boolean; // fraction >= 1 + isPaid: boolean; +} + +// ─── DB-bound queries ───────────────────────────────────────────────────────── + +/** + * Derived storage usage: SUM(size_bytes) across memories + attachments. + * Excludes memories still in 'uploading' status (file not yet in R2). + * Uses CAST to bigint to avoid integer overflow at the SQL level. + */ +export async function getFamilyStorageUsage(familyId: string): Promise { + const [mRow, aRow] = await Promise.all([ + sql<{ bytes: string }[]>` + SELECT COALESCE(SUM(size_bytes::bigint), 0) AS bytes + FROM memories + WHERE family_id = ${familyId} + AND processing_status != 'uploading' + AND size_bytes IS NOT NULL + `, + sql<{ bytes: string }[]>` + SELECT COALESCE(SUM(size_bytes::bigint), 0) AS bytes + FROM attachments + WHERE family_id = ${familyId} + AND size_bytes IS NOT NULL + `, + ]); + return Number(mRow[0]?.bytes ?? 0) + Number(aRow[0]?.bytes ?? 0); +} + +/** + * Returns full usage info for a family (for the storage meter UI). + */ +export async function getStorageInfo(familyId: string): Promise { + const [familyRow] = await sql<{ tier: string | null }[]>` + SELECT tier FROM families WHERE id = ${familyId} LIMIT 1 + `; + const paid = isPaidFamily(familyRow?.tier); + const usedBytes = await getFamilyStorageUsage(familyId); + const limitBytes = paid ? Infinity : FREE_STORAGE_LIMIT_BYTES; + const fraction = paid ? 0 : usedBytes / FREE_STORAGE_LIMIT_BYTES; + + return { + usedBytes, + limitBytes, + fraction, + approaching: !paid && fraction >= STORAGE_WARN_THRESHOLD && fraction < 1, + exceeded: !paid && fraction >= 1, + isPaid: paid, + }; +} + +/** + * Storage quota gate — called at presigned-URL issuance. + * declaredBytes is the client-reported file size (untrusted, but used for the gate). + * On-confirm, actual size is reconciled; see reconcileActualSize(). + */ +export async function checkStorageQuota( + familyId: string, + declaredBytes: number +): Promise { + const [familyRow] = await sql<{ tier: string | null }[]>` + SELECT tier FROM families WHERE id = ${familyId} LIMIT 1 + `; + + if (isPaidFamily(familyRow?.tier)) { + const usedBytes = await getFamilyStorageUsage(familyId); + return { allowed: true, usedBytes, limitBytes: Infinity }; + } + + const limitBytes = FREE_STORAGE_LIMIT_BYTES; + const usedBytes = await getFamilyStorageUsage(familyId); + + if (wouldExceedQuota(usedBytes, declaredBytes, limitBytes)) { + return { + allowed: false, + reason: "storage_quota_exceeded", + usedBytes, + limitBytes, + message: `Storage quota exceeded. You have used ${formatBytes(usedBytes)} of your ${formatBytes(limitBytes)} limit. Delete some memories or upgrade to continue uploading.`, + }; + } + + return { allowed: true, usedBytes, limitBytes }; +} + +/** + * After a confirmed R2 upload, checks if the actual object size would push + * the family over quota. Returns whether the upload should be kept. + */ +export async function reconcileActualSize( + familyId: string, + memoryId: string, + actualBytes: number +): Promise<{ keep: boolean; reason?: string }> { + const [familyRow] = await sql<{ tier: string | null }[]>` + SELECT tier FROM families WHERE id = ${familyId} LIMIT 1 + `; + + // Always update the stored size to the actual value regardless of plan + await sql` + UPDATE memories SET size_bytes = ${actualBytes} WHERE id = ${memoryId} AND family_id = ${familyId} + `; + + if (isPaidFamily(familyRow?.tier)) return { keep: true }; + + // Re-derive usage with the updated size + const usedBytes = await getFamilyStorageUsage(familyId); + if (usedBytes > FREE_STORAGE_LIMIT_BYTES) { + return { + keep: false, + reason: `Actual file size (${formatBytes(actualBytes)}) pushed family over the ${formatBytes(FREE_STORAGE_LIMIT_BYTES)} quota.`, + }; + } + + return { keep: true }; +} + +/** + * Member limit gate — called at invite creation (not acceptance). + * COUNT(family_members) is the source of truth; no separate counter. + */ +export async function checkMemberLimit(familyId: string): Promise { + const [row] = await sql<{ count: string; max_members: number | null; tier: string | null }[]>` + SELECT + COUNT(fm.id)::text AS count, + f.max_members, + f.tier + FROM families f + LEFT JOIN family_members fm ON fm.family_id = f.id + WHERE f.id = ${familyId} + GROUP BY f.id + `; + + if (!row) return { allowed: false, reason: "member_limit_reached", currentCount: 0, limit: FREE_MEMBER_LIMIT, message: "Family not found." }; + + const currentCount = Number(row.count); + const limit = row.max_members ?? FREE_MEMBER_LIMIT; + + if (isAtMemberLimit(currentCount, limit, isPaidFamily(row.tier))) { + return { + allowed: false, + reason: "member_limit_reached", + currentCount, + limit, + message: `Your family has reached its member limit (${currentCount}/${limit}). Upgrade to invite more caregivers.`, + }; + } + + return { allowed: true, currentCount, limit }; +} diff --git a/src/middleware.ts b/src/middleware.ts index e642e6a..07f9fcd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -45,6 +45,7 @@ const protectedApiRoutes = [ "/api/invites", "/api/notifications", "/api/upload", + "/api/storage-usage", "/api/chat", "/api/history", "/api/family/members", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a87cfca --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["src/__tests__/quota.test.ts"], + exclude: ["**/node_modules/**"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +});