- {/* 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/app/apple-icon.png b/src/app/apple-icon.png
new file mode 100644
index 0000000..8370e38
Binary files /dev/null and b/src/app/apple-icon.png differ
diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx
index 8751e4b..7d0ce3a 100644
--- a/src/app/invite/[token]/page.tsx
+++ b/src/app/invite/[token]/page.tsx
@@ -1,9 +1,10 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
-export default function InvitePage({ params }: { params: { token: string } }) {
+export default function InvitePage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = use(params);
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -18,7 +19,7 @@ export default function InvitePage({ params }: { params: { token: string } }) {
const res = await fetch("/api/invites/accept", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: params.token, userId }),
+ body: JSON.stringify({ token, userId }),
});
const data = await res.json();
@@ -36,7 +37,7 @@ export default function InvitePage({ params }: { params: { token: string } }) {
}
checkInvite();
- }, [params.token, router]);
+ }, [token, router]);
if (loading) {
return (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b793e66..e36e7c2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,4 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono, Caveat } from "next/font/google";
import "./globals.css";
@@ -18,13 +18,21 @@ const caveat = Caveat({
});
export const metadata: Metadata = {
- title: "Tia - Baby Tracking",
- description: "Your baby tracking companion",
- manifest: "/manifest.json",
+ title: "Tia — Baby Tracker",
+ description: "Track feeds, sleep, milestones and memories.",
icons: {
icon: "/icon.svg",
- apple: "/icon.svg",
+ apple: "/apple-icon.png",
},
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "Tia",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#fb7185",
};
export default function RootLayout({
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..4d0af85
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,23 @@
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "Tia — Baby Tracker",
+ short_name: "Tia",
+ description: "Track feeds, sleep, milestones and memories.",
+ start_url: "/?source=pwa",
+ display: "standalone",
+ background_color: "#fdf2f2",
+ theme_color: "#fb7185",
+ icons: [
+ { src: "/icons/192.png", sizes: "192x192", type: "image/png" },
+ { src: "/icons/512.png", sizes: "512x512", type: "image/png" },
+ {
+ src: "/icons/maskable-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ };
+}
diff --git a/src/app/sw.ts b/src/app/sw.ts
new file mode 100644
index 0000000..52a351c
--- /dev/null
+++ b/src/app/sw.ts
@@ -0,0 +1,42 @@
+import { defaultCache } from "@serwist/next/worker";
+import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from "serwist";
+import { NetworkOnly, Serwist } from "serwist";
+
+declare global {
+ interface WorkerGlobalScope extends SerwistGlobalConfig {
+ __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
+ }
+}
+declare const self: ServiceWorkerGlobalScope;
+
+// SECURITY: API responses carry session-protected personal/medical data.
+// They must NEVER be cached. NetworkOnly = always hit the server, no cache.
+// Listed first so it wins over defaultCache.
+const runtimeCaching: RuntimeCaching[] = [
+ {
+ matcher: ({ sameOrigin, url }) =>
+ sameOrigin && url.pathname.startsWith("/api/"),
+ handler: new NetworkOnly(),
+ },
+ ...defaultCache,
+];
+
+const serwist = new Serwist({
+ precacheEntries: self.__SW_MANIFEST,
+ precacheOptions: { cleanupOutdatedCaches: true },
+ skipWaiting: true,
+ clientsClaim: true,
+ navigationPreload: true,
+ disableDevLogs: true,
+ runtimeCaching,
+ fallbacks: {
+ entries: [
+ {
+ url: "/~offline",
+ matcher: ({ request }) => request.mode === "navigate",
+ },
+ ],
+ },
+});
+
+serwist.addEventListeners();
diff --git a/src/app/~offline/page.tsx b/src/app/~offline/page.tsx
new file mode 100644
index 0000000..9810cc6
--- /dev/null
+++ b/src/app/~offline/page.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+export default function OfflinePage() {
+ return (
+
+
🌸
+
You're offline
+
+ Reconnect to the internet to keep tracking with Tia.
+
+
window.location.reload()}
+ className="px-6 py-3 bg-rose-400 text-white rounded-xl font-semibold active:opacity-80"
+ >
+ Try again
+
+
+ );
+}
diff --git a/src/components/InstallPrompt.tsx b/src/components/InstallPrompt.tsx
new file mode 100644
index 0000000..fc2217b
--- /dev/null
+++ b/src/components/InstallPrompt.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+// Extend the BeforeInstallPromptEvent type (not in standard lib)
+interface BeforeInstallPromptEvent extends Event {
+ prompt(): Promise
;
+ readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+function IOSInstallInstructions({ onDismiss }: { onDismiss: () => void }) {
+ return (
+
+
+
+ 🌸
+ Install Tia
+
+
+ ✕
+
+
+
+ Add Tia to your home screen for the best experience.
+
+
+ Tap
+ ⬆️
+ then
+ "Add to Home Screen"
+
+
+ );
+}
+
+const DISMISSED_KEY = "tia_install_prompt_dismissed";
+
+export function InstallPrompt() {
+ const [deferred, setDeferred] = useState(null);
+ const [isIOS, setIsIOS] = useState(false);
+ const [dismissed, setDismissed] = useState(true); // start hidden to avoid flash
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ const alreadyDismissed = localStorage.getItem(DISMISSED_KEY) === "1";
+ if (alreadyDismissed) return;
+
+ const standalone = window.matchMedia("(display-mode: standalone)").matches;
+ if (standalone) return; // already installed
+
+ // iOS Safari: no beforeinstallprompt, needs manual instructions
+ const ios = /iphone|ipad|ipod/i.test(navigator.userAgent);
+ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ if (ios && isSafari) {
+ setIsIOS(true);
+ setDismissed(false);
+ }
+
+ // Android / Chrome: capture the deferred install event
+ const handler = (e: Event) => {
+ e.preventDefault();
+ setDeferred(e as BeforeInstallPromptEvent);
+ setDismissed(false);
+ };
+ window.addEventListener("beforeinstallprompt", handler);
+ return () => window.removeEventListener("beforeinstallprompt", handler);
+ }, []);
+
+ const handleDismiss = () => {
+ setDismissed(true);
+ localStorage.setItem(DISMISSED_KEY, "1");
+ };
+
+ const handleInstall = async () => {
+ if (!deferred) return;
+ await deferred.prompt();
+ const { outcome } = await deferred.userChoice;
+ if (outcome === "accepted") {
+ setDeferred(null);
+ setDismissed(true);
+ }
+ };
+
+ if (dismissed) return null;
+
+ if (deferred) {
+ return (
+
+
🌸
+
+
Install Tia
+
Add to home screen for quick access
+
+
✕
+
+ Install
+
+
+ );
+ }
+
+ if (isIOS) {
+ return ;
+ }
+
+ return null;
+}
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 (
+
+ );
+}
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 a0254af..07f9fcd 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,6 +1,18 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+// PWA static assets — must bypass auth entirely.
+// If the SW or manifest gets a 302→login, the browser registers the login
+// page as the service worker, which breaks auth for the entire PWA session.
+const pwaAssets = [
+ "/sw.js",
+ "/sw.js.map",
+ "/manifest.webmanifest",
+ "/~offline",
+ "/icons/",
+ "/serwist-",
+];
+
// Public routes that don't require authentication
const publicRoutes = [
"/",
@@ -33,6 +45,7 @@ const protectedApiRoutes = [
"/api/invites",
"/api/notifications",
"/api/upload",
+ "/api/storage-usage",
"/api/chat",
"/api/history",
"/api/family/members",
@@ -41,6 +54,13 @@ const protectedApiRoutes = [
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
+ // Always pass PWA assets through — never redirect to login
+ for (const asset of pwaAssets) {
+ if (pathname === asset || pathname.startsWith(asset)) {
+ return NextResponse.next();
+ }
+ }
+
// Always allow public routes
for (const route of publicRoutes) {
if (pathname === route || pathname.startsWith(route + "/")) {
diff --git a/tsconfig.json b/tsconfig.json
index cf9c65d..7ba9795 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "src/app/sw.ts"]
}
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"),
+ },
+ },
+});