tia/src/app/(app)/settings/page.tsx
Mannu 0c7f37fd12 feat(quota): storage quota + family-member limits for free tier
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 <noreply@anthropic.com>
2026-05-27 23:21:11 +05:30

399 lines
No EOL
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
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;
userId: string;
role: string;
displayName: string;
name: string;
email: string;
}
interface Invite {
id: string;
email: string;
displayName: string;
role: string;
expiresAt: string;
}
export default function SettingsPage() {
const router = useRouter();
const { theme, mode, setMode } = useTheme();
const { tier, memberCount, familyId, children, familyName: providerFamilyName, childId, child } = useFamily();
const [exporting, setExporting] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [familyOpen, setFamilyOpen] = useState(true);
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("caregiver");
const [inviteLoading, setInviteLoading] = useState(false);
const [pedPhone, setPedPhone] = useState("");
const [pedSaving, setPedSaving] = useState(false);
// Check if can invite more members (client-side pre-check; server enforces)
const canInvite = tier === "pro" || memberCount < 2;
// Family name from provider or fallback
const familyName = providerFamilyName || "My Family";
const themeOptions = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
{ value: "time", label: "Time of Day" },
] as const;
useEffect(() => {
if (familyId) {
fetchMembers();
fetchInvites();
fetch("/api/family").then(r => r.json()).then(d => {
if (d.family?.pediatrician_phone) setPedPhone(d.family.pediatrician_phone);
}).catch(() => {});
}
}, [familyId]);
const exportGrowthCSV = async () => {
if (!childId) return;
setExporting(true);
try {
const res = await fetch(`/api/growth?childId=${childId}`);
const data = await res.json();
const records = data.growth || [];
if (records.length === 0) { alert("No growth records to export."); return; }
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = records.map((r: { measured_at: string; weight_kg: number | null; height_cm: number | null; head_circumference_cm: number | null; notes: string | null }) => [
new Date(r.measured_at).toLocaleDateString(),
r.weight_kg ?? "",
r.height_cm ?? "",
r.head_circumference_cm ?? "",
r.notes ?? "",
]);
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${child?.name || "child"}_growth_${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch { alert("Export failed. Please try again."); }
setExporting(false);
};
const savePedPhone = async () => {
setPedSaving(true);
await fetch("/api/family", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pediatricianPhone: pedPhone }),
}).catch(() => {});
setPedSaving(false);
};
const fetchMembers = async () => {
if (!familyId) return;
try {
const res = await fetch(`/api/family/members?familyId=${familyId}`);
const data = await res.json();
setMembers(data.members || []);
} catch (err) {
console.error("Failed to fetch members:", err);
}
};
const fetchInvites = async () => {
if (!familyId) return;
try {
const res = await fetch(`/api/invites?familyId=${familyId}`);
const data = await res.json();
setInvites(data.invites || []);
} catch (err) {
console.error("Failed to fetch invites:", err);
}
};
const deleteInvite = async (inviteId: string) => {
try {
await fetch(`/api/invites/${inviteId}`, { method: "DELETE" });
setInvites(prev => prev.filter(i => i.id !== inviteId));
} catch (err) {
console.error("Failed to delete invite:", err);
}
};
const sendInvite = async () => {
if (!inviteEmail || !familyId) return;
setInviteLoading(true);
try {
const res = await fetch("/api/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
familyId: familyId,
email: inviteEmail,
role: inviteRole,
displayName: inviteEmail.split("@")[0],
}),
});
const data = await res.json();
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);
}
} catch (err) {
console.error("Failed to send invite:", err);
}
setInviteLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex items-center gap-4">
<button onClick={() => router.back()} className="p-2"></button>
<h1 className="text-xl font-bold">Settings</h1>
</div>
<div className="px-4 space-y-3">
{/* My Profile Page */}
<a href="/settings/profile"
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-white">My Profile Page</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Create your public product recommendation page</p>
</div>
<span className="text-gray-400"></span>
</a>
{/* Notifications */}
<Link href="/notifications" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">🔔</span>
<div className="font-medium">Notifications</div>
</div>
<span className="text-gray-400"></span>
</Link>
{/* Profile */}
<Link href="/profile" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex items-center gap-3">
<span className="text-xl">👤</span>
<div className="font-medium">Profile</div>
</div>
<span className="text-gray-400"></span>
</Link>
{/* Family - Single consolidated section */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setFamilyOpen(!familyOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">🏠</span>
<div className="font-medium">Family</div>
</div>
<span className={`text-gray-400 transition-transform ${familyOpen ? "rotate-180" : ""}`}></span>
</button>
{familyOpen && (
<div className="px-4 pb-4 space-y-4">
{/* Family Info */}
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-lg">{familyName}</div>
<div className="text-sm text-gray-500">
{tier === "pro" ? "Pro Plan" : <span className="bg-rose-100 text-rose-700 px-2 py-0.5 rounded-full text-xs">Free Plan</span>} · {members.length} member{members.length !== 1 ? "s" : ""} · {children?.length || 0} child{children?.length !== 1 ? "ren" : ""}
</div>
</div>
{tier === "free" && (
<Button size="sm">Upgrade</Button>
)}
</div>
{/* Family Members */}
<div>
<div className="text-sm font-medium text-gray-500 mb-2">Family Members</div>
{members.length > 0 ? (
<div className="space-y-2">
{members.map((member) => (
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
<div>
<div className="font-medium text-sm">{member.name || member.email}</div>
<div className="text-xs text-gray-400">{member.email}</div>
</div>
<Badge variant={member.role === "admin" ? "rose" : "default"}>{member.role}</Badge>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-sm">No family members</p>
)}
</div>
{/* Storage usage meter */}
<div>
<div className="text-sm font-medium text-gray-500 mb-2">Storage</div>
<StorageMeter />
</div>
{/* Manage Children */}
<Link href="/family" className="flex items-center justify-between p-3 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<span className="text-sm">Manage Children</span>
<span className="text-gray-400"></span>
</Link>
</div>
)}
</div>
{/* Invite Members */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setInviteOpen(!inviteOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<div className="font-medium">Invite Members</div>
{tier === "free" && (
<span className="text-xs px-2 py-0.5 bg-rose-100 text-rose-600 rounded-full">{memberCount}/2</span>
)}
</div>
<span className={`text-gray-400 transition-transform ${inviteOpen ? "rotate-180" : ""}`}></span>
</button>
{inviteOpen && (
<div className="px-4 pb-4">
{/* Member limit banner */}
{tier === "free" && !canInvite && (
<div className="mb-3">
<MemberLimitBanner currentCount={memberCount} limit={2} />
</div>
)}
{/* Pending invites */}
{invites.length > 0 && (
<div className="mb-3">
<div className="text-sm text-gray-500 mb-2">Pending Invites</div>
{invites.map((invite) => (
<div key={invite.id} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm mb-1">
<div>
<div className="font-medium text-gray-800 dark:text-gray-100">{invite.email}</div>
<div className="text-xs text-gray-400">Pending · expires {new Date(invite.expiresAt).toLocaleDateString()}</div>
</div>
<button
onClick={() => deleteInvite(invite.id)}
className="text-xs text-red-400 hover:text-red-600 dark:hover:text-red-300 px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Cancel invite"
>
Cancel
</button>
</div>
))}
</div>
)}
{/* Add invite form */}
{canInvite && (
<div className="space-y-2">
<Input type="email" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="Email address" />
<Select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer (read-only)</option>
</Select>
<Button fullWidth loading={inviteLoading} disabled={!inviteEmail} onClick={sendInvite}>
Send Invite
</Button>
</div>
)}
</div>
)}
</div>
{/* Theme */}
<div className="bg-white dark:bg-gray-800 rounded-xl overflow-hidden">
<button
onClick={() => setThemeOpen(!themeOpen)}
className="w-full flex items-center justify-between p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">{theme === "dark" ? "🌙" : "☀️"}</span>
<div className="font-medium">Theme</div>
</div>
<span className={`text-gray-400 transition-transform ${themeOpen ? "rotate-180" : ""}`}></span>
</button>
{themeOpen && (
<div className="px-4 pb-4">
<div className="grid grid-cols-2 gap-2">
{themeOptions.map((opt) => (
<Button
key={opt.value}
onClick={() => setMode(opt.value)}
variant={mode === opt.value ? "primary" : "secondary"}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
{/* Pediatrician Phone */}
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xl">🏥</span>
<div className="font-medium dark:text-white">Pediatrician Phone</div>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">Shown on the emergency guide and in AI medical redirects.</p>
<div className="flex gap-2">
<Input type="tel" value={pedPhone} onChange={e => setPedPhone(e.target.value)} placeholder="+91 98765 43210" className="flex-1" />
<Button size="sm" loading={pedSaving} onClick={savePedPhone}>Save</Button>
</div>
<Link href="/medical/emergency" className="text-xs text-rose-500 dark:text-rose-400">
View Emergency Guide
</Link>
</div>
{/* Export Data */}
<button
onClick={exportGrowthCSV}
disabled={exporting || !childId}
className="w-full flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<div className="flex items-center gap-3">
<span className="text-xl">📥</span>
<div>
<div className="font-medium text-left">Export Growth Data</div>
<div className="text-xs text-gray-400 text-left">Download {child?.name ? `${child.name}'s` : "growth"} records as CSV</div>
</div>
</div>
<span className="text-gray-400 text-sm">{exporting ? "…" : "→"}</span>
</button>
{/* App Version */}
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl mt-4">
<div className="font-medium">App Version</div>
<div className="text-sm text-gray-500">Tia v1.0.0</div>
</div>
</div>
</div>
);
}