SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

Member accounts: auth + MFA via Resend, collections, watchlist, alerts, deal radar, profiles (agent F1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent ea481cc

119 changed files +8,150 −5

added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
modified apps/web/next.config.ts +21 −1
@@ -1,10 +1,23 @@
1 1 import type { NextConfig } from 'next';
2 +import { existsSync } from 'node:fs';
3 +import path from 'node:path';
4 +
5 +// Monorepo: the single `.env` lives at the repository root; Next only reads the app directory.
6 +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) {
7 + if (existsSync(candidate)) {
8 + try {
9 + process.loadEnvFile(candidate);
10 + } catch {
11 + /* ignore malformed env */
12 + }
13 + }
14 +}
2 15
3 16 const nextConfig: NextConfig = {
4 17 reactStrictMode: true,
5 18 poweredByHeader: false,
6 19 // Workspace packages are consumed as TypeScript sources.
7 − transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors'],
20 + transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors', '@rareindex/notify'],
8 21 serverExternalPackages: ['postgres', 'pino', 'cheerio'],
9 22 images: {
10 23 remotePatterns: [{ protocol: 'https', hostname: '**' }],
@@ -13,6 +26,13 @@ const nextConfig: NextConfig = {
13 26 experimental: {
14 27 optimizePackageImports: ['lucide-react'],
15 28 },
29 + // Workspace packages use NodeNext-style `./file.js` imports that point at `.ts` sources.
30 + // Turbopack cannot map them; when building with `--webpack`, extensionAlias does.
31 + webpack: (config) => {
32 + config.resolve = config.resolve ?? {};
33 + config.resolve.extensionAlias = { '.js': ['.ts', '.tsx', '.js'], '.mjs': ['.mts', '.mjs'] };
34 + return config;
35 + },
16 36 async headers() {
17 37 return [
18 38 {
modified apps/web/package.json +4 −0
@@ -13,11 +13,14 @@
13 13 "dependencies": {
14 14 "@rareindex/connectors": "workspace:*",
15 15 "@rareindex/database": "workspace:*",
16 + "@rareindex/notify": "workspace:*",
16 17 "@rareindex/shared": "workspace:*",
17 18 "@rareindex/taxonomy": "workspace:*",
18 19 "@tanstack/react-query": "^5.90.0",
19 20 "lucide-react": "^1.0.0",
20 21 "next": "16.3.4",
22 + "otpauth": "^9.5.2",
23 + "qrcode": "^1.5.4",
21 24 "react": "19.2.8",
22 25 "react-dom": "19.2.8",
23 26 "server-only": "^0.0.1",
@@ -26,6 +29,7 @@
26 29 "devDependencies": {
27 30 "@tailwindcss/postcss": "^4",
28 31 "@types/node": "^24.0.0",
32 + "@types/qrcode": "^1.5.6",
29 33 "@types/react": "^19",
30 34 "@types/react-dom": "^19",
31 35 "eslint": "^9",
added apps/web/scripts/e2e-account.mts +193 −0
@@ -0,0 +1,193 @@
1 +/**
2 + * End-to-end exercise of the member flows against a running dev server (no browser needed):
3 + * forms are submitted the way a no-JS browser would (progressive enhancement of server actions).
4 + *
5 + * EMAIL_TRANSPORT=console pnpm exec next dev --webpack -p 3001 > /tmp/ri-f1-dev.log &
6 + * pnpm exec tsx scripts/e2e-account.mts http://localhost:3001 /tmp/ri-f1-dev.log
7 + */
8 +import { readFileSync } from 'node:fs';
9 +import * as OTPAuth from 'otpauth';
10 +
11 +const BASE = process.argv[2] ?? 'http://localhost:3001';
12 +const LOG = process.argv[3] ?? '/tmp/ri-f1-dev.log';
13 +const jar = new Map<string, string>();
14 +const email = `e2e+${Date.now()}@rareindex.io`;
15 +const password = 'correct horse battery staple 42';
16 +let failures = 0;
17 +
18 +function cookieHeader() {
19 + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
20 +}
21 +function storeCookies(res: Response) {
22 + for (const c of res.headers.getSetCookie()) {
23 + const [pair, ...attrs] = c.split(';');
24 + const [k, v] = pair!.split('=');
25 + if (!k) continue;
26 + const expired = attrs.some((a) => /max-age=0|expires=thu, 01 jan 1970/i.test(a.trim()));
27 + if (expired || v === '' || v === undefined) jar.delete(k.trim());
28 + else jar.set(k.trim(), v);
29 + }
30 +}
31 +async function get(path: string) {
32 + const res = await fetch(BASE + path, { headers: { cookie: cookieHeader() }, redirect: 'manual' });
33 + storeCookies(res);
34 + return { status: res.status, location: res.headers.get('location'), html: await res.text(), type: res.headers.get('content-type') ?? '' };
35 +}
36 +function hiddenInputs(formHtml: string): Record<string, string> {
37 + const out: Record<string, string> = {};
38 + for (const m of formHtml.matchAll(/<input[^>]*type="hidden"[^>]*>/g)) {
39 + const tag = m[0];
40 + const name = tag.match(/name="([^"]*)"/)?.[1];
41 + const value = tag.match(/value="([^"]*)"/)?.[1] ?? '';
42 + if (name) out[name] = value.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
43 + }
44 + return out;
45 +}
46 +/** Find the form containing a given field (or text) and return its hidden inputs. */
47 +function formFor(html: string, marker: string): Record<string, string> {
48 + const chunks = html.split(/<form/).slice(1);
49 + const chunk = chunks.find((c) => c.includes(marker) && (c.includes('$ACTION') || c.includes('ACTION_ID')));
50 + if (!chunk) throw new Error(`form with marker "${marker}" not found`);
51 + return hiddenInputs(chunk.split('</form>')[0]!);
52 +}
53 +async function post(path: string, fields: Record<string, string | Blob>, marker: string, opts: { html?: string } = {}) {
54 + const page = opts.html ?? (await get(path)).html;
55 + const hidden = formFor(page, marker);
56 + const fd = new FormData();
57 + for (const [k, v] of Object.entries(hidden)) if (!(k in fields)) fd.append(k, v);
58 + for (const [k, v] of Object.entries(fields)) fd.append(k, v);
59 + const res = await fetch(BASE + path, { method: 'POST', body: fd, headers: { cookie: cookieHeader() }, redirect: 'manual' });
60 + storeCookies(res);
61 + return { status: res.status, location: res.headers.get('location'), html: await res.text() };
62 +}
63 +function check(name: string, ok: boolean, detail = '') {
64 + console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
65 + if (!ok) failures++;
66 +}
67 +function codeFromLog(re: RegExp): string {
68 + const log = readFileSync(LOG, 'utf8');
69 + const all = [...log.matchAll(re)];
70 + const last = all[all.length - 1];
71 + if (!last) throw new Error('code not found in log');
72 + return last[1]!;
73 +}
74 +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
75 +
76 +// 1. signup
77 +let r = await post('/signup', { email, password, name: 'E2E Collector', next: '/collections?welcome=1' }, 'name="email"');
78 +check('signup redirects to /verify', r.status === 303 && (r.location ?? '').includes('/verify'), `${r.status} ${r.location}`);
79 +await sleep(500);
80 +const vcode = codeFromLog(/verification code is (\d{6})/g);
81 +check('verification code e-mailed (console)', /^\d{6}$/.test(vcode));
82 +r = await post('/verify', { code: vcode }, 'name="code"');
83 +check('verify → collections (session created)', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);
84 +check('session cookie set', jar.has('ri_session') && jar.has('ri_device'));
85 +
86 +// 2. authenticated pages
87 +for (const p of ['/collections', '/account/settings', '/account/security', '/account/notifications', '/account/api-keys', '/account/data', '/watchlist', '/alerts', '/notifications', '/saved', '/targets', '/deals', '/my-index']) {
88 + const g = await get(p);
89 + check(`GET ${p}`, g.status === 200, String(g.status));
90 +}
91 +
92 +// 3. profile handle + currency
93 +r = await post('/account/settings', { name: 'E2E Collector', handle: `e2e${Date.now() % 100000}`, bio: 'Testing', displayCurrency: 'CAD' }, 'name="handle"');
94 +check('profile saved (handle + CAD)', r.status === 200 && r.html.includes('Profile saved.'));
95 +
96 +// 4. collection + item
97 +r = await post('/collections', { name: 'E2E Vault', kind: 'collection', description: 'test' }, 'name="kind"');
98 +const colId = (r.location ?? '').match(/\/collections\/(col_[a-z0-9]+)/)?.[1];
99 +check('collection created', r.status === 303 && Boolean(colId), `${r.status} ${r.location}`);
100 +r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000001', variantId: 'var_test000000000000001', quantity: '1', acquiredAt: '2024-01-15', purchasePrice: '5000', purchaseCurrency: 'CAD', source: 'e2e', grader: '', grade: '', tags: 'grail, test' }, 'name="purchaseCurrency"');
101 +check('item added with CAD cost at 2024 FX', r.status === 200 && r.html.includes('Item added'), r.html.match(/Item added[^<]*/)?.[0]);
102 +let page = await get(`/collections/${colId}`);
103 +check('collection shows variant RIV (PSA 10 = $19,800 → CA$26,928 at 1.36)', page.html.includes('26,928'), page.html.match(/CA\$[\d,]+/g)?.slice(0, 3).join(' '));
104 +check('collection shows cost basis (5000 CAD / 1.34 = $3,731 → CA$5,075)', page.html.includes('5,075'));
105 +r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000002', quantity: '1', acquiredAt: '2025-06-01', purchasePrice: '30000', purchaseCurrency: 'USD', grader: '', grade: '' }, 'name="purchaseCurrency"');
106 +check('second item (asset-level RIV) added', r.status === 200 && r.html.includes('Item added'));
107 +r = await post(`/collections/${colId}`, { assetId: 'rare_test0000000000000003', quantity: '2', grader: '', grade: '', manualValueUsd: '700' }, 'name="purchaseCurrency"');
108 +check('third item (manual value, no RIV) added', r.status === 200 && r.html.includes('Item added'));
109 +page = await get(`/collections/${colId}`);
110 +check('allocation & performers rendered', page.html.includes('Allocation by category') && page.html.includes('Best &amp; worst'));
111 +const csv = await get(`/api/account/collections/${colId}/export?format=csv`);
112 +check('CSV export', csv.status === 200 && csv.type.includes('text/csv') && csv.html.split('\r\n').length >= 4, csv.html.split('\r\n')[0]?.slice(0, 60));
113 +const ins = await get(`/collections/${colId}/insurance`);
114 +check('insurance schedule renders', ins.status === 200 && ins.html.includes('Collection schedule'));
115 +r = await post(`/collections/${colId}`, { collectionId: colId!, public: 'on' }, 'name="public"');
116 +check('collection made public', r.status === 200 || r.status === 303);
117 +
118 +// 5. watchlist / alerts / targets / saved
119 +r = await post('/watchlist', { targetType: 'asset', targetId: 'rare_test0000000000000001' }, 'name="targetType"');
120 +page = await get('/watchlist');
121 +check('asset watched', page.html.includes('Charizard') && page.html.includes('Unwatch'));
122 +r = await post('/alerts', { alertType: 'price_below', targetType: 'asset', targetId: 'rare_test0000000000000001', threshold: '6000', channel: 'inapp', cooldownMinutes: '60' }, 'name="alertType"');
123 +check('alert created', r.status === 200 && r.html.includes('Alert created.'));
124 +r = await post('/targets', { assetId: 'rare_test0000000000000002', direction: 'below', targetUsd: '29000' }, 'name="direction"');
125 +check('target created', r.status === 200 && r.html.includes('Target set.'));
126 +r = await post('/saved', { name: 'Charizards', url: '/search?q=charizard', notify: 'on' }, 'name="url"');
127 +check('saved search', r.status === 200 && r.html.includes('Search saved.'));
128 +page = await get('/deals');
129 +check('Deal Radar lists the -25% test listing', page.html.includes('Charizard') && page.html.includes('-25.00%'), page.html.match(/-\d+\.\d+%/)?.[0]);
130 +
131 +// 6. API key
132 +r = await post('/account/api-keys', { name: 'e2e key' }, 'name="name"');
133 +const key = r.html.match(/ri_(?:free|live)_[A-Za-z0-9_-]+/)?.[0];
134 +check('API key created and shown once', Boolean(key), key?.slice(0, 14));
135 +
136 +// 7. MFA setup → logout → login with TOTP
137 +r = await post('/account/security', {}, 'Set up authenticator');
138 +const secret = r.html.match(/<p class="mono-num[^"]*">([A-Z2-7]{16,})<\/p>/)?.[1];
139 +check('MFA setup started (secret shown)', Boolean(secret));
140 +const totp = new OTPAuth.TOTP({ issuer: 'RareIndex', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret!) });
141 +r = await post('/account/security', { code: totp.generate() }, 'Code from the app', { html: r.html });
142 +const recovery = [...r.html.matchAll(/<li>([A-Z2-9]{5}-[A-Z2-9]{5})<\/li>/g)].map((m) => m[1]!);
143 +check('MFA enabled; 10 recovery codes shown once', recovery.length === 10, String(recovery.length));
144 +// sign out everywhere else + logout current
145 +r = await post('/account/security', {}, 'Sign out everywhere else');
146 +const settings = await get('/account/settings');
147 +const logoutRes = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });
148 +storeCookies(logoutRes);
149 +check('logout route', logoutRes.status === 303 || logoutRes.status === 302, String(logoutRes.status));
150 +jar.delete('ri_device'); // simulate a brand-new device
151 +r = await post('/login', { email, password, next: '/collections' }, 'name="email"');
152 +check('login with MFA → /mfa', r.status === 303 && (r.location ?? '').includes('/mfa'), `${r.status} ${r.location}`);
153 +r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"');
154 +check('TOTP accepted → /collections', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);
155 +page = await get('/account/security');
156 +check('sign-in history shows totp success', page.html.includes('totp'));
157 +// recovery code path
158 +const logout2 = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });
159 +storeCookies(logout2);
160 +jar.delete('ri_device');
161 +r = await post('/login', { email, password, next: '/collections' }, 'name="email"');
162 +r = await post('/mfa', { method: 'recovery', code: recovery[0]!.toLowerCase() }, 'name="method"');
163 +check('recovery code accepted', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location} ${r.html.match(/role="alert"[^>]*>([^<]*)/)?.[1] ?? ''}`);
164 +r = await post('/mfa', { method: 'recovery', code: recovery[0]! }, 'name="method"').catch(() => ({ status: 0, location: null, html: '' }));
165 +check('used recovery code rejected / session gone', r.status !== 303 || !(r.location ?? '').includes('/collections'));
166 +
167 +// 8. wrong password attempts (logged out) + forgot/reset
168 +{
169 + const lo = await fetch(BASE + '/api/auth/logout', { method: 'POST', headers: { cookie: cookieHeader() }, redirect: 'manual' });
170 + storeCookies(lo);
171 +}
172 +for (let i = 0; i < 3; i++) await post('/login', { email, password: 'wrong-password-123' }, 'name="email"');
173 +r = await post('/forgot', { email }, 'name="email"');
174 +check('forgot returns neutral message', r.status === 200 && r.html.includes('reset code is on its way'));
175 +await sleep(300);
176 +const rcode = codeFromLog(/enter code (\d{6})/g);
177 +r = await post('/reset', { email, code: rcode, password: 'another strong passphrase 77' }, 'name="code"');
178 +check('password reset → /login?reset=1', r.status === 303 && (r.location ?? '').includes('reset=1'), `${r.status} ${r.location}`);
179 +
180 +// 9. data export
181 +r = await post('/login', { email, password: 'another strong passphrase 77', next: '/collections' }, 'name="email"');
182 +r = await post('/mfa', { method: 'totp', code: totp.generate(), trust: 'on' }, 'name="method"');
183 +check('login after reset with TOTP', r.status === 303 && (r.location ?? '').includes('/collections'), `${r.status} ${r.location}`);
184 +page = await get('/account/security');
185 +check('sign-in history shows bad password attempts', page.html.includes('bad password'));
186 +const exp = await get('/api/account/export');
187 +check('JSON export', exp.status === 200 && exp.html.includes('"collections"') && !exp.html.includes('passwordHash'));
188 +const prof = await get(`/u/${settings.html.match(/\/u\/([a-z0-9-]+)/)?.[1] ?? 'x'}`);
189 +check('public profile renders with public collection', prof.status === 200 && prof.html.includes('E2E Vault'), String(prof.status));
190 +
191 +console.log(failures ? `\n${failures} check(s) failed` : '\nAll checks passed');
192 +console.log(`account: ${email}`);
193 +process.exit(failures ? 1 : 0);
added apps/web/scripts/send-test-mail.mts +21 −0
@@ -0,0 +1,21 @@
1 +/**
2 + * Send one branded verification e-mail through the configured transport (ops check).
3 + * RESEND_API_KEY=… EMAIL_FROM="RareIndex <no-reply@rareindex.io>" pnpm exec tsx scripts/send-test-mail.mts you@example.com
4 + */
5 +import { existsSync } from 'node:fs';
6 +import path from 'node:path';
7 +import { sendMail, verificationEmail } from '@rareindex/notify';
8 +
9 +for (const f of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) {
10 + if (existsSync(f)) process.loadEnvFile(f);
11 +}
12 +
13 +const to = process.argv[2];
14 +if (!to) {
15 + console.error('usage: send-test-mail.mts <recipient>');
16 + process.exit(1);
17 +}
18 +const m = verificationEmail({ code: String(Math.floor(100000 + Math.random() * 900000)), minutes: 10 });
19 +const r = await sendMail({ to, ...m, subject: `[RareIndex test] ${m.subject}`, tags: [{ name: 'kind', value: 'verify-test' }] });
20 +console.log(JSON.stringify(r));
21 +process.exit(r.ok ? 0 : 1);
added apps/web/src/app/(account)/account/api-keys/create-key-form.tsx +36 −0
@@ -0,0 +1,36 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { createApiKeyAction } from '@/lib/auth/account-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function CreateKeyForm() {
9 + const [state, action] = useActionState(createApiKeyAction, idle);
10 + const [copied, setCopied] = useState(false);
11 + const secret = state.data?.secret as string | undefined;
12 + return (
13 + <form action={action} className="space-y-3" noValidate>
14 + <FormMessage state={state} />
15 + {secret ? (
16 + <div className="rounded-md border border-alert/40 bg-alert-bg p-3">
17 + <p className="mono-num break-all text-xs">{secret}</p>
18 + <button
19 + type="button"
20 + className="mt-2 h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium"
21 + onClick={async () => {
22 + await navigator.clipboard.writeText(secret);
23 + setCopied(true);
24 + }}
25 + >
26 + {copied ? 'Copied' : 'Copy key'}
27 + </button>
28 + </div>
29 + ) : null}
30 + <Field label="Key name" name="name" error={state.fieldErrors?.name}>
31 + <TextInput name="name" placeholder="e.g. Research notebook" maxLength={60} required />
32 + </Field>
33 + <SubmitButton pendingText="Creating…">Create key</SubmitButton>
34 + </form>
35 + );
36 +}
added apps/web/src/app/(account)/account/api-keys/page.tsx +76 −0
@@ -0,0 +1,76 @@
1 +import type { Metadata } from 'next';
2 +import { desc, eq } from '@/lib/db';
3 +import { db, apiKeys } from '@/lib/db';
4 +import { requireUser } from '@/lib/auth/session';
5 +import { revokeApiKeyAction } from '@/lib/auth/account-actions';
6 +import { PageHeader, btnDanger } from '@/components/account/page-header';
7 +import { Card, CardHeader, Badge, Table, th, td } from '@/components/ui/primitives';
8 +import { fmtRelative } from '@/lib/format';
9 +import { CreateKeyForm } from './create-key-form';
10 +
11 +export const metadata: Metadata = { title: 'API keys', robots: { index: false } };
12 +
13 +export default async function ApiKeysPage() {
14 + const u = await requireUser('/account/api-keys');
15 + const keys = await db().select().from(apiKeys).where(eq(apiKeys.userId, u.id)).orderBy(desc(apiKeys.createdAt));
16 + return (
17 + <>
18 + <PageHeader title="API keys" description="Programmatic access to RareIndex data. Keys are shown once and stored hashed. Send them as `Authorization: Bearer <key>`." />
19 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
20 + <Card>
21 + <CardHeader title="Your keys" subtitle={`${keys.filter((k) => !k.revokedAt).length} active`} />
22 + <Table>
23 + <thead>
24 + <tr>
25 + <th className={th}>Name</th>
26 + <th className={th}>Prefix</th>
27 + <th className={th}>Tier</th>
28 + <th className={th}>Limits</th>
29 + <th className={th}>Last used</th>
30 + <th className={th}></th>
31 + </tr>
32 + </thead>
33 + <tbody>
34 + {keys.map((k) => (
35 + <tr key={k.id} className={k.revokedAt ? 'opacity-50' : ''}>
36 + <td className={td}>{k.name}</td>
37 + <td className={`${td} mono-num text-xs`}>{k.prefix}…</td>
38 + <td className={td}>
39 + <Badge tone={k.tier === 'free' ? 'neutral' : 'index'}>{k.tier}</Badge>
40 + </td>
41 + <td className={`${td} text-xs text-muted`}>
42 + {k.rateLimitPerMinute}/min · {k.dailyQuota.toLocaleString()}/day
43 + </td>
44 + <td className={`${td} text-xs text-muted`}>{k.lastUsedAt ? fmtRelative(k.lastUsedAt) : 'never'}</td>
45 + <td className={td}>
46 + {k.revokedAt ? (
47 + <span className="text-xs text-subtle">revoked</span>
48 + ) : (
49 + <form action={revokeApiKeyAction}>
50 + <input type="hidden" name="keyId" value={k.id} />
51 + <button className={btnDanger}>Revoke</button>
52 + </form>
53 + )}
54 + </td>
55 + </tr>
56 + ))}
57 + {keys.length === 0 ? (
58 + <tr>
59 + <td className={td} colSpan={6}>
60 + <span className="text-muted">No keys yet. Create one to query `/v1/assets/search`, `/v1/indices` and more.</span>
61 + </td>
62 + </tr>
63 + ) : null}
64 + </tbody>
65 + </Table>
66 + </Card>
67 + <Card>
68 + <CardHeader title="Create a key" subtitle="Free tier: 30 requests/min, 1,000/day." />
69 + <div className="p-4">
70 + <CreateKeyForm />
71 + </div>
72 + </Card>
73 + </div>
74 + </>
75 + );
76 +}
added apps/web/src/app/(account)/account/data/delete-form.tsx +24 −0
@@ -0,0 +1,24 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { deleteAccountAction } from '@/lib/auth/account-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function DeleteAccountForm() {
9 + const [state, action] = useActionState(deleteAccountAction, idle);
10 + return (
11 + <form action={action} className="space-y-3" noValidate>
12 + <FormMessage state={state} />
13 + <Field label="Password" name="password">
14 + <TextInput name="password" type="password" autoComplete="current-password" required />
15 + </Field>
16 + <Field label="Type DELETE to confirm" name="confirm">
17 + <TextInput name="confirm" autoComplete="off" required placeholder="DELETE" />
18 + </Field>
19 + <SubmitButton variant="danger" pendingText="Deleting…">
20 + Delete my account
21 + </SubmitButton>
22 + </form>
23 + );
24 +}
added apps/web/src/app/(account)/account/data/page.tsx +33 −0
@@ -0,0 +1,33 @@
1 +import type { Metadata } from 'next';
2 +import { requireUser } from '@/lib/auth/session';
3 +import { PageHeader, btnSecondary } from '@/components/account/page-header';
4 +import { Card, CardHeader } from '@/components/ui/primitives';
5 +import { DeleteAccountForm } from './delete-form';
6 +
7 +export const metadata: Metadata = { title: 'Data & privacy', robots: { index: false } };
8 +
9 +export default async function DataPage() {
10 + await requireUser('/account/data');
11 + return (
12 + <>
13 + <PageHeader title="Data & privacy" description="Your collections are private by default and never sold. Export everything or delete your account at any time." />
14 + <div className="grid gap-4 lg:grid-cols-2">
15 + <Card>
16 + <CardHeader title="Export my data" subtitle="One JSON file: profile, collections, items, watchlists, alerts, saved searches, targets, notifications." />
17 + <div className="p-4">
18 + <a href="/api/account/export" className={btnSecondary} download>
19 + Download JSON export
20 + </a>
21 + <p className="mt-3 text-xs text-subtle">Per-collection CSV exports are available on each collection page.</p>
22 + </div>
23 + </Card>
24 + <Card>
25 + <CardHeader title="Delete account" subtitle="Soft-deleted immediately, permanently purged after 30 days. Signing in during the grace period cancels the deletion." />
26 + <div className="p-4">
27 + <DeleteAccountForm />
28 + </div>
29 + </Card>
30 + </div>
31 + </>
32 + );
33 +}
added apps/web/src/app/(account)/account/notifications/page.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import type { Metadata } from 'next';
2 +import { requireUser } from '@/lib/auth/session';
3 +import { PageHeader } from '@/components/account/page-header';
4 +import { Card, CardHeader } from '@/components/ui/primitives';
5 +import { PrefsForm } from './prefs-form';
6 +
7 +export const metadata: Metadata = { title: 'Notification settings', robots: { index: false } };
8 +
9 +export default async function NotificationSettingsPage() {
10 + const u = await requireUser('/account/notifications');
11 + const p = (u.preferences ?? {}) as Record<string, unknown>;
12 + return (
13 + <>
14 + <PageHeader title="Notifications" description="Choose what reaches your inbox and your e-mail. In-app notifications are always kept in your inbox." />
15 + <Card>
16 + <CardHeader title="E-mail preferences" subtitle={`Sent to ${u.email}`} />
17 + <div className="p-4">
18 + <PrefsForm prefs={{ emailAlerts: p.emailAlerts !== false, newLoginEmails: p.newLoginEmails !== false, digest: (p.digest as string) ?? 'weekly', digestWeekday: Number(p.digestWeekday ?? 1), quietStart: (p.quietStart as number | null) ?? null, quietEnd: (p.quietEnd as number | null) ?? null, marketMoves: p.marketMoves === true }} />
19 + </div>
20 + </Card>
21 + </>
22 + );
23 +}
added apps/web/src/app/(account)/account/notifications/prefs-form.tsx +69 −0
@@ -0,0 +1,69 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { updateNotificationPrefsAction } from '@/lib/auth/account-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Checkbox, Field, FormMessage, Select, SubmitButton } from '@/components/account/form';
7 +
8 +const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
9 +
10 +export function PrefsForm({ prefs }: { prefs: { emailAlerts: boolean; newLoginEmails: boolean; digest: string; digestWeekday: number; quietStart: number | null; quietEnd: number | null; marketMoves: boolean } }) {
11 + const [state, action] = useActionState(updateNotificationPrefsAction, idle);
12 + const [quiet, setQuiet] = useState(prefs.quietStart !== null);
13 + return (
14 + <form action={action} className="space-y-5" noValidate>
15 + <FormMessage state={state} />
16 + <div className="space-y-3">
17 + <Checkbox name="emailAlerts" label="E-mail me when an alert triggers" description="Price thresholds, new listings, auctions ending, record sales, population updates." defaultChecked={prefs.emailAlerts} />
18 + <Checkbox name="marketMoves" label="Market moves in my categories" description="Notify when an index I track moves more than 3% in a day." defaultChecked={prefs.marketMoves} />
19 + <Checkbox name="newLoginEmails" label="New sign-in notifications" description="An e-mail whenever your account is accessed from a new device." defaultChecked={prefs.newLoginEmails} />
20 + </div>
21 + <div className="grid gap-4 sm:grid-cols-2">
22 + <Field label="Digest" name="digest" hint="A summary of your collection, watchlist movers and deals.">
23 + <Select name="digest" defaultValue={prefs.digest}>
24 + <option value="weekly">Weekly</option>
25 + <option value="daily">Daily</option>
26 + <option value="off">Off</option>
27 + </Select>
28 + </Field>
29 + <Field label="Weekly digest day" name="digestWeekday">
30 + <Select name="digestWeekday" defaultValue={String(prefs.digestWeekday)}>
31 + {DAYS.map((d, i) => (
32 + <option key={d} value={i}>
33 + {d}
34 + </option>
35 + ))}
36 + </Select>
37 + </Field>
38 + </div>
39 + <div className="space-y-3">
40 + <label className="flex items-start gap-2.5 text-sm">
41 + <input type="checkbox" name="quiet" checked={quiet} onChange={(e) => setQuiet(e.target.checked)} className="mt-0.5 h-4 w-4" />
42 + <span>
43 + <span className="block">Quiet hours (UTC)</span>
44 + <span className="block text-xs text-muted">Hold e-mails during these hours; they are delivered afterwards.</span>
45 + </span>
46 + </label>
47 + {quiet ? (
48 + <div className="grid max-w-xs grid-cols-2 gap-3">
49 + <Field label="From" name="quietStart">
50 + <Select name="quietStart" defaultValue={String(prefs.quietStart ?? 22)}>
51 + {Array.from({ length: 24 }, (_, h) => (
52 + <option key={h} value={h}>{`${String(h).padStart(2, '0')}:00`}</option>
53 + ))}
54 + </Select>
55 + </Field>
56 + <Field label="To" name="quietEnd">
57 + <Select name="quietEnd" defaultValue={String(prefs.quietEnd ?? 7)}>
58 + {Array.from({ length: 24 }, (_, h) => (
59 + <option key={h} value={h}>{`${String(h).padStart(2, '0')}:00`}</option>
60 + ))}
61 + </Select>
62 + </Field>
63 + </div>
64 + ) : null}
65 + </div>
66 + <SubmitButton pendingText="Saving…">Save preferences</SubmitButton>
67 + </form>
68 + );
69 +}
added apps/web/src/app/(account)/account/security/forms.tsx +141 −0
@@ -0,0 +1,141 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { changePasswordAction, confirmMfaSetupAction, disableMfaAction, regenerateRecoveryCodesAction, startMfaSetupAction } from '@/lib/auth/account-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { CodeInput, Field, FormMessage, PasswordField, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function ChangePasswordForm() {
9 + const [state, action] = useActionState(changePasswordAction, idle);
10 + return (
11 + <form action={action} className="space-y-4" noValidate>
12 + <FormMessage state={state} />
13 + <Field label="Current password" name="current" error={state.fieldErrors?.current}>
14 + <TextInput name="current" type="password" autoComplete="current-password" required />
15 + </Field>
16 + <PasswordField name="password" label="New password" autoComplete="new-password" error={state.fieldErrors?.password} />
17 + <SubmitButton variant="secondary" pendingText="Updating…">
18 + Change password
19 + </SubmitButton>
20 + <p className="text-xs text-subtle">Changing your password signs out every other session.</p>
21 + </form>
22 + );
23 +}
24 +
25 +function RecoveryCodes({ codes }: { codes: string[] }) {
26 + const [copied, setCopied] = useState(false);
27 + return (
28 + <div className="rounded-md border border-alert/40 bg-alert-bg p-3">
29 + <p className="text-xs font-medium text-alert">Recovery codes — shown once. Store them somewhere safe.</p>
30 + <ul className="mono-num mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
31 + {codes.map((c) => (
32 + <li key={c}>{c}</li>
33 + ))}
34 + </ul>
35 + <div className="mt-3 flex gap-2">
36 + <button
37 + type="button"
38 + className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium"
39 + onClick={async () => {
40 + await navigator.clipboard.writeText(codes.join('\n'));
41 + setCopied(true);
42 + }}
43 + >
44 + {copied ? 'Copied' : 'Copy'}
45 + </button>
46 + <a className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium leading-8" href={`data:text/plain;charset=utf-8,${encodeURIComponent(`RareIndex recovery codes\n\n${codes.join('\n')}\n`)}`} download="rareindex-recovery-codes.txt">
47 + Download .txt
48 + </a>
49 + </div>
50 + </div>
51 + );
52 +}
53 +
54 +export function MfaSection({ enabled }: { enabled: boolean }) {
55 + const [start, startAction] = useActionState(startMfaSetupAction, idle);
56 + const [confirm, confirmAction] = useActionState(confirmMfaSetupAction, idle);
57 + const [disable, disableAction] = useActionState(disableMfaAction, idle);
58 + const [regen, regenAction] = useActionState(regenerateRecoveryCodesAction, idle);
59 + const [showDisable, setShowDisable] = useState(false);
60 + const [showRegen, setShowRegen] = useState(false);
61 + const codes = (confirm.data?.recoveryCodes as string[] | undefined) ?? (regen.data?.recoveryCodes as string[] | undefined);
62 +
63 + if (confirm.ok && codes) {
64 + return (
65 + <div className="space-y-3">
66 + <FormMessage state={confirm} />
67 + <RecoveryCodes codes={codes} />
68 + </div>
69 + );
70 + }
71 +
72 + if (!enabled) {
73 + if (start.ok && start.data) {
74 + return (
75 + <form action={confirmAction} className="space-y-4" noValidate>
76 + <FormMessage state={confirm} />
77 + <div className="flex flex-col gap-4 sm:flex-row">
78 + {/* eslint-disable-next-line @next/next/no-img-element */}
79 + <img src={String(start.data.qr)} alt="Authenticator QR code" width={180} height={180} className="rounded-md border border-border bg-white p-1" />
80 + <div className="text-xs text-muted">
81 + <p>1. Open your authenticator (1Password, Google Authenticator, Authy, iCloud Keychain…).</p>
82 + <p className="mt-1">2. Scan the QR code, or enter this key manually:</p>
83 + <p className="mono-num mt-1 break-all rounded-sm bg-inset px-2 py-1 text-[11px] text-fg">{String(start.data.secret)}</p>
84 + <p className="mt-1">3. Enter the 6-digit code it shows.</p>
85 + </div>
86 + </div>
87 + <CodeInput name="code" autoSubmit={false} label="Code from the app" />
88 + <SubmitButton pendingText="Verifying…">Enable authenticator</SubmitButton>
89 + </form>
90 + );
91 + }
92 + return (
93 + <form action={startAction} className="space-y-3">
94 + <FormMessage state={start} />
95 + <p className="text-sm text-muted">Time-based codes from an authenticator app, plus 10 single-use recovery codes. E-mail codes remain available as a fallback.</p>
96 + <SubmitButton pendingText="Preparing…">Set up authenticator</SubmitButton>
97 + </form>
98 + );
99 + }
100 +
101 + return (
102 + <div className="space-y-4">
103 + <FormMessage state={regen} />
104 + {regen.ok && codes ? <RecoveryCodes codes={codes} /> : null}
105 + <div className="flex flex-wrap gap-2">
106 + <button type="button" className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium" onClick={() => setShowRegen((s) => !s)}>
107 + New recovery codes
108 + </button>
109 + <button type="button" className="h-8 rounded-md px-2.5 text-xs font-medium text-loss hover:bg-loss-bg" onClick={() => setShowDisable((s) => !s)}>
110 + Disable authenticator
111 + </button>
112 + </div>
113 + {showRegen ? (
114 + <form action={regenAction} className="space-y-3 rounded-md border border-border p-3" noValidate>
115 + <Field label="Confirm with your password" name="password">
116 + <TextInput name="password" type="password" autoComplete="current-password" required />
117 + </Field>
118 + <SubmitButton variant="secondary" pendingText="Generating…">
119 + Generate 10 new codes
120 + </SubmitButton>
121 + </form>
122 + ) : null}
123 + {showDisable ? (
124 + <form action={disableAction} className="space-y-3 rounded-md border border-loss/30 p-3" noValidate>
125 + <FormMessage state={disable} />
126 + <div className="grid gap-3 sm:grid-cols-2">
127 + <Field label="Password" name="password">
128 + <TextInput name="password" type="password" autoComplete="current-password" required />
129 + </Field>
130 + <Field label="Authenticator code" name="code">
131 + <TextInput name="code" inputMode="numeric" autoComplete="one-time-code" maxLength={6} required className="mono-num" />
132 + </Field>
133 + </div>
134 + <SubmitButton variant="danger" pendingText="Disabling…">
135 + Disable two-step authenticator
136 + </SubmitButton>
137 + </form>
138 + ) : null}
139 + </div>
140 + );
141 +}
added apps/web/src/app/(account)/account/security/page.tsx +137 −0
@@ -0,0 +1,137 @@
1 +import type { Metadata } from 'next';
2 +import { and, desc, eq, gt, isNull } from '@/lib/db';
3 +import { db, loginEvents, sessions, trustedDevices } from '@/lib/db';
4 +import { currentSessionId, requireUser } from '@/lib/auth/session';
5 +import { deviceLabel } from '@/lib/auth/request';
6 +import { revokeDeviceAction, revokeSessionAction, signOutEverywhereAction, toggleAlwaysAskCodeAction } from '@/lib/auth/account-actions';
7 +import { PageHeader, btnDanger, btnSecondary } from '@/components/account/page-header';
8 +import { Card, CardHeader, Badge, Table, th, td } from '@/components/ui/primitives';
9 +import { fmtRelative } from '@/lib/format';
10 +import { ChangePasswordForm, MfaSection } from './forms';
11 +
12 +export const metadata: Metadata = { title: 'Security', robots: { index: false } };
13 +
14 +export default async function SecurityPage() {
15 + const u = await requireUser('/account/security');
16 + const current = await currentSessionId();
17 + const [sess, devices, logins] = await Promise.all([
18 + db().select().from(sessions).where(and(eq(sessions.userId, u.id), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date()))).orderBy(desc(sessions.lastSeenAt)),
19 + db().select().from(trustedDevices).where(and(eq(trustedDevices.userId, u.id), isNull(trustedDevices.revokedAt), gt(trustedDevices.expiresAt, new Date()))).orderBy(desc(trustedDevices.lastUsedAt)),
20 + db().select().from(loginEvents).where(eq(loginEvents.userId, u.id)).orderBy(desc(loginEvents.createdAt)).limit(25),
21 + ]);
22 + return (
23 + <>
24 + <PageHeader title="Security" description="Password, two-step verification, trusted devices and sign-in history." />
25 + <div className="grid gap-4 lg:grid-cols-2">
26 + <Card>
27 + <CardHeader title="Two-step verification" subtitle={u.mfaEnabled ? 'Authenticator app enabled' : 'E-mail codes protect new devices; add an authenticator for stronger protection.'} action={<Badge tone={u.mfaEnabled ? 'gain' : 'alert'}>{u.mfaEnabled ? 'Authenticator on' : 'E-mail codes'}</Badge>} />
28 + <div className="p-4">
29 + <MfaSection enabled={u.mfaEnabled} />
30 + <form action={toggleAlwaysAskCodeAction} className="mt-5 border-t border-border pt-4">
31 + <label className="flex items-start gap-2.5 text-sm">
32 + <input type="checkbox" name="alwaysAskCode" defaultChecked={u.alwaysAskCode} className="mt-0.5 h-4 w-4" onChange={undefined} />
33 + <span>
34 + <span className="block">Ask for an e-mailed code on every new device</span>
35 + <span className="block text-xs text-muted">Applies when the authenticator is off. Trusted devices skip it for 30 days.</span>
36 + </span>
37 + </label>
38 + <button type="submit" className={`${btnSecondary} mt-3 h-8 text-xs`}>
39 + Save
40 + </button>
41 + </form>
42 + </div>
43 + </Card>
44 + <Card>
45 + <CardHeader title="Password" subtitle={u.passwordChangedAt ? `Last changed ${fmtRelative(u.passwordChangedAt)}` : 'Never changed'} />
46 + <div className="p-4">
47 + <ChangePasswordForm />
48 + </div>
49 + </Card>
50 + <Card>
51 + <CardHeader title="Active sessions" subtitle={`${sess.length} signed-in session${sess.length === 1 ? '' : 's'}`} action={
52 + <form action={signOutEverywhereAction}>
53 + <button className={btnDanger}>Sign out everywhere else</button>
54 + </form>
55 + } />
56 + <ul className="divide-y divide-border">
57 + {sess.map((s) => (
58 + <li key={s.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
59 + <div className="min-w-0">
60 + <p className="truncate">
61 + {deviceLabel(s.userAgent)} {s.id === current ? <Badge tone="index" className="ml-1">This device</Badge> : null}
62 + </p>
63 + <p className="text-xs text-subtle">
64 + {s.ip ?? 'unknown IP'} · active {fmtRelative(s.lastSeenAt ?? s.createdAt)} · expires {fmtRelative(s.expiresAt)}
65 + </p>
66 + </div>
67 + {s.id !== current ? (
68 + <form action={revokeSessionAction}>
69 + <input type="hidden" name="sessionId" value={s.id} />
70 + <button className={btnDanger}>Revoke</button>
71 + </form>
72 + ) : null}
73 + </li>
74 + ))}
75 + </ul>
76 + </Card>
77 + <Card>
78 + <CardHeader title="Trusted devices" subtitle="Devices that skip the second step for 30 days." />
79 + {devices.length === 0 ? (
80 + <p className="px-4 py-6 text-center text-xs text-muted">No trusted devices.</p>
81 + ) : (
82 + <ul className="divide-y divide-border">
83 + {devices.map((d) => (
84 + <li key={d.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
85 + <div className="min-w-0">
86 + <p className="truncate">{d.label ?? deviceLabel(d.userAgent)}</p>
87 + <p className="text-xs text-subtle">
88 + {d.ip ?? 'unknown IP'} · last used {fmtRelative(d.lastUsedAt ?? d.createdAt)} · until {d.expiresAt.toISOString().slice(0, 10)}
89 + </p>
90 + </div>
91 + <form action={revokeDeviceAction}>
92 + <input type="hidden" name="deviceId" value={d.id} />
93 + <button className={btnDanger}>Forget</button>
94 + </form>
95 + </li>
96 + ))}
97 + </ul>
98 + )}
99 + </Card>
100 + </div>
101 + <Card className="mt-4">
102 + <CardHeader title="Sign-in history" subtitle="Last 25 events." />
103 + <Table>
104 + <thead>
105 + <tr>
106 + <th className={th}>When</th>
107 + <th className={th}>Outcome</th>
108 + <th className={th}>Method</th>
109 + <th className={th}>Device</th>
110 + <th className={th}>IP</th>
111 + </tr>
112 + </thead>
113 + <tbody>
114 + {logins.map((l) => (
115 + <tr key={l.id}>
116 + <td className={td}>{fmtRelative(l.createdAt)}</td>
117 + <td className={td}>
118 + <Badge tone={l.outcome === 'success' ? 'gain' : l.outcome === 'mfa_required' ? 'neutral' : 'loss'}>{l.outcome.replace(/_/g, ' ')}</Badge>
119 + </td>
120 + <td className={td}>{l.method ?? '—'}</td>
121 + <td className={td}>{deviceLabel(l.userAgent)}</td>
122 + <td className={`${td} mono-num text-xs`}>{l.ip ?? '—'}</td>
123 + </tr>
124 + ))}
125 + {logins.length === 0 ? (
126 + <tr>
127 + <td className={td} colSpan={5}>
128 + <span className="text-muted">No sign-in events yet.</span>
129 + </td>
130 + </tr>
131 + ) : null}
132 + </tbody>
133 + </Table>
134 + </Card>
135 + </>
136 + );
137 +}
added apps/web/src/app/(account)/account/settings/avatar-form.tsx +29 −0
@@ -0,0 +1,29 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { uploadAvatarAction } from '@/lib/account/upload-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { FormMessage, SubmitButton } from '@/components/account/form';
7 +
8 +export function AvatarForm({ avatarUrl, initial }: { avatarUrl: string | null; initial: string }) {
9 + const [state, action] = useActionState(uploadAvatarAction, idle);
10 + const url = (state.data?.url as string | undefined) ?? avatarUrl;
11 + return (
12 + <form action={action} className="space-y-3">
13 + <FormMessage state={state} />
14 + <div className="flex items-center gap-3">
15 + {url ? (
16 + // eslint-disable-next-line @next/next/no-img-element
17 + <img src={url} alt="" className="h-16 w-16 rounded-full border border-border object-cover" />
18 + ) : (
19 + <span className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-accent text-xl font-semibold text-accent-fg">{initial}</span>
20 + )}
21 + <div className="text-xs text-muted">JPEG, PNG or WebP up to 2 MB. Square images look best.</div>
22 + </div>
23 + <input type="file" name="file" accept="image/jpeg,image/png,image/webp" required className="block w-full text-xs text-muted file:mr-3 file:rounded-md file:border file:border-border file:bg-elevated file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-fg" />
24 + <SubmitButton variant="secondary" pendingText="Uploading…">
25 + Upload avatar
26 + </SubmitButton>
27 + </form>
28 + );
29 +}
added apps/web/src/app/(account)/account/settings/forms.tsx +82 −0
@@ -0,0 +1,82 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { cancelChangeEmailAction, confirmChangeEmailAction, startChangeEmailAction, updateProfileAction } from '@/lib/auth/account-actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { CodeInput, Field, FormMessage, Select, SubmitButton, TextArea, TextInput } from '@/components/account/form';
7 +
8 +export function ProfileForm({ user }: { user: { name: string | null; handle: string | null; bio: string | null; displayCurrency: string } }) {
9 + const [state, action] = useActionState(updateProfileAction, idle);
10 + return (
11 + <form action={action} className="space-y-4" noValidate>
12 + <FormMessage state={state} />
13 + <div className="grid gap-4 sm:grid-cols-2">
14 + <Field label="Name" name="name" error={state.fieldErrors?.name}>
15 + <TextInput name="name" defaultValue={user.name ?? ''} maxLength={80} autoComplete="name" />
16 + </Field>
17 + <Field label="Public handle" name="handle" error={state.fieldErrors?.handle} hint="3–24 chars, letters/numbers/hyphens. Unlocks /u/handle.">
18 + <div className="flex items-center">
19 + <span className="rounded-l-md border border-r-0 border-border bg-sunken px-2.5 py-2 text-sm text-subtle">/u/</span>
20 + <TextInput name="handle" id="handle" defaultValue={user.handle ?? ''} className="rounded-l-none" placeholder="yourname" autoCapitalize="off" />
21 + </div>
22 + </Field>
23 + </div>
24 + <Field label="Bio" name="bio" error={state.fieldErrors?.bio} hint="Up to 280 characters.">
25 + <TextArea name="bio" defaultValue={user.bio ?? ''} maxLength={280} placeholder="What do you collect?" />
26 + </Field>
27 + <Field label="Display currency" name="displayCurrency" hint="Historical sales keep their native currency; this only changes the display conversion.">
28 + <Select name="displayCurrency" defaultValue={user.displayCurrency} className="sm:max-w-[200px]">
29 + {['USD', 'CAD', 'EUR', 'GBP', 'JPY'].map((c) => (
30 + <option key={c} value={c}>
31 + {c}
32 + </option>
33 + ))}
34 + </Select>
35 + </Field>
36 + <SubmitButton pendingText="Saving…">Save profile</SubmitButton>
37 + </form>
38 + );
39 +}
40 +
41 +export function ChangeEmailForm({ pendingEmail }: { pendingEmail: string | null }) {
42 + const [start, startAction] = useActionState(startChangeEmailAction, idle);
43 + const [confirm, confirmAction] = useActionState(confirmChangeEmailAction, idle);
44 + const pending = (start.data?.pendingEmail as string | undefined) ?? pendingEmail;
45 + if (pending && !confirm.ok) {
46 + return (
47 + <div className="space-y-4">
48 + <p className="text-sm text-muted">
49 + Enter the code we sent to <span className="font-medium text-fg">{pending}</span>.
50 + </p>
51 + <form action={confirmAction} className="space-y-3" noValidate>
52 + <FormMessage state={confirm} />
53 + <CodeInput name="code" autoSubmit={false} label="Confirmation code" />
54 + <div className="flex gap-2">
55 + <SubmitButton pendingText="Confirming…">Confirm new e-mail</SubmitButton>
56 + </div>
57 + </form>
58 + <form action={cancelChangeEmailAction}>
59 + <SubmitButton variant="ghost" className="h-8 text-xs">
60 + Cancel change
61 + </SubmitButton>
62 + </form>
63 + </div>
64 + );
65 + }
66 + return (
67 + <form action={startAction} className="space-y-4" noValidate>
68 + <FormMessage state={confirm.ok ? confirm : start} />
69 + <div className="grid gap-4 sm:grid-cols-2">
70 + <Field label="New e-mail" name="newEmail" error={start.fieldErrors?.newEmail}>
71 + <TextInput name="newEmail" type="email" autoComplete="email" required />
72 + </Field>
73 + <Field label="Current password" name="password" error={start.fieldErrors?.password}>
74 + <TextInput name="password" type="password" autoComplete="current-password" required />
75 + </Field>
76 + </div>
77 + <SubmitButton variant="secondary" pendingText="Sending code…">
78 + Send confirmation code
79 + </SubmitButton>
80 + </form>
81 + );
82 +}
added apps/web/src/app/(account)/account/settings/page.tsx +54 −0
@@ -0,0 +1,54 @@
1 +import type { Metadata } from 'next';
2 +import { requireUser } from '@/lib/auth/session';
3 +import { PageHeader } from '@/components/account/page-header';
4 +import { Card, CardHeader } from '@/components/ui/primitives';
5 +import { ProfileForm, ChangeEmailForm } from './forms';
6 +import { AvatarForm } from './avatar-form';
7 +
8 +export const metadata: Metadata = { title: 'Profile settings', robots: { index: false } };
9 +
10 +export default async function SettingsPage() {
11 + const u = await requireUser('/account/settings');
12 + return (
13 + <>
14 + <PageHeader title="Profile" description="Your name, public handle, bio and display currency." />
15 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
16 + <div className="space-y-4">
17 + <Card>
18 + <CardHeader title="Identity" subtitle="Shown on your public profile if you enable it." />
19 + <div className="p-4">
20 + <ProfileForm user={{ name: u.name, handle: u.handle, bio: u.bio, displayCurrency: u.displayCurrency }} />
21 + </div>
22 + </Card>
23 + <Card>
24 + <CardHeader title="E-mail address" subtitle={`Signed in as ${u.email}`} />
25 + <div className="p-4">
26 + <ChangeEmailForm pendingEmail={u.pendingEmail} />
27 + </div>
28 + </Card>
29 + </div>
30 + <div className="space-y-4">
31 + <Card>
32 + <CardHeader title="Avatar" />
33 + <div className="p-4">
34 + <AvatarForm avatarUrl={u.avatarUrl} initial={(u.name ?? u.email).slice(0, 1).toUpperCase()} />
35 + </div>
36 + </Card>
37 + <Card>
38 + <CardHeader title="Membership" />
39 + <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs">
40 + <dt className="text-subtle">Plan</dt>
41 + <dd className="capitalize">{u.role === 'user' ? 'Free' : u.role}</dd>
42 + <dt className="text-subtle">Member since</dt>
43 + <dd>{u.createdAt.toISOString().slice(0, 10)}</dd>
44 + <dt className="text-subtle">E-mail verified</dt>
45 + <dd>{u.emailVerifiedAt ? 'Yes' : 'No'}</dd>
46 + <dt className="text-subtle">Authenticator</dt>
47 + <dd>{u.mfaEnabled ? 'Enabled' : 'Off'}</dd>
48 + </dl>
49 + </Card>
50 + </div>
51 + </div>
52 + </>
53 + );
54 +}
added apps/web/src/app/(account)/alerts/alert-form.tsx +103 −0
@@ -0,0 +1,103 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { createAlertAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, Select, SubmitButton, TextInput } from '@/components/account/form';
7 +import { AssetPicker } from '@/components/account/asset-picker';
8 +import { CATEGORIES, INDICES } from '@rareindex/taxonomy';
9 +
10 +const TYPES: Array<{ v: string; l: string; targets: Array<'asset' | 'category' | 'index'>; threshold?: 'usd' | 'pct' }> = [
11 + { v: 'price_below', l: 'RIV falls below a value', targets: ['asset'], threshold: 'usd' },
12 + { v: 'price_above', l: 'RIV rises above a value', targets: ['asset'], threshold: 'usd' },
13 + { v: 'new_listing', l: 'New listing appears', targets: ['asset'] },
14 + { v: 'new_auction', l: 'New auction lot', targets: ['asset', 'category'] },
15 + { v: 'auction_ending', l: 'Auction ending within 24h', targets: ['asset', 'category'] },
16 + { v: 'record_sale', l: 'New record (all-time high) sale', targets: ['asset', 'category'] },
17 + { v: 'unusual_volume', l: 'Unusual sales volume (% above 90-day average)', targets: ['asset', 'category'], threshold: 'pct' },
18 + { v: 'market_move', l: 'Index / category moves more than %', targets: ['category', 'index'], threshold: 'pct' },
19 + { v: 'rare_item', l: 'Rare item appears (Rare Radar)', targets: ['category'] },
20 + { v: 'population_update', l: 'Grading population changes', targets: ['asset'] },
21 +];
22 +
23 +export function AlertForm() {
24 + const [state, action] = useActionState(createAlertAction, idle);
25 + const [type, setType] = useState('price_below');
26 + const def = TYPES.find((t) => t.v === type)!;
27 + const [target, setTarget] = useState<'asset' | 'category' | 'index'>(def.targets[0]!);
28 + const effTarget = def.targets.includes(target) ? target : def.targets[0]!;
29 + return (
30 + <form action={action} className="space-y-3" noValidate key={state.ok ? 'reset' : 'form'}>
31 + <FormMessage state={state} />
32 + <Field label="When" name="alertType">
33 + <Select name="alertType" value={type} onChange={(e) => setType(e.target.value)}>
34 + {TYPES.map((t) => (
35 + <option key={t.v} value={t.v}>
36 + {t.l}
37 + </option>
38 + ))}
39 + </Select>
40 + </Field>
41 + {def.targets.length > 1 ? (
42 + <div className="flex gap-1 text-xs">
43 + {def.targets.map((t) => (
44 + <button key={t} type="button" onClick={() => setTarget(t)} className={`rounded-full border px-3 py-1 ${effTarget === t ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted'}`}>
45 + {t}
46 + </button>
47 + ))}
48 + </div>
49 + ) : null}
50 + <input type="hidden" name="targetType" value={effTarget} />
51 + {effTarget === 'asset' ? (
52 + <AssetPicker name="targetId" withVariant={false} error={state.fieldErrors?.targetId} />
53 + ) : effTarget === 'category' ? (
54 + <Field label="Category" name="targetId">
55 + <Select name="targetId" defaultValue="pokemon">
56 + {CATEGORIES.map((c) => (
57 + <option key={c.slug} value={c.slug}>
58 + {'— '.repeat(c.level)}
59 + {c.name}
60 + </option>
61 + ))}
62 + </Select>
63 + </Field>
64 + ) : (
65 + <Field label="Index" name="targetId">
66 + <Select name="targetId" defaultValue="RARE">
67 + {INDICES.map((i) => (
68 + <option key={i.ticker} value={i.ticker}>
69 + {i.ticker} — {i.name}
70 + </option>
71 + ))}
72 + </Select>
73 + </Field>
74 + )}
75 + {def.threshold ? (
76 + <Field label={def.threshold === 'usd' ? 'Threshold (USD)' : 'Threshold (%)'} name="threshold" error={state.fieldErrors?.threshold}>
77 + <TextInput name="threshold" type="number" min={0} step={def.threshold === 'usd' ? '0.01' : '0.5'} required placeholder={def.threshold === 'usd' ? '2500' : '5'} />
78 + </Field>
79 + ) : null}
80 + <div className="grid grid-cols-2 gap-3">
81 + <Field label="Deliver via" name="channel">
82 + <Select name="channel" defaultValue="both">
83 + <option value="both">Inbox + e-mail</option>
84 + <option value="inapp">Inbox only</option>
85 + <option value="email">E-mail only</option>
86 + </Select>
87 + </Field>
88 + <Field label="Cooldown" name="cooldownMinutes" hint="Minimum time between triggers.">
89 + <Select name="cooldownMinutes" defaultValue="1440">
90 + <option value="60">1 hour</option>
91 + <option value="360">6 hours</option>
92 + <option value="1440">1 day</option>
93 + <option value="10080">1 week</option>
94 + </Select>
95 + </Field>
96 + </div>
97 + <Field label="Name (optional)" name="name">
98 + <TextInput name="name" maxLength={80} placeholder="Defaults to the asset title" />
99 + </Field>
100 + <SubmitButton pendingText="Creating…">Create alert</SubmitButton>
101 + </form>
102 + );
103 +}
added apps/web/src/app/(account)/alerts/page.tsx +111 −0
@@ -0,0 +1,111 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { listAlerts } from '@/lib/account/queries';
5 +import { getDisplay } from '@/lib/account/display';
6 +import { deleteAlertAction, toggleAlertAction } from '@/lib/account/actions';
7 +import { PageHeader, btnDanger, btnGhost } from '@/components/account/page-header';
8 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
9 +import { fmtRelative } from '@/lib/format';
10 +import { AlertForm } from './alert-form';
11 +
12 +export const metadata: Metadata = { title: 'Alerts', robots: { index: false } };
13 +
14 +const ALERT_LABELS: Record<string, string> = {
15 + price_below: 'RIV falls below',
16 + price_above: 'RIV rises above',
17 + new_listing: 'New listing',
18 + new_auction: 'New auction lot',
19 + auction_ending: 'Auction ending soon',
20 + record_sale: 'New record sale',
21 + unusual_volume: 'Unusual volume',
22 + market_move: 'Market move',
23 + rare_item: 'Rare item appears',
24 + population_update: 'Population update',
25 +};
26 +
27 +export default async function AlertsPage() {
28 + const u = await requireUser('/alerts');
29 + const d = await getDisplay();
30 + const rows = await listAlerts(u.id);
31 + const prefs = (u.preferences ?? {}) as { emailAlerts?: boolean };
32 + return (
33 + <>
34 + <PageHeader title="Alerts" description="Evaluated continuously by the RareIndex worker against valuations, listings, sales, auctions, radar findings and population reports. Delivered to your inbox and, if enabled, by e-mail." />
35 + {prefs.emailAlerts === false ? (
36 + <p className="mb-4 text-xs text-alert">
37 + E-mail delivery is off in{' '}
38 + <Link href="/account/notifications" className="underline">
39 + notification settings
40 + </Link>
41 + ; alerts will only appear in your inbox.
42 + </p>
43 + ) : null}
44 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
45 + <Card>
46 + <CardHeader title={`Your alerts · ${rows.length}`} />
47 + {rows.length === 0 ? (
48 + <EmptyState title="No alerts yet" description="Create one on the right — for example, be told when the RIV of a card you want drops below your budget, or when a new listing appears." />
49 + ) : (
50 + <Table>
51 + <thead>
52 + <tr>
53 + <th className={th}>Target</th>
54 + <th className={th}>Condition</th>
55 + <th className={`${th} text-right`}>Threshold</th>
56 + <th className={th}>Channel</th>
57 + <th className={th}>Last triggered</th>
58 + <th className={`${th} text-right`}>Count</th>
59 + <th className={th}></th>
60 + </tr>
61 + </thead>
62 + <tbody>
63 + {rows.map(({ alert: a, asset }) => (
64 + <tr key={a.id} className={a.active ? '' : 'opacity-50'}>
65 + <td className={td}>
66 + <div className="max-w-[240px]">
67 + {asset ? (
68 + <Link href={`/asset/${asset.slug}`} className="block truncate font-medium hover:underline">
69 + {asset.title}
70 + </Link>
71 + ) : (
72 + <span className="font-medium">{a.name ?? a.targetId}</span>
73 + )}
74 + <p className="text-[11px] text-subtle">{a.targetType}</p>
75 + </div>
76 + </td>
77 + <td className={td}>{ALERT_LABELS[a.alertType] ?? a.alertType}</td>
78 + <td className={tdNum}>{a.threshold !== null ? (a.alertType === 'market_move' || a.alertType === 'unusual_volume' ? `${a.threshold}%` : d.money(a.threshold)) : '—'}</td>
79 + <td className={td}>
80 + <Badge tone="neutral">{a.channel}</Badge>
81 + </td>
82 + <td className={`${td} text-xs text-muted`}>{a.lastTriggeredAt ? fmtRelative(a.lastTriggeredAt) : 'never'}</td>
83 + <td className={tdNum}>{a.triggerCount}</td>
84 + <td className={td}>
85 + <div className="flex gap-1">
86 + <form action={toggleAlertAction}>
87 + <input type="hidden" name="alertId" value={a.id} />
88 + <button className={btnGhost}>{a.active ? 'Pause' : 'Resume'}</button>
89 + </form>
90 + <form action={deleteAlertAction}>
91 + <input type="hidden" name="alertId" value={a.id} />
92 + <button className={btnDanger}>Delete</button>
93 + </form>
94 + </div>
95 + </td>
96 + </tr>
97 + ))}
98 + </tbody>
99 + </Table>
100 + )}
101 + </Card>
102 + <Card>
103 + <CardHeader title="New alert" />
104 + <div className="p-4">
105 + <AlertForm />
106 + </div>
107 + </Card>
108 + </div>
109 + </>
110 + );
111 +}
added apps/web/src/app/(account)/collections/[id]/add-item-form.tsx +120 −0
@@ -0,0 +1,120 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { addItemAction, updateItemAction } from '@/lib/account/actions';
5 +import { idle, type ActionState } from '@/lib/auth/state';
6 +import { Field, FormMessage, Select, SubmitButton, TextArea, TextInput } from '@/components/account/form';
7 +import { AssetPicker, type PickedAsset } from '@/components/account/asset-picker';
8 +
9 +const CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD'];
10 +const GRADERS = ['', 'psa', 'bgs', 'cgc', 'sgc', 'tag', 'ace', 'cbcs', 'wata', 'vga', 'pcgs', 'ngc'];
11 +
12 +export interface ItemInitial {
13 + itemId: string;
14 + asset: PickedAsset;
15 + quantity: number;
16 + acquiredAt: string | null;
17 + purchasePrice: number | null;
18 + purchaseCurrency: string | null;
19 + source: string | null;
20 + grader: string | null;
21 + grade: string | null;
22 + certificationNumber: string | null;
23 + serial: string | null;
24 + condition: string | null;
25 + notes: string | null;
26 + tags: string[];
27 + manualValueUsd: number | null;
28 + soldAt: string | null;
29 + soldPriceUsd: number | null;
30 +}
31 +
32 +export function AddItemForm({ collectionId }: { collectionId: string }) {
33 + const [state, action] = useActionState(addItemAction, idle);
34 + return <ItemFields state={state} action={action} collectionId={collectionId} submitLabel="Add to collection" />;
35 +}
36 +
37 +export function EditItemForm({ initial }: { initial: ItemInitial }) {
38 + const [state, action] = useActionState(updateItemAction, idle);
39 + return <ItemFields state={state} action={action} initial={initial} submitLabel="Save item" />;
40 +}
41 +
42 +function ItemFields({ state, action, collectionId, initial, submitLabel }: { state: ActionState; action: (fd: FormData) => void; collectionId?: string; initial?: ItemInitial; submitLabel: string }) {
43 + return (
44 + <form action={action} className="space-y-4" noValidate key={state.ok && !initial ? String(state.data?.itemId) : 'form'}>
45 + {collectionId ? <input type="hidden" name="collectionId" value={collectionId} /> : null}
46 + {initial ? <input type="hidden" name="itemId" value={initial.itemId} /> : null}
47 + <FormMessage state={state} />
48 + {initial ? <input type="hidden" name="assetId" value={initial.asset.id} /> : null}
49 + <AssetPicker initial={initial?.asset ?? null} error={state.fieldErrors?.assetId} withVariant={!initial} />
50 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
51 + <Field label="Quantity" name="quantity" error={state.fieldErrors?.quantity}>
52 + <TextInput name="quantity" type="number" min={1} max={10000} defaultValue={initial?.quantity ?? 1} required />
53 + </Field>
54 + <Field label="Acquired on" name="acquiredAt" error={state.fieldErrors?.acquiredAt}>
55 + <TextInput name="acquiredAt" type="date" defaultValue={initial?.acquiredAt ?? ''} max={new Date().toISOString().slice(0, 10)} />
56 + </Field>
57 + <Field label="Purchase price (per unit)" name="purchasePrice" error={state.fieldErrors?.purchasePrice}>
58 + <TextInput name="purchasePrice" type="number" min={0} step="0.01" defaultValue={initial?.purchasePrice ?? ''} placeholder="0.00" />
59 + </Field>
60 + <Field label="Currency" name="purchaseCurrency" hint="Converted to USD at the acquisition-date rate.">
61 + <Select name="purchaseCurrency" defaultValue={initial?.purchaseCurrency ?? 'USD'}>
62 + {CURRENCIES.map((c) => (
63 + <option key={c} value={c}>
64 + {c}
65 + </option>
66 + ))}
67 + </Select>
68 + </Field>
69 + </div>
70 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
71 + <Field label="Grader" name="grader" hint="Used when no variant is selected.">
72 + <Select name="grader" defaultValue={initial?.grader ?? ''}>
73 + {GRADERS.map((g) => (
74 + <option key={g} value={g}>
75 + {g ? g.toUpperCase() : 'Raw / none'}
76 + </option>
77 + ))}
78 + </Select>
79 + </Field>
80 + <Field label="Grade" name="grade">
81 + <TextInput name="grade" defaultValue={initial?.grade ?? ''} placeholder="10, 9.5, 9.8, MS65…" maxLength={12} />
82 + </Field>
83 + <Field label="Certification #" name="certificationNumber">
84 + <TextInput name="certificationNumber" defaultValue={initial?.certificationNumber ?? ''} maxLength={40} className="mono-num" />
85 + </Field>
86 + <Field label="Serial / reference" name="serial">
87 + <TextInput name="serial" defaultValue={initial?.serial ?? ''} maxLength={60} className="mono-num" />
88 + </Field>
89 + </div>
90 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
91 + <Field label="Condition" name="condition">
92 + <TextInput name="condition" defaultValue={initial?.condition ?? ''} placeholder="sealed, CIB, unworn, full set…" maxLength={40} />
93 + </Field>
94 + <Field label="Source" name="source">
95 + <TextInput name="source" defaultValue={initial?.source ?? ''} placeholder="eBay, Heritage, local show…" maxLength={120} />
96 + </Field>
97 + <Field label="Tags" name="tags" hint="Comma-separated, up to 12.">
98 + <TextInput name="tags" defaultValue={initial?.tags.join(', ') ?? ''} placeholder="grail, sealed, gift" maxLength={200} />
99 + </Field>
100 + <Field label="Manual value (USD)" name="manualValueUsd" hint="Fallback only when RareIndex has no valuation; never shown as RIV.">
101 + <TextInput name="manualValueUsd" type="number" min={0} step="0.01" defaultValue={initial?.manualValueUsd ?? ''} />
102 + </Field>
103 + </div>
104 + {initial ? (
105 + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
106 + <Field label="Sold on (optional)" name="soldAt">
107 + <TextInput name="soldAt" type="date" defaultValue={initial.soldAt ?? ''} />
108 + </Field>
109 + <Field label="Sold price (USD)" name="soldPriceUsd">
110 + <TextInput name="soldPriceUsd" type="number" min={0} step="0.01" defaultValue={initial.soldPriceUsd ?? ''} />
111 + </Field>
112 + </div>
113 + ) : null}
114 + <Field label="Notes" name="notes">
115 + <TextArea name="notes" defaultValue={initial?.notes ?? ''} maxLength={2000} placeholder="Provenance, storage location, insurance reference…" className="min-h-[70px]" />
116 + </Field>
117 + <SubmitButton pendingText="Saving…">{submitLabel}</SubmitButton>
118 + </form>
119 + );
120 +}
added apps/web/src/app/(account)/collections/[id]/import/import-form.tsx +26 −0
@@ -0,0 +1,26 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { importCsvAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { FormMessage, SubmitButton } from '@/components/account/form';
7 +
8 +export function ImportForm({ collectionId }: { collectionId: string }) {
9 + const [state, action] = useActionState(importCsvAction, idle);
10 + const errors = (state.data?.errors as string[] | undefined) ?? [];
11 + return (
12 + <form action={action} className="space-y-3">
13 + <input type="hidden" name="collectionId" value={collectionId} />
14 + <FormMessage state={state} />
15 + {errors.length ? (
16 + <ul className="rounded-md border border-alert/30 bg-alert-bg p-3 text-xs text-alert">
17 + {errors.map((e) => (
18 + <li key={e}>{e}</li>
19 + ))}
20 + </ul>
21 + ) : null}
22 + <input type="file" name="file" accept=".csv,text/csv" required className="block w-full text-xs text-muted file:mr-3 file:rounded-md file:border file:border-border file:bg-elevated file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-fg" />
23 + <SubmitButton pendingText="Importing…">Import</SubmitButton>
24 + </form>
25 + );
26 +}
added apps/web/src/app/(account)/collections/[id]/import/page.tsx +64 −0
@@ -0,0 +1,64 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { requireUser } from '@/lib/auth/session';
5 +import { getCollection } from '@/lib/account/queries';
6 +import { PageHeader, btnSecondary } from '@/components/account/page-header';
7 +import { Card, CardHeader } from '@/components/ui/primitives';
8 +import { ImportForm } from './import-form';
9 +
10 +export const metadata: Metadata = { title: 'Import CSV', robots: { index: false } };
11 +
12 +const TEMPLATE = 'asset_id,asset_slug,quantity,acquired_at,purchase_price,currency,grader,grade,cert,serial,source,condition,notes,tags\n,1999-base-set-charizard-4-102-1st-edition,1,2021-05-14,3200,USD,psa,10,12345678,,eBay,,bought from a friend,grail|sealed\n';
13 +
14 +export default async function ImportPage({ params }: { params: Promise<{ id: string }> }) {
15 + const { id } = await params;
16 + const u = await requireUser(`/collections/${id}/import`);
17 + const col = await getCollection(u.id, id);
18 + if (!col) notFound();
19 + return (
20 + <>
21 + <nav className="mb-2 text-xs text-muted">
22 + <Link href={`/collections/${id}`} className="hover:text-fg">
23 + {col.name}
24 + </Link>{' '}
25 + / <span className="text-fg">Import</span>
26 + </nav>
27 + <PageHeader title="Import items from CSV" description="Up to 500 rows per file. Each row needs an asset_id (rare_…) or asset_slug from RareIndex; unknown assets are skipped and reported." actions={<a href={`data:text/csv;charset=utf-8,${encodeURIComponent(TEMPLATE)}`} download="rareindex-import-template.csv" className={btnSecondary}>Download template</a>} />
28 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
29 + <Card>
30 + <CardHeader title="Upload" />
31 + <div className="p-4">
32 + <ImportForm collectionId={id} />
33 + </div>
34 + </Card>
35 + <Card>
36 + <CardHeader title="Columns" />
37 + <ul className="space-y-1 p-4 text-xs text-muted">
38 + <li>
39 + <code>asset_id</code> or <code>asset_slug</code> — required
40 + </li>
41 + <li>
42 + <code>quantity</code> — default 1
43 + </li>
44 + <li>
45 + <code>acquired_at</code> — YYYY-MM-DD
46 + </li>
47 + <li>
48 + <code>purchase_price</code>, <code>currency</code> — per unit, converted at acquisition-date FX
49 + </li>
50 + <li>
51 + <code>grader</code>, <code>grade</code>, <code>cert</code>, <code>serial</code>
52 + </li>
53 + <li>
54 + <code>source</code>, <code>condition</code>, <code>notes</code>
55 + </li>
56 + <li>
57 + <code>tags</code> — separated by <code>|</code> or commas
58 + </li>
59 + </ul>
60 + </Card>
61 + </div>
62 + </>
63 + );
64 +}
added apps/web/src/app/(account)/collections/[id]/insurance/page.tsx +87 −0
@@ -0,0 +1,87 @@
1 +import type { Metadata } from 'next';
2 +import { notFound } from 'next/navigation';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { getCollectionDetail } from '@/lib/account/queries';
5 +import { getDisplay } from '@/lib/account/display';
6 +import { confidenceLabel, fmtDate } from '@/lib/format';
7 +import { PrintButton } from './print-button';
8 +
9 +export const metadata: Metadata = { title: 'Insurance schedule', robots: { index: false } };
10 +
11 +/** Printable schedule of items with values, sources and confidence — a document, not an appraisal. */
12 +export default async function InsurancePage({ params }: { params: Promise<{ id: string }> }) {
13 + const { id } = await params;
14 + const u = await requireUser(`/collections/${id}/insurance`);
15 + const detail = await getCollectionDetail(u.id, id);
16 + if (!detail) notFound();
17 + const d = await getDisplay();
18 + const { collection: c, summary: s, items } = detail;
19 + const rows = [...s.items].sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0));
20 + return (
21 + <div className="mx-auto max-w-4xl print:max-w-none">
22 + <div className="mb-4 flex items-center justify-between print:hidden">
23 + <p className="text-sm text-muted">Use your browser&apos;s print dialog to save as PDF.</p>
24 + <PrintButton />
25 + </div>
26 + <article className="card p-8 print:border-0 print:p-0 print:shadow-none">
27 + <header className="flex items-start justify-between border-b border-border pb-4">
28 + <div>
29 + <p className="text-[11px] font-semibold uppercase tracking-wider text-subtle">RareIndex · Collection schedule</p>
30 + <h1 className="mt-1 text-2xl font-semibold tracking-tight">{c.name}</h1>
31 + <p className="text-sm text-muted">
32 + Prepared for {u.name ?? u.email} · {fmtDate(new Date())} · values in {d.currency}
33 + </p>
34 + </div>
35 + <div className="text-right">
36 + <p className="text-[11px] uppercase tracking-wider text-subtle">Total estimated value</p>
37 + <p className="num text-2xl font-semibold">{s.valuedCount ? d.money(s.valueUsd) : '—'}</p>
38 + <p className="text-xs text-muted">
39 + {s.valuedCount}/{s.itemCount} items valued · confidence {confidenceLabel(s.confidence)}
40 + </p>
41 + </div>
42 + </header>
43 + <table className="mt-4 w-full text-[12px]">
44 + <thead>
45 + <tr className="border-b border-border text-left text-[10px] uppercase tracking-wider text-subtle">
46 + <th className="py-1.5 pr-2">#</th>
47 + <th className="py-1.5 pr-2">Item</th>
48 + <th className="py-1.5 pr-2">Grade / condition</th>
49 + <th className="py-1.5 pr-2">Cert / serial</th>
50 + <th className="py-1.5 pr-2 text-right">Qty</th>
51 + <th className="py-1.5 pr-2 text-right">Acquired</th>
52 + <th className="py-1.5 pr-2 text-right">Cost</th>
53 + <th className="py-1.5 pr-2 text-right">Est. value</th>
54 + <th className="py-1.5 text-right">Basis</th>
55 + </tr>
56 + </thead>
57 + <tbody>
58 + {rows.map((i, idx) => {
59 + const src = items.find((x) => x.id === i.id)!;
60 + return (
61 + <tr key={i.id} className="border-b border-border align-top">
62 + <td className="py-1.5 pr-2 text-subtle">{idx + 1}</td>
63 + <td className="py-1.5 pr-2">
64 + <p className="font-medium">{i.title}</p>
65 + <p className="text-[11px] text-muted">{i.categorySlug.replace(/_/g, ' ')}{src.source ? ` · from ${src.source}` : ''}</p>
66 + </td>
67 + <td className="py-1.5 pr-2">{src.variantLabel ?? (i.grader ? `${i.grader.toUpperCase()} ${i.grade ?? ''}` : src.condition ?? '—')}</td>
68 + <td className="py-1.5 pr-2 font-mono text-[11px]">{[src.certificationNumber, src.serial].filter(Boolean).join(' / ') || '—'}</td>
69 + <td className="num py-1.5 pr-2 text-right">{i.quantity}</td>
70 + <td className="num py-1.5 pr-2 text-right">{i.acquiredAt ?? '—'}</td>
71 + <td className="num py-1.5 pr-2 text-right">{i.costUsd === null ? '—' : d.money(i.costUsd)}</td>
72 + <td className="num py-1.5 pr-2 text-right font-medium">{i.valueUsd === null ? 'unavailable' : d.money(i.valueUsd)}</td>
73 + <td className="py-1.5 text-right text-[11px] text-muted">{i.valueSource === 'manual' ? 'owner estimate' : i.valueSource === 'none' ? '—' : `RIV · ${confidenceLabel(i.confidence).toLowerCase()}`}</td>
74 + </tr>
75 + );
76 + })}
77 + </tbody>
78 + </table>
79 + <footer className="mt-6 text-[11px] leading-relaxed text-subtle">
80 + <p>
81 + RIV (RareIndex Valuation) figures are statistical estimates derived from observed public sales and listings, each with a confidence level and sample size; they are not appraisals, authentication or a guarantee of insurable value. Items marked &quot;owner estimate&quot; use a value entered by the collection owner. Items marked &quot;unavailable&quot; have insufficient market evidence. Historical purchase costs are converted to {d.currency} at the exchange rate of the acquisition date. RareIndex does not authenticate items.
82 + </p>
83 + </footer>
84 + </article>
85 + </div>
86 + );
87 +}
added apps/web/src/app/(account)/collections/[id]/insurance/print-button.tsx +9 −0
@@ -0,0 +1,9 @@
1 +'use client';
2 +
3 +export function PrintButton() {
4 + return (
5 + <button type="button" onClick={() => window.print()} className="inline-flex h-9 items-center rounded-md bg-accent px-3 text-[13px] font-medium text-accent-fg hover:opacity-90">
6 + Print / Save as PDF
7 + </button>
8 + );
9 +}
added apps/web/src/app/(account)/collections/[id]/items/[itemId]/page.tsx +143 −0
@@ -0,0 +1,143 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { and, eq } from '@/lib/db';
5 +import { db, collections, collectionItems } from '@/lib/db';
6 +import { requireUser } from '@/lib/auth/session';
7 +import { getAssetBrief, listCollections, loadItems } from '@/lib/account/queries';
8 +import { valueItem } from '@/lib/account/portfolio';
9 +import { getDisplay } from '@/lib/account/display';
10 +import { deleteItemAction, moveItemAction, removeItemPhotoAction } from '@/lib/account/actions';
11 +import { PageHeader, btnDanger, btnSecondary } from '@/components/account/page-header';
12 +import { ValueSourceBadge } from '@/components/account/portfolio-widgets';
13 +import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';
14 +import { confidenceLabel, fmtPct } from '@/lib/format';
15 +import { EditItemForm } from '../../add-item-form';
16 +import { PhotoUploadForm } from './photo-form';
17 +
18 +export const metadata: Metadata = { title: 'Item', robots: { index: false } };
19 +
20 +export default async function ItemPage({ params }: { params: Promise<{ id: string; itemId: string }> }) {
21 + const { id, itemId } = await params;
22 + const u = await requireUser(`/collections/${id}/items/${itemId}`);
23 + const own = await db().select({ id: collectionItems.id }).from(collectionItems).innerJoin(collections, eq(collections.id, collectionItems.collectionId)).where(and(eq(collectionItems.id, itemId), eq(collections.id, id), eq(collections.userId, u.id))).limit(1);
24 + if (!own[0]) notFound();
25 + const loaded = (await loadItems([id])).find((i) => i.id === itemId);
26 + if (!loaded) notFound();
27 + const v = valueItem(loaded);
28 + const brief = await getAssetBrief(loaded.assetId);
29 + const d = await getDisplay();
30 + const others = (await listCollections(u.id)).filter((c) => c.collection.id !== id);
31 +
32 + return (
33 + <>
34 + <nav className="mb-2 text-xs text-muted">
35 + <Link href="/collections" className="hover:text-fg">
36 + Collections
37 + </Link>{' '}
38 + /{' '}
39 + <Link href={`/collections/${id}`} className="hover:text-fg">
40 + Collection
41 + </Link>{' '}
42 + / <span className="text-fg">{loaded.title}</span>
43 + </nav>
44 + <PageHeader
45 + title={loaded.title}
46 + description={
47 + <>
48 + {loaded.categorySlug.replace(/_/g, ' ')} · <ValueSourceBadge item={v} />{' '}
49 + <Link href={`/asset/${loaded.assetSlug}`} className="underline-offset-4 hover:underline">
50 + Open asset page →
51 + </Link>
52 + </>
53 + }
54 + actions={
55 + <>
56 + {others.length ? (
57 + <form action={moveItemAction} className="flex items-center gap-1">
58 + <input type="hidden" name="itemId" value={itemId} />
59 + <select name="toCollectionId" className="h-9 rounded-md border border-border bg-elevated px-2 text-[13px]" defaultValue="">
60 + <option value="" disabled>
61 + Move to…
62 + </option>
63 + {others.map((o) => (
64 + <option key={o.collection.id} value={o.collection.id}>
65 + {o.collection.name}
66 + </option>
67 + ))}
68 + </select>
69 + <button className={btnSecondary}>Move</button>
70 + </form>
71 + ) : null}
72 + <form action={deleteItemAction}>
73 + <input type="hidden" name="itemId" value={itemId} />
74 + <button className={btnDanger}>Remove item</button>
75 + </form>
76 + </>
77 + }
78 + />
79 + <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
80 + <Stat label="Current value" value={v.valueUsd === null ? '—' : d.money(v.valueUsd)} sub={v.valueUsd === null ? 'no market evidence yet' : v.confidence !== null ? `confidence ${confidenceLabel(v.confidence)}` : 'manual value'} />
81 + <Stat label="Cost" value={v.costUsd === null ? '—' : d.money(v.costUsd)} sub={loaded.purchasePriceNative !== null && loaded.acquiredCurrency && loaded.acquiredCurrency !== 'USD' ? `${loaded.purchasePriceNative} ${loaded.acquiredCurrency} at acquisition FX` : v.acquiredAt ? `acquired ${v.acquiredAt}` : 'no purchase price'} />
82 + <Stat label="Gain" value={<Delta value={v.gainPct} className="text-lg" />} sub={v.gainUsd === null ? '—' : d.money(v.gainUsd)} />
83 + <Stat label="Annualised" value={fmtPct(v.annualizedReturn)} sub={v.holdingDays !== null ? `${v.holdingDays} days held` : 'needs acquisition date'} />
84 + </div>
85 + {brief?.stats ? (
86 + <Card className="mb-5">
87 + <CardHeader title="Market context" subtitle="From the RareIndex asset record" />
88 + <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs sm:grid-cols-4">
89 + <dt className="text-subtle">Asset RIV</dt>
90 + <dd className="num">{brief.stats.rivUsd ? `${d.money(brief.stats.rivUsd)} (${brief.stats.rivSampleSize} sales)` : '—'}</dd>
91 + <dt className="text-subtle">Latest sale</dt>
92 + <dd className="num">{brief.stats.latestSaleUsd ? d.money(brief.stats.latestSaleUsd) : '—'}</dd>
93 + <dt className="text-subtle">30d / 1y</dt>
94 + <dd className="num">
95 + {fmtPct(brief.stats.change30d)} / {fmtPct(brief.stats.change1y)}
96 + </dd>
97 + <dt className="text-subtle">Liquidity · rarity</dt>
98 + <dd className="num">
99 + {brief.stats.liquidityScore ?? '—'} · {brief.stats.rarityScore ?? '—'}
100 + </dd>
101 + <dt className="text-subtle">Active listings</dt>
102 + <dd className="num">{brief.stats.activeListings}{brief.stats.minAskUsd ? ` · min ask ${d.money(brief.stats.minAskUsd)}` : ''}</dd>
103 + <dt className="text-subtle">Sales 30d</dt>
104 + <dd className="num">{brief.stats.sales30d}</dd>
105 + </dl>
106 + </Card>
107 + ) : null}
108 + <div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
109 + <Card>
110 + <CardHeader title="Details" />
111 + <div className="p-4">
112 + <EditItemForm initial={{ itemId, asset: { id: loaded.assetId, title: loaded.title, categorySlug: loaded.categorySlug, year: null, heroImageUrl: loaded.heroImageUrl, rivUsd: loaded.assetRivUsd }, quantity: loaded.quantity, acquiredAt: loaded.acquiredAt, purchasePrice: loaded.purchasePriceNative, purchaseCurrency: loaded.acquiredCurrency, source: loaded.source, grader: loaded.grader, grade: loaded.grade, certificationNumber: loaded.certificationNumber, serial: loaded.serial, condition: loaded.condition, notes: loaded.notes, tags: loaded.tags, manualValueUsd: loaded.manualValueUsd, soldAt: loaded.soldAt, soldPriceUsd: loaded.soldPriceUsd ?? null }} />
113 + </div>
114 + </Card>
115 + <Card>
116 + <CardHeader title="Photos" subtitle={`${loaded.photos.length}/12`} />
117 + <div className="space-y-3 p-4">
118 + {loaded.photos.length ? (
119 + <ul className="grid grid-cols-3 gap-2">
120 + {loaded.photos.map((p) => (
121 + <li key={p} className="group relative">
122 + {/* eslint-disable-next-line @next/next/no-img-element */}
123 + <img src={p} alt="" className="aspect-square w-full rounded-sm border border-border object-cover" />
124 + <form action={removeItemPhotoAction} className="absolute right-1 top-1">
125 + <input type="hidden" name="itemId" value={itemId} />
126 + <input type="hidden" name="url" value={p} />
127 + <button aria-label="Remove photo" className="rounded-sm bg-black/60 px-1.5 text-[10px] text-white opacity-0 group-hover:opacity-100">
128 + ✕
129 + </button>
130 + </form>
131 + </li>
132 + ))}
133 + </ul>
134 + ) : (
135 + <p className="text-xs text-subtle">No photos yet. Photos stay private unless the collection is public.</p>
136 + )}
137 + <PhotoUploadForm itemId={itemId} />
138 + </div>
139 + </Card>
140 + </div>
141 + </>
142 + );
143 +}
added apps/web/src/app/(account)/collections/[id]/items/[itemId]/photo-form.tsx +21 −0
@@ -0,0 +1,21 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { addItemPhotoAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { FormMessage, SubmitButton } from '@/components/account/form';
7 +
8 +export function PhotoUploadForm({ itemId }: { itemId: string }) {
9 + const [state, action] = useActionState(addItemPhotoAction, idle);
10 + return (
11 + <form action={action} className="space-y-2">
12 + <input type="hidden" name="itemId" value={itemId} />
13 + <FormMessage state={state} />
14 + <input type="file" name="files" accept="image/jpeg,image/png,image/webp" multiple capture="environment" className="block w-full text-xs text-muted file:mr-3 file:rounded-md file:border file:border-border file:bg-elevated file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-fg" />
15 + <SubmitButton variant="secondary" pendingText="Uploading…">
16 + Upload photos
17 + </SubmitButton>
18 + <p className="text-[11px] text-subtle">JPEG/PNG/WebP up to 8 MB each. On mobile you can take a photo directly.</p>
19 + </form>
20 + );
21 +}
added apps/web/src/app/(account)/collections/[id]/page.tsx +232 −0
@@ -0,0 +1,232 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { requireUser } from '@/lib/auth/session';
5 +import { getCollectionDetail, listCollections } from '@/lib/account/queries';
6 +import { getDisplay } from '@/lib/account/display';
7 +import { deleteCollectionAction, toggleCollectionPublicAction } from '@/lib/account/actions';
8 +import { PageHeader, btnSecondary, btnDanger, btnPrimary } from '@/components/account/page-header';
9 +import { SummaryStats, AllocationCards, ValueSourceBadge } from '@/components/account/portfolio-widgets';
10 +import { LineChart } from '@/components/account/charts';
11 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
12 +import { fmtDate, fmtPct, confidenceLabel } from '@/lib/format';
13 +import { EditCollectionForm } from '../create-form';
14 +import { AddItemForm } from './add-item-form';
15 +
16 +export const metadata: Metadata = { title: 'Collection', robots: { index: false } };
17 +
18 +export default async function CollectionPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<Record<string, string | undefined>> }) {
19 + const { id } = await params;
20 + const sp = await searchParams;
21 + const u = await requireUser(`/collections/${id}`);
22 + const detail = await getCollectionDetail(u.id, id);
23 + if (!detail) notFound();
24 + const d = await getDisplay();
25 + const { collection: c, summary: s, items, history } = detail;
26 + const others = (await listCollections(u.id)).filter((x) => x.collection.id !== id);
27 + const sort = sp.sort ?? 'value';
28 + const rows = [...s.items].sort((a, b) => {
29 + if (sort === 'gain') return (b.gainPct ?? -Infinity) - (a.gainPct ?? -Infinity);
30 + if (sort === 'name') return a.title.localeCompare(b.title);
31 + if (sort === 'recent') return (items.find((i) => i.id === b.id)?.createdAt.getTime() ?? 0) - (items.find((i) => i.id === a.id)?.createdAt.getTime() ?? 0);
32 + return (b.valueUsd ?? -1) - (a.valueUsd ?? -1);
33 + });
34 + const publicUrl = c.isPublic && u.handle && c.publicSlug ? `/u/${u.handle}/${c.publicSlug}` : null;
35 +
36 + return (
37 + <>
38 + <nav className="mb-2 text-xs text-muted">
39 + <Link href="/collections" className="hover:text-fg">
40 + Collections
41 + </Link>{' '}
42 + / <span className="text-fg">{c.name}</span>
43 + </nav>
44 + <PageHeader
45 + title={
46 + <span className="flex items-center gap-2">
47 + {c.color ? <span className="h-3 w-3 rounded-full" style={{ background: c.color }} /> : null}
48 + {c.name}
49 + {c.isPublic ? <Badge tone="index">public</Badge> : <Badge tone="neutral">private</Badge>}
50 + </span>
51 + }
52 + description={c.description ?? `${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · created ${fmtDate(c.createdAt)}`}
53 + actions={
54 + <>
55 + <a href="#add" className={btnPrimary}>
56 + Add item
57 + </a>
58 + <Link href={`/collections/${id}/import`} className={btnSecondary}>
59 + Import CSV
60 + </Link>
61 + <Link href={`/collections/${id}/insurance`} className={btnSecondary}>
62 + Insurance schedule
63 + </Link>
64 + <a href={`/api/account/collections/${id}/export?format=csv`} className={btnSecondary}>
65 + CSV
66 + </a>
67 + <a href={`/api/account/collections/${id}/export?format=json`} className={btnSecondary}>
68 + JSON
69 + </a>
70 + </>
71 + }
72 + />
73 + <SummaryStats s={s} d={d} className="mb-5" />
74 + {s.valuedCount > 0 ? (
75 + <div className="mb-5 space-y-4">
76 + <Card>
77 + <CardHeader title="Value history" subtitle={history.length ? `${history.length} daily snapshots (value vs cost basis)` : 'Snapshots are recorded daily by the RareIndex worker'} />
78 + <div className="p-4">
79 + <LineChart series={[{ name: 'Value', points: history.map((h) => ({ date: String(h.date), value: h.valueUsd * d.rate })) }, { name: 'Cost basis', points: history.map((h) => ({ date: String(h.date), value: h.costBasisUsd * d.rate })), color: 'var(--ri-fg-subtle)', dashed: true }]} height={180} formatY={(v) => d.money(v / d.rate, { compact: true })} />
80 + </div>
81 + </Card>
82 + <AllocationCards s={s} d={d} />
83 + </div>
84 + ) : null}
85 +
86 + <Card className="mb-5">
87 + <CardHeader
88 + title="Items"
89 + subtitle={`${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · ${s.unitCount} unit${s.unitCount === 1 ? '' : 's'}`}
90 + action={
91 + <div className="flex gap-1 text-xs">
92 + {[
93 + ['value', 'Value'],
94 + ['gain', 'Return'],
95 + ['recent', 'Recent'],
96 + ['name', 'Name'],
97 + ].map(([k, l]) => (
98 + <Link key={k} href={`?sort=${k}`} className={`rounded-sm px-2 py-1 ${sort === k ? 'bg-inset text-fg' : 'text-muted hover:text-fg'}`}>
99 + {l}
100 + </Link>
101 + ))}
102 + </div>
103 + }
104 + />
105 + {rows.length === 0 ? (
106 + <EmptyState title="This collection is empty" description="Add an item below by searching the RareIndex asset universe, or import a CSV." />
107 + ) : (
108 + <Table>
109 + <thead>
110 + <tr>
111 + <th className={th}>Item</th>
112 + <th className={th}>Variant</th>
113 + <th className={`${th} text-right`}>Qty</th>
114 + <th className={`${th} text-right`}>Cost</th>
115 + <th className={`${th} text-right`}>Value</th>
116 + <th className={`${th} text-right`}>Gain</th>
117 + <th className={`${th} text-right`}>30d</th>
118 + <th className={th}>Source</th>
119 + <th className={th}></th>
120 + </tr>
121 + </thead>
122 + <tbody>
123 + {rows.map((i) => {
124 + const src = items.find((x) => x.id === i.id)!;
125 + return (
126 + <tr key={i.id} className="hover:bg-sunken">
127 + <td className={td}>
128 + <div className="flex items-center gap-2.5">
129 + {src.photos[0] || src.heroImageUrl ? (
130 + // eslint-disable-next-line @next/next/no-img-element
131 + <img src={src.photos[0] ?? src.heroImageUrl ?? ''} alt="" className="h-9 w-9 rounded-sm object-cover" />
132 + ) : (
133 + <span className="h-9 w-9 rounded-sm bg-inset" />
134 + )}
135 + <div className="min-w-0 max-w-[280px]">
136 + <Link href={`/collections/${id}/items/${i.id}`} className="block truncate font-medium hover:underline">
137 + {i.title}
138 + </Link>
139 + <p className="truncate text-[11px] text-subtle">
140 + {i.categorySlug.replace(/_/g, ' ')}
141 + {i.acquiredAt ? ` · acquired ${i.acquiredAt}` : ''}
142 + {src.tags.length ? ` · ${src.tags.join(', ')}` : ''}
143 + </p>
144 + </div>
145 + </div>
146 + </td>
147 + <td className={`${td} text-xs`}>{src.variantLabel ?? (i.grader ? `${i.grader.toUpperCase()} ${i.grade ?? ''}` : src.condition ?? '—')}</td>
148 + <td className={tdNum}>{i.quantity}</td>
149 + <td className={tdNum}>{i.costUsd === null ? <span className="text-subtle">—</span> : d.money(i.costUsd)}</td>
150 + <td className={tdNum}>
151 + {i.valueUsd === null ? <span className="text-subtle" title="No market evidence yet">—</span> : <span title={i.confidence !== null ? `Confidence ${confidenceLabel(i.confidence)}` : undefined}>{d.money(i.valueUsd)}</span>}
152 + </td>
153 + <td className={tdNum}>
154 + <Delta value={i.gainPct} />
155 + </td>
156 + <td className={tdNum}>
157 + <span className="text-xs">{fmtPct(i.change30d)}</span>
158 + </td>
159 + <td className={td}>
160 + <ValueSourceBadge item={i} />
161 + </td>
162 + <td className={td}>
163 + <Link href={`/collections/${id}/items/${i.id}`} className="text-xs text-muted hover:text-fg">
164 + Edit
165 + </Link>
166 + </td>
167 + </tr>
168 + );
169 + })}
170 + </tbody>
171 + </Table>
172 + )}
173 + </Card>
174 +
175 + <div id="add" className="mb-5"><Card>
176 + <CardHeader title="Add an item" subtitle="Search the RareIndex asset universe, then record what you paid. Photos and notes can be added after saving." />
177 + <div className="p-4">
178 + <AddItemForm collectionId={id} />
179 + </div>
180 + </Card></div>
181 +
182 + <div className="grid gap-4 lg:grid-cols-2">
183 + <Card>
184 + <CardHeader title="Sharing" subtitle="Private by default. Public collections appear on your collector profile with values and photos — never purchase prices." />
185 + <div className="space-y-3 p-4 text-sm">
186 + <form action={toggleCollectionPublicAction} className="flex items-center gap-3">
187 + <input type="hidden" name="collectionId" value={id} />
188 + <label className="flex items-center gap-2">
189 + <input type="checkbox" name="public" defaultChecked={c.isPublic} className="h-4 w-4" />
190 + Make this collection public
191 + </label>
192 + <button className={btnSecondary}>Save</button>
193 + </form>
194 + {c.isPublic ? (
195 + publicUrl ? (
196 + <p className="text-xs text-muted">
197 + Public at{' '}
198 + <Link href={publicUrl} className="text-fg underline">
199 + {publicUrl}
200 + </Link>
201 + </p>
202 + ) : (
203 + <p className="text-xs text-alert">
204 + Claim a public handle in{' '}
205 + <Link href="/account/settings#handle" className="underline">
206 + profile settings
207 + </Link>{' '}
208 + to get a shareable URL.
209 + </p>
210 + )
211 + ) : null}
212 + </div>
213 + </Card>
214 + <Card>
215 + <CardHeader title="Settings" />
216 + <div className="space-y-4 p-4">
217 + <EditCollectionForm collection={{ id: c.id, name: c.name, description: c.description, kind: c.kind, budgetUsd: c.budgetUsd, color: c.color }} />
218 + <div className="flex items-center justify-between border-t border-border pt-4">
219 + <p className="text-xs text-muted">
220 + {others.length ? `Move items to another collection from each item page.` : 'Create another collection to move items between them.'}
221 + </p>
222 + <form action={deleteCollectionAction}>
223 + <input type="hidden" name="collectionId" value={id} />
224 + <button className={btnDanger}>Delete collection</button>
225 + </form>
226 + </div>
227 + </div>
228 + </Card>
229 + </div>
230 + </>
231 + );
232 +}
added apps/web/src/app/(account)/collections/create-form.tsx +56 −0
@@ -0,0 +1,56 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { createCollectionAction, updateCollectionAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, Select, SubmitButton, TextArea, TextInput } from '@/components/account/form';
7 +
8 +const KINDS = [
9 + ['collection', 'Collection'],
10 + ['wishlist', 'Wishlist (with budget)'],
11 + ['vault', 'Vault / storage'],
12 + ['sold', 'Sold archive'],
13 +] as const;
14 +
15 +export function CreateCollectionForm() {
16 + const [state, action] = useActionState(createCollectionAction, idle);
17 + return <CollectionFields state={state} action={action} submitLabel="Create collection" />;
18 +}
19 +
20 +export function EditCollectionForm({ collection }: { collection: { id: string; name: string; description: string | null; kind: string; budgetUsd: number | null; color: string | null } }) {
21 + const [state, action] = useActionState(updateCollectionAction, idle);
22 + return <CollectionFields state={state} action={action} submitLabel="Save changes" initial={collection} />;
23 +}
24 +
25 +function CollectionFields({ state, action, submitLabel, initial }: { state: import('@/lib/auth/state').ActionState; action: (fd: FormData) => void; submitLabel: string; initial?: { id: string; name: string; description: string | null; kind: string; budgetUsd: number | null; color: string | null } }) {
26 + return (
27 + <form action={action} className="space-y-4" noValidate>
28 + {initial ? <input type="hidden" name="collectionId" value={initial.id} /> : null}
29 + <FormMessage state={state} />
30 + <div className="grid gap-4 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_80px]">
31 + <Field label="Name" name="name" error={state.fieldErrors?.name}>
32 + <TextInput name="name" required maxLength={80} defaultValue={initial?.name ?? ''} placeholder="e.g. Vintage Pokémon, Watch box, 2025 pickups" />
33 + </Field>
34 + <Field label="Type" name="kind">
35 + <Select name="kind" defaultValue={initial?.kind ?? 'collection'}>
36 + {KINDS.map(([v, l]) => (
37 + <option key={v} value={v}>
38 + {l}
39 + </option>
40 + ))}
41 + </Select>
42 + </Field>
43 + <Field label="Budget (USD, optional)" name="budgetUsd" error={state.fieldErrors?.budgetUsd}>
44 + <TextInput name="budgetUsd" type="number" min={0} step="1" defaultValue={initial?.budgetUsd ?? ''} placeholder="wishlists" />
45 + </Field>
46 + <Field label="Colour" name="color">
47 + <input type="color" name="color" defaultValue={initial?.color ?? '#1f3a8a'} className="h-10 w-full cursor-pointer rounded-md border border-border bg-elevated p-1 md:h-9" />
48 + </Field>
49 + </div>
50 + <Field label="Description (optional)" name="description">
51 + <TextArea name="description" maxLength={500} defaultValue={initial?.description ?? ''} placeholder="What lives here?" className="min-h-[60px]" />
52 + </Field>
53 + <SubmitButton pendingText="Saving…">{submitLabel}</SubmitButton>
54 + </form>
55 + );
56 +}
added apps/web/src/app/(account)/collections/page.tsx +150 −0
@@ -0,0 +1,150 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { listCollections, portfolioHistory, indexSeries } from '@/lib/account/queries';
5 +import { summarizePortfolio, rebase } from '@/lib/account/portfolio';
6 +import { loadItems } from '@/lib/account/queries';
7 +import { getDisplay } from '@/lib/account/display';
8 +import { PageHeader, btnPrimary, btnSecondary } from '@/components/account/page-header';
9 +import { SummaryStats, AllocationCards } from '@/components/account/portfolio-widgets';
10 +import { LineChart } from '@/components/account/charts';
11 +import { Card, CardHeader, Delta, EmptyState, Badge } from '@/components/ui/primitives';
12 +import { fmtRelative } from '@/lib/format';
13 +import { CreateCollectionForm } from './create-form';
14 +
15 +export const metadata: Metadata = { title: 'Collections', robots: { index: false } };
16 +
17 +export default async function CollectionsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
18 + const sp = await searchParams;
19 + const u = await requireUser('/collections');
20 + const d = await getDisplay();
21 + const cols = await listCollections(u.id);
22 + const allItems = await loadItems(cols.map((c) => c.collection.id));
23 + const total = summarizePortfolio(allItems);
24 + const history = await portfolioHistory(u.id);
25 + const rare = history.length ? await indexSeries('RARE', history[0]!.date) : [];
26 + const mine = rebase(history.map((h) => ({ date: h.date, value: h.valueUsd })));
27 + const bench = rebase(rare);
28 +
29 + return (
30 + <>
31 + {sp.welcome ? (
32 + <div className="mb-5 rounded-md border border-index/30 bg-index-bg px-4 py-3 text-sm">
33 + <p className="font-medium">Welcome to RareIndex{u.name ? `, ${u.name}` : ''}.</p>
34 + <p className="mt-0.5 text-muted">Start by creating a collection and adding what you own. Every item is valued with the RareIndex Valuation (RIV) when enough market evidence exists — and marked honestly when it does not.</p>
35 + </div>
36 + ) : null}
37 + <PageHeader
38 + title="Collections"
39 + description="Your portfolio across every collection, valued with RIV and your cost basis at acquisition-date exchange rates."
40 + actions={
41 + <>
42 + <Link href="/deals" className={btnSecondary}>
43 + Deal Radar
44 + </Link>
45 + <a href="#new" className={btnPrimary}>
46 + New collection
47 + </a>
48 + </>
49 + }
50 + />
51 + {d.fallback ? <p className="mb-3 text-xs text-alert">No exchange rate loaded for your display currency yet — values are shown in USD.</p> : null}
52 + <SummaryStats s={total} d={d} className="mb-5" />
53 +
54 + <div className="mb-5 grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
55 + <Card>
56 + <CardHeader title="Portfolio value" subtitle={history.length ? `${history.length} daily snapshots · vs RARE index (rebased to 1000)` : 'Daily snapshots begin after your first valued item'} action={<Link href="/my-index" className="text-muted hover:text-fg">My Index →</Link>} />
57 + <div className="p-4">
58 + <LineChart series={[{ name: 'My portfolio', points: mine }, ...(bench.length > 1 ? [{ name: 'RARE', points: bench, color: 'var(--ri-fg-subtle)', dashed: true }] : [])]} height={200} />
59 + {history.length ? (
60 + <div className="mt-2 flex flex-wrap gap-4 text-xs text-muted">
61 + <span>
62 + <span className="mr-1 inline-block h-2 w-3 rounded-sm bg-index align-middle" /> My portfolio
63 + </span>
64 + {bench.length > 1 ? (
65 + <span>
66 + <span className="mr-1 inline-block h-0.5 w-3 border-t border-dashed border-subtle align-middle" /> RARE Global Collectibles Index
67 + </span>
68 + ) : (
69 + <span className="text-subtle">RARE index history is not available yet for this period.</span>
70 + )}
71 + </div>
72 + ) : null}
73 + </div>
74 + </Card>
75 + <Card>
76 + <CardHeader title="At a glance" />
77 + <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs">
78 + <dt className="text-subtle">Collections</dt>
79 + <dd className="num text-right">{cols.length}</dd>
80 + <dt className="text-subtle">Items · units</dt>
81 + <dd className="num text-right">
82 + {total.itemCount} · {total.unitCount}
83 + </dd>
84 + <dt className="text-subtle">Categories</dt>
85 + <dd className="num text-right">{total.allocationByCategory.length}</dd>
86 + <dt className="text-subtle">Awaiting valuation</dt>
87 + <dd className="num text-right">{total.unvaluedCount}</dd>
88 + <dt className="text-subtle">Display currency</dt>
89 + <dd className="text-right">{d.currency}</dd>
90 + </dl>
91 + <div className="border-t border-border p-4 text-xs text-muted">
92 + RIV = RareIndex Valuation. Each item shows whether its value comes from a graded-variant RIV, the asset-level RIV, or a manual value you entered. Nothing is invented: items without market evidence stay unvalued.
93 + </div>
94 + </Card>
95 + </div>
96 +
97 + {total.valuedCount > 0 ? <div className="mb-5"><AllocationCards s={total} d={d} /></div> : null}
98 +
99 + <section className="mb-6">
100 + <h2 className="mb-3 text-base font-semibold tracking-tight">Your collections</h2>
101 + {cols.length === 0 ? (
102 + <Card>
103 + <EmptyState title="No collections yet" description="Create your first collection below. You can keep separate collections per category, per vault, or a wishlist with a budget." />
104 + </Card>
105 + ) : (
106 + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
107 + {cols.map(({ collection: c, summary: s }) => (
108 + <Link key={c.id} href={`/collections/${c.id}`} className="card block p-4 transition hover:border-border-strong">
109 + <div className="flex items-start justify-between gap-2">
110 + <div className="min-w-0">
111 + <p className="flex items-center gap-2 truncate text-sm font-semibold">
112 + {c.color ? <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: c.color }} /> : null}
113 + {c.name}
114 + </p>
115 + <p className="mt-0.5 text-xs text-muted">
116 + {s.itemCount} item{s.itemCount === 1 ? '' : 's'} · updated {fmtRelative(c.updatedAt)}
117 + </p>
118 + </div>
119 + <div className="flex gap-1">
120 + {c.kind !== 'collection' ? <Badge tone="neutral">{c.kind}</Badge> : null}
121 + {c.isPublic ? <Badge tone="index">public</Badge> : null}
122 + </div>
123 + </div>
124 + <div className="mt-3 flex items-end justify-between">
125 + <div>
126 + <p className="num text-lg font-semibold">{s.valuedCount ? d.money(s.valueUsd) : '—'}</p>
127 + <p className="text-[11px] text-subtle">{s.valuedCount ? `${s.valuedCount}/${s.itemCount} valued` : 'no valuation yet'}</p>
128 + </div>
129 + <Delta value={s.returnPct} />
130 + </div>
131 + {c.kind === 'wishlist' && c.budgetUsd ? (
132 + <p className="mt-2 text-[11px] text-muted">
133 + Budget {d.money(c.budgetUsd)} · wishlist value {d.money(s.valueUsd)}
134 + </p>
135 + ) : null}
136 + </Link>
137 + ))}
138 + </div>
139 + )}
140 + </section>
141 +
142 + <div id="new"><Card>
143 + <CardHeader title="New collection" subtitle="Private by default. You can share it publicly later from its page." />
144 + <div className="p-4">
145 + <CreateCollectionForm />
146 + </div>
147 + </Card></div>
148 + </>
149 + );
150 +}
added apps/web/src/app/(account)/deals/page.tsx +98 −0
@@ -0,0 +1,98 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { dealRadar } from '@/lib/account/queries';
5 +import { getDisplay } from '@/lib/account/display';
6 +import { PageHeader, btnSecondary } from '@/components/account/page-header';
7 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
8 +import { fmtPct, fmtRelative, confidenceLabel } from '@/lib/format';
9 +
10 +export const metadata: Metadata = { title: 'Deal Radar', robots: { index: false } };
11 +
12 +export default async function DealsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
13 + const sp = await searchParams;
14 + const u = await requireUser('/deals');
15 + const d = await getDisplay();
16 + const min = Math.min(0.9, Math.max(0.05, Number(sp.min ?? 15) / 100));
17 + const rows = await dealRadar(u.id, { minDiscount: min, limit: 100 });
18 + return (
19 + <>
20 + <PageHeader
21 + title="Deal Radar"
22 + description="Active listings priced materially below the RareIndex Valuation, restricted to the categories in your collections and watchlist. Analytical data, not investment advice — a discount can also mean a misidentified or damaged item."
23 + actions={
24 + <div className="flex gap-1 text-xs">
25 + {[10, 15, 25, 40].map((p) => (
26 + <Link key={p} href={`?min=${p}`} className={`rounded-full border px-3 py-1.5 ${Math.round(min * 100) === p ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted'}`}>
27 + ≥ {p}% below RIV
28 + </Link>
29 + ))}
30 + </div>
31 + }
32 + />
33 + <Card>
34 + <CardHeader title={`${rows.length} listing${rows.length === 1 ? '' : 's'}`} subtitle="Only assets with RIV confidence ≥ 50% and at least 5 sales qualify. Watched assets first." />
35 + {rows.length === 0 ? (
36 + <EmptyState title="No deals in your universe right now" description="Deal Radar looks at the categories of your collections and watchlist. Add items or watch a category to widen the net, or lower the discount threshold." action={<Link href="/watchlist" className={btnSecondary}>Manage watchlist</Link>} />
37 + ) : (
38 + <Table>
39 + <thead>
40 + <tr>
41 + <th className={th}>Listing</th>
42 + <th className={`${th} text-right`}>Ask</th>
43 + <th className={`${th} text-right`}>RIV</th>
44 + <th className={`${th} text-right`}>Discount</th>
45 + <th className={th}>Confidence</th>
46 + <th className={th}>Grade</th>
47 + <th className={th}>Source</th>
48 + <th className={th}>Seen</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {rows.map((r) => (
53 + <tr key={String(r.id)} className="hover:bg-sunken">
54 + <td className={td}>
55 + <div className="flex items-center gap-2.5">
56 + {r.hero_image_url ? (
57 + // eslint-disable-next-line @next/next/no-img-element
58 + <img src={String(r.hero_image_url)} alt="" className="h-9 w-9 rounded-sm object-cover" />
59 + ) : (
60 + <span className="h-9 w-9 rounded-sm bg-inset" />
61 + )}
62 + <div className="max-w-[280px]">
63 + <Link href={`/asset/${String(r.slug)}`} className="block truncate font-medium hover:underline">
64 + {String(r.title)}
65 + </Link>
66 + <p className="text-[11px] text-subtle">
67 + {String(r.category_slug).replace(/_/g, ' ')}
68 + {r.watched ? (
69 + <Badge tone="index" className="ml-1">
70 + watched
71 + </Badge>
72 + ) : null}
73 + </p>
74 + </div>
75 + </div>
76 + </td>
77 + <td className={tdNum}>{d.money(Number(r.price_usd))}</td>
78 + <td className={tdNum}>{d.money(Number(r.riv_usd))}</td>
79 + <td className={`${tdNum} text-gain`}>{fmtPct(Number(r.discount_to_riv))}</td>
80 + <td className={`${td} text-xs`}>
81 + {confidenceLabel(Number(r.riv_confidence))} · {Number(r.riv_sample_size)} sales
82 + </td>
83 + <td className={`${td} text-xs`}>{r.grader ? `${String(r.grader).toUpperCase()} ${r.grade ?? ''}` : (r.condition as string | null) ?? '—'}</td>
84 + <td className={td}>
85 + <a href={String(r.source_url)} target="_blank" rel="noopener nofollow" className="text-xs underline-offset-4 hover:underline">
86 + {String(r.source_id)} ↗
87 + </a>
88 + </td>
89 + <td className={`${td} text-xs text-muted`}>{fmtRelative(new Date(String(r.last_seen_at)))}</td>
90 + </tr>
91 + ))}
92 + </tbody>
93 + </Table>
94 + )}
95 + </Card>
96 + </>
97 + );
98 +}
added apps/web/src/app/(account)/layout.tsx +29 −0
@@ -0,0 +1,29 @@
1 +import type { ReactNode } from 'react';
2 +import { headers } from 'next/headers';
3 +import { and, count, eq, isNull } from '@/lib/db';
4 +import { db, notifications } from '@/lib/db';
5 +import { requireUser } from '@/lib/auth/session';
6 +import { AccountNav } from '@/components/account/account-nav';
7 +
8 +export default async function AccountLayout({ children }: { children: ReactNode }) {
9 + const h = await headers();
10 + const path = h.get('x-invoke-path') ?? h.get('next-url') ?? undefined;
11 + const user = await requireUser(path);
12 + const rows = await db().select({ n: count() }).from(notifications).where(and(eq(notifications.userId, user.id), isNull(notifications.readAt)));
13 + const n = rows[0]?.n ?? 0;
14 + return (
15 + <div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)]">
16 + <aside className="lg:sticky lg:top-[72px] lg:self-start">
17 + <div className="mb-3 hidden items-center gap-2 px-2 lg:flex">
18 + <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-accent-fg">{(user.name ?? user.email).slice(0, 1).toUpperCase()}</span>
19 + <div className="min-w-0">
20 + <p className="truncate text-[13px] font-medium">{user.name ?? 'Collector'}</p>
21 + <p className="truncate text-[11px] text-subtle">{user.email}</p>
22 + </div>
23 + </div>
24 + <AccountNav unread={Number(n)} handle={user.handle} />
25 + </aside>
26 + <div className="min-w-0">{children}</div>
27 + </div>
28 + );
29 +}
added apps/web/src/app/(account)/my-index/page.tsx +70 −0
@@ -0,0 +1,70 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { indexSeries, listCollections, loadItems, portfolioHistory } from '@/lib/account/queries';
5 +import { rebase, summarizePortfolio } from '@/lib/account/portfolio';
6 +import { getDisplay } from '@/lib/account/display';
7 +import { INDICES, indexForCategory } from '@rareindex/taxonomy';
8 +import { PageHeader, btnSecondary } from '@/components/account/page-header';
9 +import { LineChart } from '@/components/account/charts';
10 +import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';
11 +import { pctChange, volatility, maxDrawdown } from '@rareindex/shared';
12 +import { fmtPct } from '@/lib/format';
13 +
14 +export const metadata: Metadata = { title: 'My Index', robots: { index: false } };
15 +
16 +/** Personal index: the member's portfolio value rebased to 1000, against RARE and the relevant subindices. */
17 +export default async function MyIndexPage() {
18 + const u = await requireUser('/my-index');
19 + const d = await getDisplay();
20 + const cols = await listCollections(u.id);
21 + const items = await loadItems(cols.map((c) => c.collection.id));
22 + const summary = summarizePortfolio(items);
23 + const history = await portfolioHistory(u.id);
24 + const since = history[0]?.date;
25 + const mine = rebase(history.map((h) => ({ date: h.date, value: h.valueUsd })));
26 + const tickers = ['RARE', ...new Set(summary.allocationByCategory.map((b) => indexForCategory(b.key)?.ticker).filter((t): t is string => Boolean(t)))].slice(0, 5);
27 + const bench = await Promise.all(tickers.map(async (t) => ({ ticker: t, points: rebase(await indexSeries(t, since)) })));
28 + const values = history.map((h) => h.valueUsd);
29 + const last = values[values.length - 1] ?? null;
30 + const nowMs = new Date().getTime();
31 + const at = (days: number) => {
32 + if (!history.length) return null;
33 + const target = new Date(nowMs - days * 86_400_000).toISOString().slice(0, 10);
34 + const p = [...history].reverse().find((h) => h.date <= target);
35 + return p ? pctChange(p.valueUsd, last) : null;
36 + };
37 + const vol = volatility(values, 365);
38 + const dd = maxDrawdown(values);
39 +
40 + return (
41 + <>
42 + <PageHeader title="My Index" description="Your portfolio as an index: rebased to 1,000 on your first snapshot and compared with RARE and the subindices of the categories you hold." actions={<Link href="/collections" className={btnSecondary}>Collections</Link>} />
43 + <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-5">
44 + <Stat label="My index" value={mine.length ? mine[mine.length - 1]!.value.toFixed(1) : '—'} sub={history.length ? `${history.length} snapshots since ${since}` : 'no snapshots yet'} />
45 + <Stat label="7d" value={<Delta value={at(7)} className="text-lg" />} />
46 + <Stat label="30d" value={<Delta value={at(30)} className="text-lg" />} />
47 + <Stat label="Volatility (ann.)" value={vol === null ? '—' : fmtPct(vol, 1, false)} sub={values.length < 30 ? 'needs 30+ days' : 'from daily snapshots'} />
48 + <Stat label="Max drawdown" value={dd === null ? '—' : fmtPct(-dd, 1)} />
49 + </div>
50 + <Card>
51 + <CardHeader title="Rebased performance" subtitle={`Portfolio value in ${d.currency} vs indices · both rebased to 1,000`} />
52 + <div className="p-4">
53 + <LineChart series={[{ name: 'My index', points: mine }, ...bench.filter((b) => b.points.length > 1).map((b, i) => ({ name: b.ticker, points: b.points, color: INDICES.find((x) => x.ticker === b.ticker)?.color ?? `var(--ri-fg-subtle)`, dashed: i > 0 }))]} height={260} formatY={(v) => v.toFixed(0)} />
54 + <div className="mt-3 flex flex-wrap gap-4 text-xs text-muted">
55 + <span>
56 + <span className="mr-1 inline-block h-2 w-3 rounded-sm bg-index align-middle" /> My index
57 + </span>
58 + {bench.map((b) => (
59 + <span key={b.ticker}>
60 + <span className="mr-1 inline-block h-2 w-3 rounded-sm align-middle" style={{ background: INDICES.find((x) => x.ticker === b.ticker)?.color ?? 'var(--ri-fg-subtle)' }} /> {b.ticker}
61 + {b.points.length <= 1 ? <span className="text-subtle"> (no history yet)</span> : null}
62 + </span>
63 + ))}
64 + </div>
65 + <p className="mt-3 text-[11px] text-subtle">Your index reflects valuation changes and additions/removals; it is not a time-weighted return. Index values are published only when enough constituents are priced.</p>
66 + </div>
67 + </Card>
68 + </>
69 + );
70 +}
added apps/web/src/app/(account)/notifications/page.tsx +64 −0
@@ -0,0 +1,64 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { listNotifications } from '@/lib/account/queries';
5 +import { clearNotificationsAction, markReadAction } from '@/lib/account/actions';
6 +import { PageHeader, btnSecondary, btnGhost } from '@/components/account/page-header';
7 +import { Card, EmptyState, Badge } from '@/components/ui/primitives';
8 +import { fmtRelative } from '@/lib/format';
9 +
10 +export const metadata: Metadata = { title: 'Inbox', robots: { index: false } };
11 +
12 +const TONE: Record<string, 'index' | 'alert' | 'gain' | 'neutral' | 'rarity'> = { alert: 'alert', security: 'loss' as never, system: 'neutral', digest: 'index', target_hit: 'gain', radar: 'rarity' };
13 +
14 +export default async function InboxPage() {
15 + const u = await requireUser('/notifications');
16 + const rows = await listNotifications(u.id, 100);
17 + const unread = rows.filter((r) => !r.readAt).length;
18 + return (
19 + <>
20 + <PageHeader
21 + title="Inbox"
22 + description={`${unread} unread · alerts, price targets, security events and digests.`}
23 + actions={
24 + <>
25 + <form action={markReadAction}>
26 + <input type="hidden" name="id" value="all" />
27 + <button className={btnSecondary}>Mark all read</button>
28 + </form>
29 + <form action={clearNotificationsAction}>
30 + <button className={btnGhost}>Clear read</button>
31 + </form>
32 + </>
33 + }
34 + />
35 + <Card>
36 + {rows.length === 0 ? (
37 + <EmptyState title="Nothing here yet" description="When an alert triggers or a price target is hit, it lands here first." action={<Link href="/alerts" className={btnSecondary}>Create an alert</Link>} />
38 + ) : (
39 + <ul className="divide-y divide-border">
40 + {rows.map((n) => (
41 + <li key={n.id} className={`flex items-start gap-3 px-4 py-3 ${n.readAt ? '' : 'bg-sunken'}`}>
42 + <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${n.readAt ? 'bg-transparent' : 'bg-index'}`} />
43 + <div className="min-w-0 flex-1">
44 + <div className="flex flex-wrap items-center gap-2">
45 + <Badge tone={TONE[n.kind] ?? 'neutral'}>{n.kind.replace(/_/g, ' ')}</Badge>
46 + <p className="text-sm font-medium">{n.href ? <Link href={n.href} className="hover:underline">{n.title}</Link> : n.title}</p>
47 + <span className="text-[11px] text-subtle">{fmtRelative(n.createdAt)}</span>
48 + </div>
49 + {n.body ? <p className="mt-0.5 text-xs text-muted">{n.body}</p> : null}
50 + </div>
51 + {!n.readAt ? (
52 + <form action={markReadAction}>
53 + <input type="hidden" name="id" value={n.id} />
54 + <button className={btnGhost}>Read</button>
55 + </form>
56 + ) : null}
57 + </li>
58 + ))}
59 + </ul>
60 + )}
61 + </Card>
62 + </>
63 + );
64 +}
added apps/web/src/app/(account)/saved/page.tsx +63 −0
@@ -0,0 +1,63 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { listSavedSearches } from '@/lib/account/queries';
5 +import { deleteSavedSearchAction, toggleSavedSearchNotifyAction } from '@/lib/account/actions';
6 +import { PageHeader, btnDanger, btnGhost } from '@/components/account/page-header';
7 +import { Card, CardHeader, EmptyState, Badge } from '@/components/ui/primitives';
8 +import { fmtRelative } from '@/lib/format';
9 +import { SaveSearchForm } from './save-form';
10 +
11 +export const metadata: Metadata = { title: 'Saved searches', robots: { index: false } };
12 +
13 +export default async function SavedPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
14 + const sp = await searchParams;
15 + const u = await requireUser('/saved');
16 + const rows = await listSavedSearches(u.id);
17 + return (
18 + <>
19 + <PageHeader title="Saved searches" description="Keep your Explore and Search filters one click away. Searches with notifications on are re-run daily; new matches land in your inbox." />
20 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
21 + <Card>
22 + <CardHeader title={`Saved · ${rows.length}`} />
23 + {rows.length === 0 ? (
24 + <EmptyState title="No saved searches" description="Run a search, then use “Save this search” — or paste a RareIndex search URL on the right." />
25 + ) : (
26 + <ul className="divide-y divide-border">
27 + {rows.map((s) => (
28 + <li key={s.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
29 + <div className="min-w-0">
30 + <Link href={s.url} className="block truncate font-medium hover:underline">
31 + {s.name}
32 + </Link>
33 + <p className="truncate text-[11px] text-subtle">
34 + {s.url} · saved {fmtRelative(s.createdAt)}
35 + {s.lastRunAt ? ` · last run ${fmtRelative(s.lastRunAt)} (${s.lastCount ?? 0} results)` : ''}
36 + </p>
37 + </div>
38 + <div className="flex items-center gap-1">
39 + {s.notify ? <Badge tone="index">notify</Badge> : null}
40 + <form action={toggleSavedSearchNotifyAction}>
41 + <input type="hidden" name="id" value={s.id} />
42 + <button className={btnGhost}>{s.notify ? 'Mute' : 'Notify'}</button>
43 + </form>
44 + <form action={deleteSavedSearchAction}>
45 + <input type="hidden" name="id" value={s.id} />
46 + <button className={btnDanger}>Delete</button>
47 + </form>
48 + </div>
49 + </li>
50 + ))}
51 + </ul>
52 + )}
53 + </Card>
54 + <Card>
55 + <CardHeader title="Save a search" />
56 + <div className="p-4">
57 + <SaveSearchForm initialUrl={sp.url ?? ''} initialName={sp.name ?? ''} />
58 + </div>
59 + </Card>
60 + </div>
61 + </>
62 + );
63 +}
added apps/web/src/app/(account)/saved/save-form.tsx +23 −0
@@ -0,0 +1,23 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { saveSearchAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Checkbox, Field, FormMessage, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function SaveSearchForm({ initialUrl, initialName }: { initialUrl: string; initialName: string }) {
9 + const [state, action] = useActionState(saveSearchAction, idle);
10 + return (
11 + <form action={action} className="space-y-3" noValidate key={state.ok ? 'reset' : 'form'}>
12 + <FormMessage state={state} />
13 + <Field label="Name" name="name" error={state.fieldErrors?.name}>
14 + <TextInput name="name" required maxLength={80} defaultValue={initialName} placeholder="e.g. PSA 10 Charizards under $5k" />
15 + </Field>
16 + <Field label="RareIndex URL" name="url" error={state.fieldErrors?.url} hint="Paste the path of a /search or /explore page with its filters.">
17 + <TextInput name="url" required defaultValue={initialUrl} placeholder="/search?q=charizard+psa+10&max=5000" className="mono-num text-xs md:text-xs" />
18 + </Field>
19 + <Checkbox name="notify" label="Notify me about new matches" description="Re-run daily; new assets or listings go to your inbox." />
20 + <SubmitButton pendingText="Saving…">Save search</SubmitButton>
21 + </form>
22 + );
23 +}
added apps/web/src/app/(account)/targets/page.tsx +93 −0
@@ -0,0 +1,93 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { listTargets } from '@/lib/account/queries';
5 +import { getDisplay } from '@/lib/account/display';
6 +import { deleteTargetAction } from '@/lib/account/actions';
7 +import { PageHeader, btnDanger } from '@/components/account/page-header';
8 +import { Card, CardHeader, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
9 +import { Progress } from '@/components/account/charts';
10 +import { fmtRelative } from '@/lib/format';
11 +import { TargetForm } from './target-form';
12 +
13 +export const metadata: Metadata = { title: 'Price targets', robots: { index: false } };
14 +
15 +export default async function TargetsPage() {
16 + const u = await requireUser('/targets');
17 + const d = await getDisplay();
18 + const rows = await listTargets(u.id);
19 + return (
20 + <>
21 + <PageHeader title="Price targets" description="Set a RIV level you are waiting for — to buy below or to sell above — and watch progress. You are notified once when it is reached." />
22 + <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
23 + <Card>
24 + <CardHeader title={`Targets · ${rows.length}`} />
25 + {rows.length === 0 ? (
26 + <EmptyState title="No targets yet" description="Add one on the right. Progress is measured from the RIV at the time you set the target." />
27 + ) : (
28 + <Table>
29 + <thead>
30 + <tr>
31 + <th className={th}>Asset</th>
32 + <th className={th}>Direction</th>
33 + <th className={`${th} text-right`}>Baseline</th>
34 + <th className={`${th} text-right`}>Now</th>
35 + <th className={`${th} text-right`}>Target</th>
36 + <th className={`${th} w-[160px]`}>Progress</th>
37 + <th className={th}>Status</th>
38 + <th className={th}></th>
39 + </tr>
40 + </thead>
41 + <tbody>
42 + {rows.map(({ target: t, asset, stats }) => {
43 + const now = stats?.rivUsd ?? null;
44 + const base = t.baselineUsd ?? now;
45 + let progress = 0;
46 + if (now !== null && base !== null && base !== t.targetUsd) progress = (now - base) / (t.targetUsd - base);
47 + const hit = t.hitAt !== null || (now !== null && (t.direction === 'above' ? now >= t.targetUsd : now <= t.targetUsd));
48 + return (
49 + <tr key={t.id}>
50 + <td className={td}>
51 + <div className="max-w-[240px]">
52 + <Link href={`/asset/${asset.slug}`} className="block truncate font-medium hover:underline">
53 + {asset.title}
54 + </Link>
55 + <p className="text-[11px] text-subtle">
56 + set {fmtRelative(t.createdAt)}
57 + {t.note ? ` · ${t.note}` : ''}
58 + </p>
59 + </div>
60 + </td>
61 + <td className={td}>
62 + <Badge tone={t.direction === 'above' ? 'gain' : 'index'}>{t.direction === 'above' ? 'sell above' : 'buy below'}</Badge>
63 + </td>
64 + <td className={tdNum}>{t.baselineUsd ? d.money(t.baselineUsd) : '—'}</td>
65 + <td className={tdNum}>{now ? d.money(now) : <span className="text-subtle">—</span>}</td>
66 + <td className={tdNum}>{d.money(t.targetUsd)}</td>
67 + <td className={td}>
68 + <Progress value={hit ? 1 : progress} />
69 + </td>
70 + <td className={td}>{hit ? <Badge tone="gain">reached</Badge> : now === null ? <Badge tone="neutral">no RIV yet</Badge> : <span className="text-xs text-muted">{Math.round(Math.max(0, Math.min(1, progress)) * 100)}%</span>}</td>
71 + <td className={td}>
72 + <form action={deleteTargetAction}>
73 + <input type="hidden" name="id" value={t.id} />
74 + <button className={btnDanger}>Remove</button>
75 + </form>
76 + </td>
77 + </tr>
78 + );
79 + })}
80 + </tbody>
81 + </Table>
82 + )}
83 + </Card>
84 + <Card>
85 + <CardHeader title="New target" />
86 + <div className="p-4">
87 + <TargetForm />
88 + </div>
89 + </Card>
90 + </div>
91 + </>
92 + );
93 +}
added apps/web/src/app/(account)/targets/target-form.tsx +32 −0
@@ -0,0 +1,32 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { createTargetAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, Select, SubmitButton, TextInput } from '@/components/account/form';
7 +import { AssetPicker } from '@/components/account/asset-picker';
8 +
9 +export function TargetForm() {
10 + const [state, action] = useActionState(createTargetAction, idle);
11 + return (
12 + <form action={action} className="space-y-3" noValidate key={state.ok ? 'reset' : 'form'}>
13 + <FormMessage state={state} />
14 + <AssetPicker name="assetId" withVariant={false} error={state.fieldErrors?.assetId} />
15 + <div className="grid grid-cols-2 gap-3">
16 + <Field label="Direction" name="direction">
17 + <Select name="direction" defaultValue="below">
18 + <option value="below">Buy — RIV falls below</option>
19 + <option value="above">Sell — RIV rises above</option>
20 + </Select>
21 + </Field>
22 + <Field label="Target (USD)" name="targetUsd" error={state.fieldErrors?.targetUsd}>
23 + <TextInput name="targetUsd" type="number" min={0} step="0.01" required placeholder="2500" />
24 + </Field>
25 + </div>
26 + <Field label="Note (optional)" name="note">
27 + <TextInput name="note" maxLength={300} placeholder="Why this level?" />
28 + </Field>
29 + <SubmitButton pendingText="Saving…">Set target</SubmitButton>
30 + </form>
31 + );
32 +}
added apps/web/src/app/(account)/watchlist/forms.tsx +67 −0
@@ -0,0 +1,67 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { toggleWatchAction, updateWatchItemAction } from '@/lib/account/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, Select, SubmitButton, TextInput } from '@/components/account/form';
7 +import { AssetPicker } from '@/components/account/asset-picker';
8 +import { CATEGORIES } from '@rareindex/taxonomy';
9 +
10 +export function WatchAddForm() {
11 + const [mode, setMode] = useState<'asset' | 'category'>('asset');
12 + return (
13 + <form action={toggleWatchAction} className="space-y-3">
14 + <div className="flex gap-1 text-xs">
15 + {(['asset', 'category'] as const).map((m) => (
16 + <button key={m} type="button" onClick={() => setMode(m)} className={`rounded-full border px-3 py-1 ${mode === m ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted'}`}>
17 + {m === 'asset' ? 'Asset' : 'Category / market'}
18 + </button>
19 + ))}
20 + </div>
21 + <input type="hidden" name="targetType" value={mode} />
22 + {mode === 'asset' ? (
23 + <AssetPicker name="targetId" withVariant={false} />
24 + ) : (
25 + <Field label="Category" name="targetId">
26 + <Select name="targetId" defaultValue="pokemon">
27 + {CATEGORIES.map((c) => (
28 + <option key={c.slug} value={c.slug}>
29 + {'— '.repeat(c.level)}
30 + {c.name}
31 + </option>
32 + ))}
33 + </Select>
34 + </Field>
35 + )}
36 + <SubmitButton pendingText="Adding…">Watch</SubmitButton>
37 + </form>
38 + );
39 +}
40 +
41 +export function WatchNoteForm({ itemId, note, targetPriceUsd }: { itemId: string; note: string | null; targetPriceUsd: number | null }) {
42 + const [state, action] = useActionState(updateWatchItemAction, idle);
43 + const [open, setOpen] = useState(false);
44 + if (!open) {
45 + return (
46 + <button type="button" onClick={() => setOpen(true)} className="max-w-full truncate text-left text-xs text-muted hover:text-fg">
47 + {note ? note : targetPriceUsd ? 'Edit target' : '+ note / target'}
48 + </button>
49 + );
50 + }
51 + return (
52 + <form action={action} className="flex flex-col gap-1">
53 + <input type="hidden" name="itemId" value={itemId} />
54 + <TextInput name="note" defaultValue={note ?? ''} placeholder="Note" maxLength={500} className="h-8 text-xs md:text-xs" />
55 + <TextInput name="targetPriceUsd" type="number" min={0} step="0.01" defaultValue={targetPriceUsd ?? ''} placeholder="Target (USD)" className="h-8 text-xs md:text-xs" />
56 + <div className="flex items-center gap-2">
57 + <SubmitButton variant="secondary" className="h-7 px-2 text-xs">
58 + Save
59 + </SubmitButton>
60 + <button type="button" onClick={() => setOpen(false)} className="text-xs text-muted">
61 + Cancel
62 + </button>
63 + <FormMessage state={state} className="py-0.5 text-xs" />
64 + </div>
65 + </form>
66 + );
67 +}
added apps/web/src/app/(account)/watchlist/page.tsx +147 −0
@@ -0,0 +1,147 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { requireUser } from '@/lib/auth/session';
4 +import { activeListingsForAssets, loadWatchlist } from '@/lib/account/queries';
5 +import { getDisplay } from '@/lib/account/display';
6 +import { toggleWatchAction } from '@/lib/account/actions';
7 +import { PageHeader, btnDanger } from '@/components/account/page-header';
8 +import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';
9 +import { fmtRelative, fmtPct, confidenceLabel } from '@/lib/format';
10 +import { WatchAddForm, WatchNoteForm } from './forms';
11 +
12 +export const metadata: Metadata = { title: 'Watchlist', robots: { index: false } };
13 +
14 +export default async function WatchlistPage() {
15 + const u = await requireUser('/watchlist');
16 + const d = await getDisplay();
17 + const rows = await loadWatchlist(u.id);
18 + const assetRows = rows.filter((r) => r.item.targetType === 'asset');
19 + const otherRows = rows.filter((r) => r.item.targetType !== 'asset');
20 + const listingAgg = await activeListingsForAssets(assetRows.map((r) => r.item.targetId));
21 + const lMap = new Map(listingAgg.map((l) => [l.assetId, l]));
22 +
23 + return (
24 + <>
25 + <PageHeader title="Watchlist" description="Assets, categories, sets and sources you follow, with live RareIndex Valuation, change and the cheapest active ask." />
26 + <Card className="mb-5">
27 + <CardHeader title={`Assets · ${assetRows.length}`} subtitle="Change since watching uses the RIV at the time you added the asset." />
28 + {assetRows.length === 0 ? (
29 + <EmptyState title="No assets watched yet" description="Add an asset below, or use the watch button on any asset page." />
30 + ) : (
31 + <Table>
32 + <thead>
33 + <tr>
34 + <th className={th}>Asset</th>
35 + <th className={`${th} text-right`}>RIV</th>
36 + <th className={`${th} text-right`}>1d</th>
37 + <th className={`${th} text-right`}>30d</th>
38 + <th className={`${th} text-right`}>Since watch</th>
39 + <th className={`${th} text-right`}>Listings</th>
40 + <th className={`${th} text-right`}>Min ask</th>
41 + <th className={`${th} text-right`}>Target</th>
42 + <th className={th}>Note</th>
43 + <th className={th}></th>
44 + </tr>
45 + </thead>
46 + <tbody>
47 + {assetRows.map(({ item, asset, stats }) => {
48 + const l = lMap.get(item.targetId);
49 + const since = item.baselineUsd && stats?.rivUsd ? (stats.rivUsd - item.baselineUsd) / item.baselineUsd : null;
50 + const targetHit = item.targetPriceUsd && stats?.rivUsd ? stats.rivUsd <= item.targetPriceUsd : false;
51 + return (
52 + <tr key={item.id} className="hover:bg-sunken">
53 + <td className={td}>
54 + {asset ? (
55 + <div className="max-w-[280px]">
56 + <Link href={`/asset/${asset.slug}`} className="block truncate font-medium hover:underline">
57 + {asset.title}
58 + </Link>
59 + <p className="text-[11px] text-subtle">
60 + {asset.categorySlug.replace(/_/g, ' ')} · added {fmtRelative(item.createdAt)}
61 + </p>
62 + </div>
63 + ) : (
64 + <span className="text-muted">Asset removed</span>
65 + )}
66 + </td>
67 + <td className={tdNum}>{stats?.rivUsd ? <span title={`confidence ${confidenceLabel(stats.rivConfidence)} · ${stats.rivSampleSize} sales`}>{d.money(stats.rivUsd)}</span> : <span className="text-subtle">—</span>}</td>
68 + <td className={tdNum}>
69 + <Delta value={stats?.change1d} />
70 + </td>
71 + <td className={tdNum}>
72 + <Delta value={stats?.change30d} />
73 + </td>
74 + <td className={tdNum}>
75 + <Delta value={since} />
76 + </td>
77 + <td className={tdNum}>{l ? Number(l.n) : 0}</td>
78 + <td className={tdNum}>{l?.minAsk ? d.money(Number(l.minAsk)) : <span className="text-subtle">—</span>}</td>
79 + <td className={tdNum}>
80 + {item.targetPriceUsd ? (
81 + <span className={targetHit ? 'text-gain' : ''}>
82 + {d.money(item.targetPriceUsd)}
83 + {targetHit ? <Badge tone="gain" className="ml-1">hit</Badge> : null}
84 + </span>
85 + ) : (
86 + <span className="text-subtle">—</span>
87 + )}
88 + </td>
89 + <td className={`${td} max-w-[220px]`}>
90 + <WatchNoteForm itemId={item.id} note={item.note} targetPriceUsd={item.targetPriceUsd} />
91 + </td>
92 + <td className={td}>
93 + <form action={toggleWatchAction}>
94 + <input type="hidden" name="targetType" value="asset" />
95 + <input type="hidden" name="targetId" value={item.targetId} />
96 + <button className={btnDanger}>Unwatch</button>
97 + </form>
98 + </td>
99 + </tr>
100 + );
101 + })}
102 + </tbody>
103 + </Table>
104 + )}
105 + </Card>
106 + <div className="grid gap-4 lg:grid-cols-2">
107 + <Card>
108 + <CardHeader title={`Markets, sets & sources · ${otherRows.length}`} subtitle="Category watches feed Deal Radar and market-move alerts." />
109 + {otherRows.length === 0 ? (
110 + <p className="px-4 py-6 text-xs text-muted">Nothing here yet. Watch a category from its market page, or add one below.</p>
111 + ) : (
112 + <ul className="divide-y divide-border">
113 + {otherRows.map(({ item, category }) => (
114 + <li key={item.id} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
115 + <div>
116 + <Badge tone="neutral" className="mr-2">
117 + {item.targetType}
118 + </Badge>
119 + {item.targetType === 'category' ? (
120 + <Link href={`/markets/${item.targetId}`} className="hover:underline">
121 + {category?.name ?? item.targetId}
122 + </Link>
123 + ) : (
124 + <span>{item.label ?? item.targetId}</span>
125 + )}
126 + {item.targetType === 'category' && category ? <span className="ml-2 text-xs text-subtle">{fmtPct(null)}</span> : null}
127 + </div>
128 + <form action={toggleWatchAction}>
129 + <input type="hidden" name="targetType" value={item.targetType} />
130 + <input type="hidden" name="targetId" value={item.targetId} />
131 + <button className={btnDanger}>Unwatch</button>
132 + </form>
133 + </li>
134 + ))}
135 + </ul>
136 + )}
137 + </Card>
138 + <Card>
139 + <CardHeader title="Add to watchlist" />
140 + <div className="p-4">
141 + <WatchAddForm />
142 + </div>
143 + </Card>
144 + </div>
145 + </>
146 + );
147 +}
added apps/web/src/app/(auth)/forgot/forgot-form.tsx +30 −0
@@ -0,0 +1,30 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useActionState } from 'react';
5 +import { forgotAction } from '@/lib/auth/actions';
6 +import { idle } from '@/lib/auth/state';
7 +import { Field, FormMessage, SubmitButton, TextInput } from '@/components/account/form';
8 +
9 +export function ForgotForm() {
10 + const [state, action] = useActionState(forgotAction, idle);
11 + return (
12 + <form action={action} className="mt-6 space-y-4" noValidate>
13 + <FormMessage state={state} />
14 + <Field label="E-mail" name="email">
15 + <TextInput name="email" type="email" autoComplete="email" required autoFocus placeholder="you@example.com" />
16 + </Field>
17 + <SubmitButton className="w-full" pendingText="Sending…">
18 + Send reset code
19 + </SubmitButton>
20 + {state.ok ? (
21 + <p className="text-center text-xs text-muted">
22 + Got the code?{' '}
23 + <Link href="/reset" className="font-medium text-fg underline-offset-4 hover:underline">
24 + Enter it here
25 + </Link>
26 + </p>
27 + ) : null}
28 + </form>
29 + );
30 +}
added apps/web/src/app/(auth)/forgot/page.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ForgotForm } from './forgot-form';
4 +
5 +export const metadata: Metadata = { title: 'Reset your password', robots: { index: false } };
6 +
7 +export default function ForgotPage() {
8 + return (
9 + <>
10 + <h1 className="text-xl font-semibold tracking-tight">Reset your password</h1>
11 + <p className="mt-1 text-sm text-muted">Enter your e-mail and we will send a reset link and a 6-digit code.</p>
12 + <ForgotForm />
13 + <p className="mt-6 text-center text-sm text-muted">
14 + <Link href="/login" className="font-medium text-fg underline-offset-4 hover:underline">
15 + Back to sign in
16 + </Link>
17 + </p>
18 + </>
19 + );
20 +}
added apps/web/src/app/(auth)/layout.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import type { ReactNode } from 'react';
2 +
3 +export default function AuthLayout({ children }: { children: ReactNode }) {
4 + return (
5 + <div className="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-6 sm:py-12">
6 + <div className="card p-6 sm:p-8">{children}</div>
7 + <p className="mt-6 text-center text-[11px] leading-relaxed text-subtle">Protected by e-mail verification and optional authenticator codes. We never sell private collection data.</p>
8 + </div>
9 + );
10 +}
added apps/web/src/app/(auth)/login/login-form.tsx +30 −0
@@ -0,0 +1,30 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useActionState } from 'react';
5 +import { loginAction } from '@/lib/auth/actions';
6 +import { idle } from '@/lib/auth/state';
7 +import { Field, FormMessage, PasswordField, SubmitButton, TextInput } from '@/components/account/form';
8 +
9 +export function LoginForm({ next }: { next: string }) {
10 + const [state, action] = useActionState(loginAction, idle);
11 + return (
12 + <form action={action} className="mt-6 space-y-4" noValidate>
13 + <input type="hidden" name="next" value={next} />
14 + <FormMessage state={state} />
15 + <Field label="E-mail" name="email" error={state.fieldErrors?.email}>
16 + <TextInput name="email" type="email" autoComplete="email" required autoFocus placeholder="you@example.com" invalid={Boolean(state.fieldErrors?.email)} />
17 + </Field>
18 + <PasswordField name="password" autoComplete="current-password" showMeter={false} error={state.fieldErrors?.password} />
19 + <div className="flex items-center justify-between">
20 + <span />
21 + <Link href="/forgot" className="text-xs font-medium text-muted hover:text-fg">
22 + Forgot password?
23 + </Link>
24 + </div>
25 + <SubmitButton className="w-full" pendingText="Signing in…">
26 + Continue
27 + </SubmitButton>
28 + </form>
29 + );
30 +}
added apps/web/src/app/(auth)/login/page.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { redirect } from 'next/navigation';
4 +import { getCurrentUser } from '@/lib/auth/session';
5 +import { safeNext } from '@/lib/auth/pending';
6 +import { LoginForm } from './login-form';
7 +
8 +export const metadata: Metadata = { title: 'Sign in', robots: { index: false } };
9 +
10 +export default async function LoginPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
11 + const sp = await searchParams;
12 + const next = safeNext(typeof sp.next === 'string' ? sp.next : null);
13 + const user = await getCurrentUser();
14 + if (user) redirect(next);
15 + return (
16 + <>
17 + <h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
18 + <p className="mt-1 text-sm text-muted">Welcome back. Your collections, watchlists and alerts are waiting.</p>
19 + {sp.reset ? <p className="mt-4 rounded-md border border-gain/30 bg-gain-bg px-3 py-2 text-sm text-gain">Password updated. Sign in with your new password.</p> : null}
20 + {sp.expired ? <p className="mt-4 rounded-md border border-alert/30 bg-alert-bg px-3 py-2 text-sm text-alert">Your session expired. Please sign in again.</p> : null}
21 + <LoginForm next={next} />
22 + <p className="mt-6 text-center text-sm text-muted">
23 + New to RareIndex?{' '}
24 + <Link href={`/signup${next !== '/collections' ? `?next=${encodeURIComponent(next)}` : ''}`} className="font-medium text-fg underline-offset-4 hover:underline">
25 + Create an account
26 + </Link>
27 + </p>
28 + </>
29 + );
30 +}
added apps/web/src/app/(auth)/mfa/mfa-form.tsx +63 −0
@@ -0,0 +1,63 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { mfaUseEmailAction, mfaVerifyAction, resendCodeAction } from '@/lib/auth/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Checkbox, CodeInput, Cooldown, Field, FormMessage, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +type Method = 'totp' | 'email_code' | 'recovery';
9 +
10 +export function MfaForm({ stage }: { stage: 'totp' | 'email_code' }) {
11 + const [state, action] = useActionState(mfaVerifyAction, idle);
12 + const [emailState, emailAction] = useActionState(mfaUseEmailAction, idle);
13 + const [resend, resendAction] = useActionState(resendCodeAction, idle);
14 + const [method, setMethod] = useState<Method>(emailState.ok ? 'email_code' : stage);
15 + const effective: Method = emailState.ok && method === 'totp' ? 'email_code' : method;
16 + const cooldown = typeof resend.data?.retryAfter === 'number' ? resend.data.retryAfter : resend.ok || emailState.ok ? 45 : 0;
17 + return (
18 + <div className="mt-6 space-y-4">
19 + <form action={action} className="space-y-4" noValidate>
20 + <input type="hidden" name="method" value={effective} />
21 + <FormMessage state={state} />
22 + <FormMessage state={emailState} />
23 + {effective === 'recovery' ? (
24 + <Field label="Recovery code" name="code" hint="One of the 10 codes you saved when enabling the authenticator.">
25 + <TextInput name="code" autoComplete="off" placeholder="ABCDE-FGHJK" className="mono-num uppercase" required autoFocus />
26 + </Field>
27 + ) : (
28 + <CodeInput name="code" label={effective === 'totp' ? 'Authenticator code' : 'E-mailed code'} />
29 + )}
30 + <Checkbox name="trust" label="Trust this device for 30 days" description="Skip the second step on this browser." />
31 + <SubmitButton className="w-full" pendingText="Verifying…">
32 + Verify
33 + </SubmitButton>
34 + </form>
35 + <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs">
36 + {stage === 'totp' && effective !== 'email_code' ? (
37 + <form action={emailAction}>
38 + <SubmitButton variant="ghost" className="h-8 text-xs">
39 + E-mail me a code instead
40 + </SubmitButton>
41 + </form>
42 + ) : null}
43 + {effective === 'email_code' ? (
44 + <form action={resendAction}>
45 + <FormMessage state={resend} className="mb-2" />
46 + <Cooldown seconds={cooldown}>
47 + {(ready, left) => (
48 + <SubmitButton variant="ghost" className="h-8 text-xs" disabled={!ready}>
49 + {ready ? 'Send a new code' : `New code in ${left}s`}
50 + </SubmitButton>
51 + )}
52 + </Cooldown>
53 + </form>
54 + ) : null}
55 + {stage === 'totp' ? (
56 + <button type="button" className="text-muted hover:text-fg" onClick={() => setMethod(effective === 'recovery' ? 'totp' : 'recovery')}>
57 + {effective === 'recovery' ? 'Use authenticator code' : 'Use a recovery code'}
58 + </button>
59 + ) : null}
60 + </div>
61 + </div>
62 + );
63 +}
added apps/web/src/app/(auth)/mfa/page.tsx +19 −0
@@ -0,0 +1,19 @@
1 +import type { Metadata } from 'next';
2 +import { redirect } from 'next/navigation';
3 +import { getPending } from '@/lib/auth/pending';
4 +import { MfaForm } from './mfa-form';
5 +
6 +export const metadata: Metadata = { title: 'Two-step verification', robots: { index: false } };
7 +
8 +export default async function MfaPage() {
9 + const p = await getPending();
10 + if (!p) redirect('/login?expired=1');
11 + if (p.stage === 'verify') redirect('/verify');
12 + return (
13 + <>
14 + <h1 className="text-xl font-semibold tracking-tight">Two-step verification</h1>
15 + <p className="mt-1 text-sm text-muted">{p.stage === 'totp' ? 'Enter the 6-digit code from your authenticator app.' : <>We e-mailed a 6-digit sign-in code to <span className="font-medium text-fg">{p.email}</span> because this device is new.</>}</p>
16 + <MfaForm stage={p.stage === 'totp' ? 'totp' : 'email_code'} />
17 + </>
18 + );
19 +}
added apps/web/src/app/(auth)/reset/page.tsx +16 −0
@@ -0,0 +1,16 @@
1 +import type { Metadata } from 'next';
2 +import { ResetForm } from './reset-form';
3 +
4 +export const metadata: Metadata = { title: 'Choose a new password', robots: { index: false } };
5 +
6 +export default async function ResetPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
7 + const sp = await searchParams;
8 + const t = typeof sp.t === 'string' ? sp.t : '';
9 + return (
10 + <>
11 + <h1 className="text-xl font-semibold tracking-tight">Choose a new password</h1>
12 + <p className="mt-1 text-sm text-muted">{t ? 'Your reset link is valid. Pick a new password below.' : 'Enter your e-mail, the 6-digit code we sent, and a new password.'}</p>
13 + <ResetForm token={t} />
14 + </>
15 + );
16 +}
added apps/web/src/app/(auth)/reset/reset-form.tsx +29 −0
@@ -0,0 +1,29 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { resetAction } from '@/lib/auth/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { CodeInput, Field, FormMessage, PasswordField, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function ResetForm({ token }: { token: string }) {
9 + const [state, action] = useActionState(resetAction, idle);
10 + return (
11 + <form action={action} className="mt-6 space-y-4" noValidate>
12 + <FormMessage state={state} />
13 + {token ? (
14 + <input type="hidden" name="t" value={token} />
15 + ) : (
16 + <>
17 + <Field label="E-mail" name="email" error={state.fieldErrors?.email}>
18 + <TextInput name="email" type="email" autoComplete="email" required />
19 + </Field>
20 + <CodeInput name="code" autoSubmit={false} error={state.fieldErrors?.code} label="Reset code" />
21 + </>
22 + )}
23 + <PasswordField name="password" label="New password" autoComplete="new-password" error={state.fieldErrors?.password} />
24 + <SubmitButton className="w-full" pendingText="Saving…">
25 + Set new password
26 + </SubmitButton>
27 + </form>
28 + );
29 +}
added apps/web/src/app/(auth)/signup/page.tsx +37 −0
@@ -0,0 +1,37 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { redirect } from 'next/navigation';
4 +import { getCurrentUser } from '@/lib/auth/session';
5 +import { safeNext } from '@/lib/auth/pending';
6 +import { SignupForm } from './signup-form';
7 +
8 +export const metadata: Metadata = { title: 'Create your account', robots: { index: false } };
9 +
10 +const PERKS = ['Track your collection with live RareIndex valuations and cost basis', 'Watchlists, price targets and alerts for the assets you care about', 'Deal Radar: listings priced below fair value in your categories', 'Weekly digest, exports and a shareable collector profile'];
11 +
12 +export default async function SignupPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
13 + const sp = await searchParams;
14 + const next = safeNext(typeof sp.next === 'string' ? sp.next : null, '/collections?welcome=1');
15 + if (await getCurrentUser()) redirect(next);
16 + return (
17 + <>
18 + <h1 className="text-xl font-semibold tracking-tight">Create your account</h1>
19 + <p className="mt-1 text-sm text-muted">Free. We will e-mail you a 6-digit code to confirm your address.</p>
20 + <ul className="mt-4 space-y-1.5 text-xs text-muted">
21 + {PERKS.map((p) => (
22 + <li key={p} className="flex gap-2">
23 + <span className="mt-[7px] h-1 w-1 shrink-0 rounded-full bg-fg" />
24 + {p}
25 + </li>
26 + ))}
27 + </ul>
28 + <SignupForm next={next} />
29 + <p className="mt-6 text-center text-sm text-muted">
30 + Already have an account?{' '}
31 + <Link href="/login" className="font-medium text-fg underline-offset-4 hover:underline">
32 + Sign in
33 + </Link>
34 + </p>
35 + </>
36 + );
37 +}
added apps/web/src/app/(auth)/signup/signup-form.tsx +28 −0
@@ -0,0 +1,28 @@
1 +'use client';
2 +
3 +import { useActionState, useState } from 'react';
4 +import { signupAction } from '@/lib/auth/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { Field, FormMessage, PasswordField, SubmitButton, TextInput } from '@/components/account/form';
7 +
8 +export function SignupForm({ next }: { next: string }) {
9 + const [state, action] = useActionState(signupAction, idle);
10 + const [email, setEmail] = useState('');
11 + return (
12 + <form action={action} className="mt-6 space-y-4" noValidate>
13 + <input type="hidden" name="next" value={next} />
14 + <FormMessage state={state} />
15 + <Field label="Name (optional)" name="name">
16 + <TextInput name="name" type="text" autoComplete="name" placeholder="How should we greet you?" maxLength={80} />
17 + </Field>
18 + <Field label="E-mail" name="email" error={state.fieldErrors?.email}>
19 + <TextInput name="email" type="email" autoComplete="email" required placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} invalid={Boolean(state.fieldErrors?.email)} />
20 + </Field>
21 + <PasswordField name="password" autoComplete="new-password" email={email} error={state.fieldErrors?.password} />
22 + <SubmitButton className="w-full" pendingText="Creating account…">
23 + Create account
24 + </SubmitButton>
25 + <p className="text-center text-[11px] leading-relaxed text-subtle">By continuing you agree that RareIndex is an information platform: valuations are estimates, not appraisals or investment advice.</p>
26 + </form>
27 + );
28 +}
added apps/web/src/app/(auth)/verify/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { redirect } from 'next/navigation';
4 +import { getPending } from '@/lib/auth/pending';
5 +import { VerifyForm } from './verify-form';
6 +
7 +export const metadata: Metadata = { title: 'Confirm your e-mail', robots: { index: false } };
8 +
9 +export default async function VerifyPage() {
10 + const p = await getPending();
11 + if (!p) redirect('/login?expired=1');
12 + if (p.stage !== 'verify') redirect('/mfa');
13 + return (
14 + <>
15 + <h1 className="text-xl font-semibold tracking-tight">Confirm your e-mail</h1>
16 + <p className="mt-1 text-sm text-muted">
17 + We sent a 6-digit code to <span className="font-medium text-fg">{p.email}</span>. It expires in 10 minutes.
18 + </p>
19 + <VerifyForm />
20 + <p className="mt-6 text-center text-xs text-muted">
21 + Wrong address?{' '}
22 + <Link href="/signup" className="font-medium text-fg underline-offset-4 hover:underline">
23 + Start over
24 + </Link>
25 + </p>
26 + </>
27 + );
28 +}
added apps/web/src/app/(auth)/verify/verify-form.tsx +33 −0
@@ -0,0 +1,33 @@
1 +'use client';
2 +
3 +import { useActionState } from 'react';
4 +import { resendCodeAction, verifyEmailAction } from '@/lib/auth/actions';
5 +import { idle } from '@/lib/auth/state';
6 +import { CodeInput, Cooldown, FormMessage, SubmitButton } from '@/components/account/form';
7 +
8 +export function VerifyForm() {
9 + const [state, action] = useActionState(verifyEmailAction, idle);
10 + const [resend, resendAction] = useActionState(resendCodeAction, idle);
11 + const cooldown = typeof resend.data?.retryAfter === 'number' ? resend.data.retryAfter : resend.ok ? 45 : 0;
12 + return (
13 + <div className="mt-6 space-y-4">
14 + <form action={action} className="space-y-4" noValidate>
15 + <FormMessage state={state} />
16 + <CodeInput name="code" error={state.fieldErrors?.code} label="Verification code" />
17 + <SubmitButton className="w-full" pendingText="Checking…">
18 + Confirm
19 + </SubmitButton>
20 + </form>
21 + <form action={resendAction} className="text-center">
22 + <FormMessage state={resend} className="mb-3 text-left" />
23 + <Cooldown seconds={cooldown}>
24 + {(ready, left) => (
25 + <SubmitButton variant="ghost" className="h-8 text-xs" disabled={!ready}>
26 + {ready ? 'Send a new code' : `Send a new code in ${left}s`}
27 + </SubmitButton>
28 + )}
29 + </Cooldown>
30 + </form>
31 + </div>
32 + );
33 +}
added apps/web/src/app/account/page.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import { redirect } from 'next/navigation';
2 +import { getCurrentUser } from '@/lib/auth/session';
3 +
4 +/** /account is the header's "Sign in" target: route to settings when signed in, else to login. */
5 +export default async function AccountIndex() {
6 + const u = await getCurrentUser();
7 + redirect(u ? '/account/settings' : '/login?next=%2Faccount%2Fsettings');
8 +}
added apps/web/src/app/api/account/assets/[id]/variants/route.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { NextResponse } from 'next/server';
2 +import { getCurrentUser } from '@/lib/auth/session';
3 +import { listVariants } from '@/lib/account/queries';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
8 + if (!(await getCurrentUser())) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
9 + const { id } = await ctx.params;
10 + const rows = await listVariants(id);
11 + return NextResponse.json(rows.map((r) => ({ id: r.v.id, label: r.v.label, rivUsd: r.s?.rivUsd ?? null })));
12 +}
added apps/web/src/app/api/account/assets/search/route.ts +14 −0
@@ -0,0 +1,14 @@
1 +import { NextResponse } from 'next/server';
2 +import { getCurrentUser } from '@/lib/auth/session';
3 +import { searchAssets } from '@/lib/account/queries';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +export async function GET(req: Request) {
8 + if (!(await getCurrentUser())) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
9 + const url = new URL(req.url);
10 + const q = (url.searchParams.get('q') ?? '').slice(0, 120);
11 + const category = url.searchParams.get('category') ?? undefined;
12 + const hits = await searchAssets(q, 12, category);
13 + return NextResponse.json(hits.map((h) => ({ id: h.id, title: h.title, categorySlug: h.categorySlug, year: h.year, heroImageUrl: h.heroImageUrl, rivUsd: h.rivUsd })), { headers: { 'cache-control': 'private, max-age=30' } });
14 +}
added apps/web/src/app/api/account/collections/[id]/export/route.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { NextResponse } from 'next/server';
2 +import { getCurrentUser } from '@/lib/auth/session';
3 +import { getCollectionDetail } from '@/lib/account/queries';
4 +import { toCsv } from '@/lib/account/portfolio';
5 +
6 +export const dynamic = 'force-dynamic';
7 +
8 +const COLUMNS = ['item_id', 'asset_id', 'asset_slug', 'title', 'category', 'variant', 'grader', 'grade', 'cert', 'serial', 'quantity', 'acquired_at', 'purchase_price', 'currency', 'purchase_price_usd', 'source', 'condition', 'value_usd', 'value_source', 'confidence', 'gain_usd', 'gain_pct', 'tags', 'notes'];
9 +
10 +export async function GET(req: Request, ctx: { params: Promise<{ id: string }> }) {
11 + const u = await getCurrentUser();
12 + if (!u) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
13 + const { id } = await ctx.params;
14 + const detail = await getCollectionDetail(u.id, id);
15 + if (!detail) return NextResponse.json({ error: 'not found' }, { status: 404 });
16 + const format = new URL(req.url).searchParams.get('format') ?? 'csv';
17 + const rows = detail.summary.items.map((i) => {
18 + const src = detail.items.find((x) => x.id === i.id)!;
19 + return {
20 + item_id: i.id,
21 + asset_id: i.assetId,
22 + asset_slug: src.assetSlug,
23 + title: i.title,
24 + category: i.categorySlug,
25 + variant: src.variantLabel,
26 + grader: i.grader,
27 + grade: i.grade,
28 + cert: src.certificationNumber,
29 + serial: src.serial,
30 + quantity: i.quantity,
31 + acquired_at: i.acquiredAt,
32 + purchase_price: src.purchasePriceNative,
33 + currency: src.acquiredCurrency,
34 + purchase_price_usd: i.purchasePriceUsd,
35 + source: src.source,
36 + condition: src.condition,
37 + value_usd: i.valueUsd,
38 + value_source: i.valueSource,
39 + confidence: i.confidence,
40 + gain_usd: i.gainUsd,
41 + gain_pct: i.gainPct,
42 + tags: src.tags.join('|'),
43 + notes: src.notes,
44 + };
45 + });
46 + const stamp = new Date().toISOString().slice(0, 10);
47 + if (format === 'json') {
48 + return new NextResponse(JSON.stringify({ collection: detail.collection.name, exportedAt: new Date().toISOString(), summary: { valueUsd: detail.summary.valueUsd, costBasisUsd: detail.summary.costBasisUsd, gainUsd: detail.summary.gainUsd, returnPct: detail.summary.returnPct, items: detail.summary.itemCount }, items: rows }, null, 2), { headers: { 'content-type': 'application/json', 'content-disposition': `attachment; filename="rareindex-${detail.collection.publicSlug ?? id}-${stamp}.json"` } });
49 + }
50 + return new NextResponse(toCsv(rows, COLUMNS), { headers: { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': `attachment; filename="rareindex-${detail.collection.publicSlug ?? id}-${stamp}.csv"` } });
51 +}
added apps/web/src/app/api/account/export/route.ts +29 −0
@@ -0,0 +1,29 @@
1 +import { NextResponse } from 'next/server';
2 +import { eq, inArray } from '@/lib/db';
3 +import { db, collections, collectionItems, watchlists, watchlistItems, alerts, savedSearches, priceTargets, notifications, loginEvents, apiKeys } from '@/lib/db';
4 +import { getCurrentUser } from '@/lib/auth/session';
5 +
6 +export const dynamic = 'force-dynamic';
7 +
8 +/** Full personal data export (GDPR-style): everything the member created, as JSON. */
9 +export async function GET() {
10 + const u = await getCurrentUser();
11 + if (!u) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
12 + const cols = await db().select().from(collections).where(eq(collections.userId, u.id));
13 + const items = cols.length ? await db().select().from(collectionItems).where(inArray(collectionItems.collectionId, cols.map((c) => c.id))) : [];
14 + const wls = await db().select().from(watchlists).where(eq(watchlists.userId, u.id));
15 + const wItems = wls.length ? await db().select().from(watchlistItems).where(inArray(watchlistItems.watchlistId, wls.map((w) => w.id))) : [];
16 + const [al, ss, pt, nt, le, keys] = await Promise.all([
17 + db().select().from(alerts).where(eq(alerts.userId, u.id)),
18 + db().select().from(savedSearches).where(eq(savedSearches.userId, u.id)),
19 + db().select().from(priceTargets).where(eq(priceTargets.userId, u.id)),
20 + db().select().from(notifications).where(eq(notifications.userId, u.id)),
21 + db().select().from(loginEvents).where(eq(loginEvents.userId, u.id)),
22 + db().select({ id: apiKeys.id, name: apiKeys.name, prefix: apiKeys.prefix, tier: apiKeys.tier, createdAt: apiKeys.createdAt, revokedAt: apiKeys.revokedAt }).from(apiKeys).where(eq(apiKeys.userId, u.id)),
23 + ]);
24 + const { passwordHash: _p, totpSecretEnc: _t, ...profile } = u;
25 + void _p;
26 + void _t;
27 + const body = { exportedAt: new Date().toISOString(), profile, collections: cols, collectionItems: items, watchlists: wls, watchlistItems: wItems, alerts: al, savedSearches: ss, priceTargets: pt, notifications: nt, loginEvents: le, apiKeys: keys };
28 + return new NextResponse(JSON.stringify(body, null, 2), { headers: { 'content-type': 'application/json', 'content-disposition': `attachment; filename="rareindex-export-${new Date().toISOString().slice(0, 10)}.json"` } });
29 +}
added apps/web/src/app/api/account/uploads/[id]/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { NextResponse } from 'next/server';
2 +import { readUpload } from '@/lib/account/uploads';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +/** Serves member uploads. Avatars and photos of public collections are public; ids are unguessable (100-bit). */
7 +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
8 + const { id } = await ctx.params;
9 + if (!/^img_[0-9a-z]{20}$/.test(id)) return new NextResponse('Not found', { status: 404 });
10 + const up = await readUpload(id);
11 + if (!up) return new NextResponse('Not found', { status: 404 });
12 + return new NextResponse(new Uint8Array(up.buf), { headers: { 'content-type': up.mime, 'cache-control': 'public, max-age=31536000, immutable', 'x-content-type-options': 'nosniff' } });
13 +}
added apps/web/src/app/api/auth/logout/route.ts +11 −0
@@ -0,0 +1,11 @@
1 +import { NextResponse } from 'next/server';
2 +import { destroySession } from '@/lib/auth/session';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +/** POST /api/auth/logout — used by the user menu and by API clients; GET is not allowed (CSRF). */
7 +export async function POST(req: Request) {
8 + await destroySession();
9 + const url = new URL('/', req.url);
10 + return NextResponse.redirect(url, 303);
11 +}
added apps/web/src/app/api/auth/session/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { NextResponse } from 'next/server';
2 +import { getCurrentUser } from '@/lib/auth/session';
3 +import { unreadCount } from '@/lib/account/queries';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** GET /api/auth/session — lightweight identity for client components (header user menu). */
8 +export async function GET() {
9 + const u = await getCurrentUser();
10 + if (!u) return NextResponse.json({ user: null }, { headers: { 'cache-control': 'private, no-store' } });
11 + const unread = await unreadCount(u.id);
12 + return NextResponse.json({ user: { id: u.id, email: u.email, name: u.name, handle: u.handle, avatarUrl: u.avatarUrl, displayCurrency: u.displayCurrency, role: u.role }, unread }, { headers: { 'cache-control': 'private, no-store' } });
13 +}
added apps/web/src/app/u/[handle]/[slug]/page.tsx +100 −0
@@ -0,0 +1,100 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { publicProfile } from '@/lib/account/queries';
5 +import { Card, CardHeader, Delta, Table, th, td, tdNum } from '@/components/ui/primitives';
6 +import { Donut } from '@/components/account/charts';
7 +import { ValueSourceBadge } from '@/components/account/portfolio-widgets';
8 +import { fmtMoney, confidenceLabel } from '@/lib/format';
9 +
10 +export async function generateMetadata({ params }: { params: Promise<{ handle: string; slug: string }> }): Promise<Metadata> {
11 + const { handle, slug } = await params;
12 + const p = await publicProfile(handle);
13 + const c = p?.collections.find((x) => x.collection.publicSlug === slug);
14 + if (!p || !c) return { title: 'Collection not found' };
15 + return { title: `${c.collection.name} · ${p.user.name ?? `@${handle}`}`, description: c.collection.description ?? `${c.summary.itemCount} items on RareIndex` };
16 +}
17 +
18 +export default async function PublicCollectionPage({ params }: { params: Promise<{ handle: string; slug: string }> }) {
19 + const { handle, slug } = await params;
20 + const p = await publicProfile(handle);
21 + const c = p?.collections.find((x) => x.collection.publicSlug === slug);
22 + if (!p || !c) notFound();
23 + const items = p.items.filter((i) => i.collectionId === c.collection.id);
24 + const rows = [...c.summary.items].sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0));
25 + return (
26 + <div className="mx-auto max-w-5xl">
27 + <nav className="mb-2 pt-4 text-xs text-muted">
28 + <Link href={`/u/${handle}`} className="hover:text-fg">
29 + @{handle}
30 + </Link>{' '}
31 + / <span className="text-fg">{c.collection.name}</span>
32 + </nav>
33 + <header className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
34 + <div>
35 + <h1 className="text-2xl font-semibold tracking-tight">{c.collection.name}</h1>
36 + {c.collection.description ? <p className="mt-1 text-sm text-muted">{c.collection.description}</p> : null}
37 + </div>
38 + <div className="text-right">
39 + <p className="num text-2xl font-semibold">{c.summary.valuedCount ? fmtMoney(c.summary.valueUsd) : '—'}</p>
40 + <p className="text-xs text-muted">
41 + {c.summary.itemCount} items · confidence {confidenceLabel(c.summary.confidence)}
42 + </p>
43 + </div>
44 + </header>
45 + <div className="mb-5 grid gap-4 md:grid-cols-3">
46 + <Card className="md:col-span-1">
47 + <CardHeader title="Allocation" />
48 + <div className="p-4">
49 + <Donut data={c.summary.allocationByCategory.map((b) => ({ label: b.label, value: b.valueUsd }))} size={100} />
50 + </div>
51 + </Card>
52 + <Card className="md:col-span-2">
53 + <CardHeader title="Items" />
54 + <Table>
55 + <thead>
56 + <tr>
57 + <th className={th}>Item</th>
58 + <th className={th}>Variant</th>
59 + <th className={`${th} text-right`}>Qty</th>
60 + <th className={`${th} text-right`}>Value</th>
61 + <th className={th}>Basis</th>
62 + </tr>
63 + </thead>
64 + <tbody>
65 + {rows.map((i) => {
66 + const src = items.find((x) => x.id === i.id)!;
67 + return (
68 + <tr key={i.id}>
69 + <td className={td}>
70 + <div className="flex items-center gap-2.5">
71 + {src.photos[0] || src.heroImageUrl ? (
72 + // eslint-disable-next-line @next/next/no-img-element
73 + <img src={src.photos[0] ?? src.heroImageUrl ?? ''} alt="" className="h-9 w-9 rounded-sm object-cover" />
74 + ) : (
75 + <span className="h-9 w-9 rounded-sm bg-inset" />
76 + )}
77 + <Link href={`/asset/${src.assetSlug}`} className="max-w-[260px] truncate font-medium hover:underline">
78 + {i.title}
79 + </Link>
80 + </div>
81 + </td>
82 + <td className={`${td} text-xs`}>{src.variantLabel ?? (i.grader ? `${i.grader.toUpperCase()} ${i.grade ?? ''}` : src.condition ?? '—')}</td>
83 + <td className={tdNum}>{i.quantity}</td>
84 + <td className={tdNum}>{i.valueUsd === null ? <span className="text-subtle">—</span> : fmtMoney(i.valueUsd)}</td>
85 + <td className={td}>
86 + <ValueSourceBadge item={i} />
87 + </td>
88 + </tr>
89 + );
90 + })}
91 + </tbody>
92 + </Table>
93 + </Card>
94 + </div>
95 + <p className="text-[11px] text-subtle">
96 + Shared by @{handle}. Values are RareIndex Valuation estimates; <Delta value={c.summary.returnPct} /> return is shown without purchase details. RareIndex does not authenticate items.
97 + </p>
98 + </div>
99 + );
100 +}
added apps/web/src/app/u/[handle]/opengraph-image.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import { ImageResponse } from 'next/og';
2 +import { publicProfile } from '@/lib/account/queries';
3 +import { summarizePortfolio } from '@/lib/account/portfolio';
4 +import { fmtMoney } from '@/lib/format';
5 +
6 +export const runtime = 'nodejs';
7 +export const alt = 'RareIndex collector profile';
8 +export const size = { width: 1200, height: 630 };
9 +export const contentType = 'image/png';
10 +
11 +export default async function Image({ params }: { params: Promise<{ handle: string }> }) {
12 + const { handle } = await params;
13 + const p = await publicProfile(handle);
14 + const name = p?.user.name ?? `@${handle}`;
15 + const total = p ? summarizePortfolio(p.items) : null;
16 + const top = total?.allocationByFamily.slice(0, 3).map((b) => b.label).join(' · ') ?? '';
17 + return new ImageResponse(
18 + (
19 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: '#fafaf9', color: '#0b0b0c', fontFamily: 'sans-serif' }}>
20 + <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 28, fontWeight: 600 }}>
21 + <div style={{ width: 40, height: 40, background: '#0b0b0c', color: '#fff', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22 }}>R</div>
22 + RareIndex <span style={{ color: '#55555b', fontWeight: 400 }}>· collector profile</span>
23 + </div>
24 + <div style={{ display: 'flex', flexDirection: 'column' }}>
25 + <div style={{ fontSize: 72, fontWeight: 700, letterSpacing: -2 }}>{name}</div>
26 + <div style={{ fontSize: 30, color: '#55555b', marginTop: 8 }}>@{handle}{top ? ` · ${top}` : ''}</div>
27 + </div>
28 + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', borderTop: '2px solid #e4e4e2', paddingTop: 24 }}>
29 + <div style={{ display: 'flex', flexDirection: 'column' }}>
30 + <div style={{ fontSize: 20, color: '#8a8a91', textTransform: 'uppercase', letterSpacing: 2 }}>Public collection value</div>
31 + <div style={{ fontSize: 56, fontWeight: 700 }}>{total?.valuedCount ? fmtMoney(total.valueUsd) : '—'}</div>
32 + </div>
33 + <div style={{ fontSize: 24, color: '#55555b' }}>{total ? `${total.itemCount} items · ${p?.collections.length ?? 0} collections` : ''}</div>
34 + </div>
35 + </div>
36 + ),
37 + { ...size },
38 + );
39 +}
added apps/web/src/app/u/[handle]/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { eq } from '@/lib/db';
5 +import { db, userBadges } from '@/lib/db';
6 +import { publicProfile } from '@/lib/account/queries';
7 +import { summarizePortfolio } from '@/lib/account/portfolio';
8 +import { BADGE_DEFS } from '@/lib/account/badges';
9 +import { Card, CardHeader, Badge, EmptyState, Delta } from '@/components/ui/primitives';
10 +import { Donut } from '@/components/account/charts';
11 +import { fmtMoney, fmtDate } from '@/lib/format';
12 +
13 +export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }): Promise<Metadata> {
14 + const { handle } = await params;
15 + const p = await publicProfile(handle);
16 + if (!p) return { title: 'Collector not found' };
17 + const name = p.user.name ?? `@${p.user.handle}`;
18 + return { title: `${name} · Collector profile`, description: p.user.bio ?? `${name} on RareIndex`, openGraph: { title: `${name} on RareIndex`, description: p.user.bio ?? 'Collector profile', images: [`/u/${handle}/opengraph-image`] } };
19 +}
20 +
21 +export default async function ProfilePage({ params }: { params: Promise<{ handle: string }> }) {
22 + const { handle } = await params;
23 + const p = await publicProfile(handle);
24 + if (!p) notFound();
25 + const total = summarizePortfolio(p.items);
26 + const badges = await db().select().from(userBadges).where(eq(userBadges.userId, p.user.id));
27 + return (
28 + <div className="mx-auto max-w-4xl">
29 + <header className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center">
30 + {p.user.avatarUrl ? (
31 + // eslint-disable-next-line @next/next/no-img-element
32 + <img src={p.user.avatarUrl} alt="" className="h-20 w-20 rounded-full border border-border object-cover" />
33 + ) : (
34 + <span className="inline-flex h-20 w-20 items-center justify-center rounded-full bg-accent text-2xl font-semibold text-accent-fg">{(p.user.name ?? p.user.handle ?? '?').slice(0, 1).toUpperCase()}</span>
35 + )}
36 + <div className="min-w-0 flex-1">
37 + <h1 className="text-2xl font-semibold tracking-tight">{p.user.name ?? `@${p.user.handle}`}</h1>
38 + <p className="text-sm text-muted">
39 + @{p.user.handle} · member since {fmtDate(p.user.createdAt, { month: 'long' })}
40 + </p>
41 + {p.user.bio ? <p className="mt-2 max-w-xl text-sm">{p.user.bio}</p> : null}
42 + {badges.length ? (
43 + <div className="mt-3 flex flex-wrap gap-1.5">
44 + {badges.map((b) => (
45 + <Badge key={b.badge} tone={BADGE_DEFS[b.badge]?.tone ?? 'neutral'}>
46 + {BADGE_DEFS[b.badge]?.label ?? b.badge}
47 + </Badge>
48 + ))}
49 + </div>
50 + ) : null}
51 + </div>
52 + <div className="text-right">
53 + <p className="text-[11px] uppercase tracking-wider text-subtle">Public collection value</p>
54 + <p className="num text-2xl font-semibold">{total.valuedCount ? fmtMoney(total.valueUsd) : '—'}</p>
55 + <p className="text-xs text-muted">
56 + {total.itemCount} item{total.itemCount === 1 ? '' : 's'} across {p.collections.length} public collection{p.collections.length === 1 ? '' : 's'}
57 + </p>
58 + </div>
59 + </header>
60 + {p.collections.length === 0 ? (
61 + <Card>
62 + <EmptyState title="No public collections" description="This collector has not shared any collection yet." />
63 + </Card>
64 + ) : (
65 + <div className="grid gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
66 + <div className="space-y-3">
67 + {p.collections.map(({ collection: c, summary: s }) => (
68 + <Link key={c.id} href={`/u/${handle}/${c.publicSlug}`} className="card flex items-center justify-between gap-3 p-4 hover:border-border-strong">
69 + <div className="min-w-0">
70 + <p className="truncate text-sm font-semibold">{c.name}</p>
71 + <p className="text-xs text-muted">{c.description ?? `${s.itemCount} items`}</p>
72 + </div>
73 + <div className="text-right">
74 + <p className="num text-base font-semibold">{s.valuedCount ? fmtMoney(s.valueUsd) : '—'}</p>
75 + <Delta value={s.returnPct} className="text-xs" />
76 + </div>
77 + </Link>
78 + ))}
79 + </div>
80 + <Card>
81 + <CardHeader title="Allocation" />
82 + <div className="p-4">
83 + <Donut data={total.allocationByFamily.map((b) => ({ label: b.label, value: b.valueUsd }))} size={110} />
84 + </div>
85 + </Card>
86 + </div>
87 + )}
88 + <p className="mt-6 text-[11px] text-subtle">Values are RareIndex Valuation estimates with confidence levels; purchase prices are never shown publicly. RareIndex does not authenticate items.</p>
89 + </div>
90 + );
91 +}
added apps/web/src/components/account/account-nav.tsx +92 −0
@@ -0,0 +1,92 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { cn } from '@/lib/format';
6 +
7 +export interface AccountNavItem {
8 + href: string;
9 + label: string;
10 + badge?: number;
11 +}
12 +
13 +export const ACCOUNT_NAV: Array<{ label: string; items: AccountNavItem[] }> = [
14 + {
15 + label: 'My RareIndex',
16 + items: [
17 + { href: '/collections', label: 'Collections' },
18 + { href: '/my-index', label: 'My Index' },
19 + { href: '/watchlist', label: 'Watchlist' },
20 + { href: '/targets', label: 'Price targets' },
21 + { href: '/deals', label: 'Deal Radar' },
22 + { href: '/saved', label: 'Saved searches' },
23 + ],
24 + },
25 + {
26 + label: 'Signals',
27 + items: [
28 + { href: '/alerts', label: 'Alerts' },
29 + { href: '/notifications', label: 'Inbox' },
30 + ],
31 + },
32 + {
33 + label: 'Account',
34 + items: [
35 + { href: '/account/settings', label: 'Profile' },
36 + { href: '/account/security', label: 'Security' },
37 + { href: '/account/notifications', label: 'Notifications' },
38 + { href: '/account/api-keys', label: 'API keys' },
39 + { href: '/account/data', label: 'Data & privacy' },
40 + ],
41 + },
42 +];
43 +
44 +export function AccountNav({ unread, handle }: { unread: number; handle: string | null }) {
45 + const pathname = usePathname();
46 + return (
47 + <nav aria-label="Account" className="text-[13px]">
48 + {/* mobile: horizontal scroll */}
49 + <div className="-mx-4 flex gap-1 overflow-x-auto px-4 pb-2 scrollbar-none lg:hidden">
50 + {ACCOUNT_NAV.flatMap((g) => g.items).map((i) => {
51 + const active = pathname === i.href || pathname.startsWith(`${i.href}/`);
52 + return (
53 + <Link key={i.href} href={i.href} className={cn('shrink-0 rounded-full border px-3 py-1.5', active ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted')}>
54 + {i.label}
55 + {i.href === '/notifications' && unread > 0 ? <span className="ml-1.5 rounded-full bg-alert px-1.5 text-[10px] font-semibold text-white">{unread}</span> : null}
56 + </Link>
57 + );
58 + })}
59 + </div>
60 + {/* desktop: sidebar */}
61 + <div className="hidden lg:block">
62 + {ACCOUNT_NAV.map((g) => (
63 + <div key={g.label} className="mb-4">
64 + <p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">{g.label}</p>
65 + <ul className="space-y-0.5">
66 + {g.items.map((i) => {
67 + const active = pathname === i.href || pathname.startsWith(`${i.href}/`);
68 + return (
69 + <li key={i.href}>
70 + <Link href={i.href} className={cn('flex items-center justify-between rounded-md px-2 py-1.5', active ? 'bg-inset font-medium text-fg' : 'text-muted hover:bg-inset hover:text-fg')}>
71 + {i.label}
72 + {i.href === '/notifications' && unread > 0 ? <span className="rounded-full bg-alert px-1.5 text-[10px] font-semibold text-white">{unread}</span> : null}
73 + </Link>
74 + </li>
75 + );
76 + })}
77 + </ul>
78 + </div>
79 + ))}
80 + {handle ? (
81 + <Link href={`/u/${handle}`} className="block rounded-md border border-dashed border-border px-2 py-1.5 text-xs text-muted hover:text-fg">
82 + Public profile → /u/{handle}
83 + </Link>
84 + ) : (
85 + <Link href="/account/settings#handle" className="block rounded-md border border-dashed border-border px-2 py-1.5 text-xs text-muted hover:text-fg">
86 + Claim a public handle →
87 + </Link>
88 + )}
89 + </div>
90 + </nav>
91 + );
92 +}
added apps/web/src/components/account/asset-picker.tsx +146 −0
@@ -0,0 +1,146 @@
1 +'use client';
2 +
3 +import { useEffect, useRef, useState } from 'react';
4 +import { Search, X } from 'lucide-react';
5 +import { inputClass } from './form';
6 +import { fmtMoney } from '@/lib/format';
7 +
8 +export interface PickedAsset {
9 + id: string;
10 + title: string;
11 + categorySlug: string;
12 + year: number | null;
13 + heroImageUrl: string | null;
14 + rivUsd: number | null;
15 +}
16 +interface Variant {
17 + id: string;
18 + label: string;
19 + rivUsd: number | null;
20 +}
21 +
22 +/**
23 + * Typeahead over RareIndex assets (server search) + variant picker. Emits hidden inputs
24 + * `assetId` and `variantId` for the surrounding form.
25 + */
26 +export function AssetPicker({ name = 'assetId', initial, error, withVariant = true, label = 'Asset' }: { name?: string; initial?: PickedAsset | null; error?: string | null; withVariant?: boolean; label?: string }) {
27 + const [q, setQ] = useState('');
28 + const [hits, setHits] = useState<PickedAsset[]>([]);
29 + const [open, setOpen] = useState(false);
30 + const [picked, setPicked] = useState<PickedAsset | null>(initial ?? null);
31 + const [variants, setVariants] = useState<Variant[]>([]);
32 + const [loading, setLoading] = useState(false);
33 + const box = useRef<HTMLDivElement>(null);
34 +
35 + useEffect(() => {
36 + if (q.trim().length < 2) return;
37 + const ctrl = new AbortController();
38 + const t = setTimeout(async () => {
39 + setLoading(true);
40 + try {
41 + const res = await fetch(`/api/account/assets/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal });
42 + if (res.ok) setHits((await res.json()) as PickedAsset[]);
43 + } catch {
44 + /* aborted */
45 + } finally {
46 + setLoading(false);
47 + }
48 + }, 180);
49 + return () => {
50 + clearTimeout(t);
51 + ctrl.abort();
52 + };
53 + }, [q]);
54 +
55 + useEffect(() => {
56 + if (!picked || !withVariant) return;
57 + fetch(`/api/account/assets/${picked.id}/variants`)
58 + .then((r) => (r.ok ? r.json() : []))
59 + .then((v: Variant[]) => setVariants(v))
60 + .catch(() => setVariants([]));
61 + }, [picked, withVariant]);
62 +
63 + useEffect(() => {
64 + const onDoc = (e: MouseEvent) => {
65 + if (box.current && !box.current.contains(e.target as Node)) setOpen(false);
66 + };
67 + document.addEventListener('mousedown', onDoc);
68 + return () => document.removeEventListener('mousedown', onDoc);
69 + }, []);
70 +
71 + return (
72 + <div className="space-y-1.5" ref={box}>
73 + <label className="block text-xs font-medium text-muted">{label}</label>
74 + <input type="hidden" name={name} value={picked?.id ?? ''} />
75 + {picked ? (
76 + <div className="flex items-center gap-3 rounded-md border border-border bg-sunken px-3 py-2">
77 + {picked.heroImageUrl ? (
78 + // eslint-disable-next-line @next/next/no-img-element
79 + <img src={picked.heroImageUrl} alt="" className="h-10 w-10 rounded-sm object-cover" />
80 + ) : (
81 + <span className="h-10 w-10 rounded-sm bg-inset" />
82 + )}
83 + <div className="min-w-0 flex-1">
84 + <p className="truncate text-sm font-medium">{picked.title}</p>
85 + <p className="text-xs text-muted">
86 + {picked.categorySlug.replace(/_/g, ' ')}
87 + {picked.year ? ` · ${picked.year}` : ''}
88 + {picked.rivUsd ? ` · RIV ${fmtMoney(picked.rivUsd)}` : ' · no valuation yet'}
89 + </p>
90 + </div>
91 + <button type="button" aria-label="Change asset" className="text-subtle hover:text-fg" onClick={() => setPicked(null)}>
92 + <X className="h-4 w-4" />
93 + </button>
94 + </div>
95 + ) : (
96 + <div className="relative">
97 + <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-subtle" />
98 + <input value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); if (e.target.value.trim().length < 2) setHits([]); }} onFocus={() => setOpen(true)} placeholder="Search RareIndex assets… e.g. 1999 Charizard, Rolex 116500LN, LEGO 75192" className={`${inputClass} pl-8`} autoComplete="off" aria-autocomplete="list" />
99 + {open && (hits.length > 0 || loading || q.trim().length >= 2) ? (
100 + <ul className="absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-md border border-border bg-elevated shadow-pop" role="listbox">
101 + {hits.map((h) => (
102 + <li key={h.id}>
103 + <button type="button" className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-inset" onClick={() => { setPicked(h); setOpen(false); setQ(''); }}>
104 + {h.heroImageUrl ? (
105 + // eslint-disable-next-line @next/next/no-img-element
106 + <img src={h.heroImageUrl} alt="" className="h-9 w-9 rounded-sm object-cover" />
107 + ) : (
108 + <span className="h-9 w-9 rounded-sm bg-inset" />
109 + )}
110 + <span className="min-w-0 flex-1">
111 + <span className="block truncate text-sm">{h.title}</span>
112 + <span className="block text-xs text-muted">
113 + {h.categorySlug.replace(/_/g, ' ')}
114 + {h.year ? ` · ${h.year}` : ''}
115 + </span>
116 + </span>
117 + <span className="num text-xs text-muted">{h.rivUsd ? fmtMoney(h.rivUsd) : '—'}</span>
118 + </button>
119 + </li>
120 + ))}
121 + {!loading && hits.length === 0 && q.trim().length >= 2 ? <li className="px-3 py-3 text-xs text-muted">No asset matches yet. RareIndex adds assets as connectors ingest them; try a different spelling or set name.</li> : null}
122 + {loading ? <li className="px-3 py-2 text-xs text-subtle">Searching…</li> : null}
123 + </ul>
124 + ) : null}
125 + </div>
126 + )}
127 + {error ? <p className="text-xs text-loss">{error}</p> : null}
128 + {withVariant && picked ? (
129 + <div className="pt-1">
130 + <label className="block text-xs font-medium text-muted" htmlFor="variantId">
131 + Variant / grade
132 + </label>
133 + <select name="variantId" id="variantId" className={`${inputClass} mt-1.5`} defaultValue="">
134 + <option value="">Not specified — use grader/grade fields below</option>
135 + {variants.map((v) => (
136 + <option key={v.id} value={v.id}>
137 + {v.label}
138 + {v.rivUsd ? ` · RIV ${fmtMoney(v.rivUsd)}` : ''}
139 + </option>
140 + ))}
141 + </select>
142 + </div>
143 + ) : null}
144 + </div>
145 + );
146 +}
added apps/web/src/components/account/charts.tsx +155 −0
@@ -0,0 +1,155 @@
1 +/**
2 + * Small server-renderable SVG charts for account pages: one system (hairlines, tabular numbers,
3 + * neutral ink, accents only for gain/loss/index). No client JS required.
4 + */
5 +import { cn } from '@/lib/format';
6 +
7 +const PALETTE = ['var(--ri-index)', 'var(--ri-gold)', 'var(--ri-rarity)', 'var(--ri-gain)', 'var(--ri-alert)', 'var(--ri-fg-muted)', 'var(--ri-loss)', 'var(--ri-fg-subtle)'];
8 +
9 +export function Donut({ data, size = 120, thickness = 14, className, centerLabel, centerValue }: { data: Array<{ label: string; value: number }>; size?: number; thickness?: number; className?: string; centerLabel?: string; centerValue?: string }) {
10 + const total = data.reduce((a, d) => a + d.value, 0);
11 + const r = (size - thickness) / 2;
12 + const c = 2 * Math.PI * r;
13 + let offset = 0;
14 + return (
15 + <div className={cn('flex items-center gap-4', className)}>
16 + <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Allocation">
17 + <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--ri-bg-inset)" strokeWidth={thickness} />
18 + {total > 0
19 + ? data.map((d, i) => {
20 + const len = (d.value / total) * c;
21 + const el = <circle key={d.label} cx={size / 2} cy={size / 2} r={r} fill="none" stroke={PALETTE[i % PALETTE.length]} strokeWidth={thickness} strokeDasharray={`${len} ${c - len}`} strokeDashoffset={-offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} />;
22 + offset += len;
23 + return el;
24 + })
25 + : null}
26 + {centerValue ? (
27 + <>
28 + <text x="50%" y="48%" textAnchor="middle" fontSize={size / 9} fontWeight={600} fill="var(--ri-fg)" style={{ fontVariantNumeric: 'tabular-nums' }}>
29 + {centerValue}
30 + </text>
31 + {centerLabel ? (
32 + <text x="50%" y="62%" textAnchor="middle" fontSize={size / 13} fill="var(--ri-fg-subtle)">
33 + {centerLabel}
34 + </text>
35 + ) : null}
36 + </>
37 + ) : null}
38 + </svg>
39 + <ul className="min-w-0 flex-1 space-y-1 text-xs">
40 + {data.slice(0, 8).map((d, i) => (
41 + <li key={d.label} className="flex items-center gap-2">
42 + <span className="h-2 w-2 shrink-0 rounded-sm" style={{ background: PALETTE[i % PALETTE.length] }} />
43 + <span className="min-w-0 flex-1 truncate text-muted">{d.label}</span>
44 + <span className="num text-fg">{total > 0 ? `${((d.value / total) * 100).toFixed(0)}%` : '—'}</span>
45 + </li>
46 + ))}
47 + {data.length === 0 ? <li className="text-subtle">No valued items yet.</li> : null}
48 + </ul>
49 + </div>
50 + );
51 +}
52 +
53 +export interface SeriesPoint {
54 + date: string;
55 + value: number;
56 +}
57 +
58 +export function LineChart({ series, height = 180, className, formatY = (v: number) => v.toFixed(0), showArea = true, ariaLabel = 'Value over time' }: { series: Array<{ name: string; points: SeriesPoint[]; color?: string; dashed?: boolean }>; height?: number; className?: string; formatY?: (v: number) => string; showArea?: boolean; ariaLabel?: string }) {
59 + const all = series.flatMap((s) => s.points);
60 + if (all.length < 2) {
61 + return (
62 + <div className={cn('flex items-center justify-center rounded-md border border-dashed border-border text-xs text-subtle', className)} style={{ height }}>
63 + Not enough history yet — values are snapshotted daily.
64 + </div>
65 + );
66 + }
67 + const W = 640;
68 + const H = height;
69 + const padL = 44;
70 + const padR = 8;
71 + const padT = 8;
72 + const padB = 20;
73 + const dates = [...new Set(all.map((p) => p.date))].sort();
74 + const t0 = new Date(dates[0]!).getTime();
75 + const t1 = new Date(dates[dates.length - 1]!).getTime();
76 + const min = Math.min(...all.map((p) => p.value));
77 + const max = Math.max(...all.map((p) => p.value));
78 + const span = max - min || max || 1;
79 + const yMin = min - span * 0.08;
80 + const yMax = max + span * 0.08;
81 + const x = (d: string) => padL + ((new Date(d).getTime() - t0) / Math.max(1, t1 - t0)) * (W - padL - padR);
82 + const y = (v: number) => padT + (1 - (v - yMin) / (yMax - yMin)) * (H - padT - padB);
83 + const ticks = [yMin + (yMax - yMin) * 0.1, (yMin + yMax) / 2, yMax - (yMax - yMin) * 0.1];
84 + return (
85 + <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={ariaLabel} preserveAspectRatio="none">
86 + {ticks.map((t) => (
87 + <g key={t}>
88 + <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} stroke="var(--ri-border)" strokeDasharray="2 3" />
89 + <text x={padL - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ri-fg-subtle)" style={{ fontVariantNumeric: 'tabular-nums' }}>
90 + {formatY(t)}
91 + </text>
92 + </g>
93 + ))}
94 + {series.map((s, i) => {
95 + const pts = [...s.points].sort((a, b) => a.date.localeCompare(b.date));
96 + const d = pts.map((p, j) => `${j === 0 ? 'M' : 'L'}${x(p.date).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
97 + const color = s.color ?? PALETTE[i % PALETTE.length];
98 + const last = pts[pts.length - 1]!;
99 + return (
100 + <g key={s.name}>
101 + {showArea && i === 0 ? <path d={`${d} L${x(last.date).toFixed(1)},${H - padB} L${x(pts[0]!.date).toFixed(1)},${H - padB} Z`} fill={color} opacity={0.06} /> : null}
102 + <path d={d} fill="none" stroke={color} strokeWidth={1.6} strokeDasharray={s.dashed ? '4 3' : undefined} vectorEffect="non-scaling-stroke" />
103 + <circle cx={x(last.date)} cy={y(last.value)} r={2.5} fill={color} />
104 + </g>
105 + );
106 + })}
107 + <text x={padL} y={H - 6} fontSize={10} fill="var(--ri-fg-subtle)">
108 + {dates[0]}
109 + </text>
110 + <text x={W - padR} y={H - 6} fontSize={10} textAnchor="end" fill="var(--ri-fg-subtle)">
111 + {dates[dates.length - 1]}
112 + </text>
113 + </svg>
114 + );
115 +}
116 +
117 +export function Sparkline({ points, width = 96, height = 24, positive }: { points: number[]; width?: number; height?: number; positive?: boolean | null }) {
118 + if (points.length < 2) return <span className="text-subtle">—</span>;
119 + const min = Math.min(...points);
120 + const max = Math.max(...points);
121 + const span = max - min || 1;
122 + const d = points.map((v, i) => `${i === 0 ? 'M' : 'L'}${((i / (points.length - 1)) * width).toFixed(1)},${(height - 2 - ((v - min) / span) * (height - 4)).toFixed(1)}`).join(' ');
123 + const stroke = positive === null || positive === undefined ? 'var(--ri-fg-muted)' : positive ? 'var(--ri-gain)' : 'var(--ri-loss)';
124 + return (
125 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>
126 + <path d={d} fill="none" stroke={stroke} strokeWidth={1.4} />
127 + </svg>
128 + );
129 +}
130 +
131 +export function Bars({ data, className, format = (v: number) => v.toFixed(0) }: { data: Array<{ label: string; value: number; tone?: 'gain' | 'loss' | 'neutral' }>; className?: string; format?: (v: number) => string }) {
132 + const max = Math.max(1, ...data.map((d) => Math.abs(d.value)));
133 + return (
134 + <ul className={cn('space-y-1.5 text-xs', className)}>
135 + {data.map((d) => (
136 + <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_120px_64px] items-center gap-2">
137 + <span className="truncate text-muted">{d.label}</span>
138 + <span className="h-2 rounded-sm bg-inset">
139 + <span className={cn('block h-2 rounded-sm', d.tone === 'loss' ? 'bg-loss' : d.tone === 'gain' ? 'bg-gain' : 'bg-index')} style={{ width: `${(Math.abs(d.value) / max) * 100}%` }} />
140 + </span>
141 + <span className={cn('num text-right', d.tone === 'loss' ? 'text-loss' : d.tone === 'gain' ? 'text-gain' : 'text-fg')}>{format(d.value)}</span>
142 + </li>
143 + ))}
144 + </ul>
145 + );
146 +}
147 +
148 +export function Progress({ value, className }: { value: number; className?: string }) {
149 + const v = Math.max(0, Math.min(1, value));
150 + return (
151 + <span className={cn('block h-1.5 w-full rounded-full bg-inset', className)}>
152 + <span className={cn('block h-1.5 rounded-full', v >= 1 ? 'bg-gain' : 'bg-index')} style={{ width: `${v * 100}%` }} />
153 + </span>
154 + );
155 +}
added apps/web/src/components/account/form.tsx +148 −0
@@ -0,0 +1,148 @@
1 +'use client';
2 +
3 +import { useFormStatus } from 'react-dom';
4 +import { useEffect, useMemo, useState, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
5 +import { Eye, EyeOff, Loader2 } from 'lucide-react';
6 +import { passwordStrength } from '@/lib/auth/password-strength';
7 +import type { ActionState } from '@/lib/auth/state';
8 +import { cn } from '@/lib/format';
9 +
10 +export const inputClass = 'block w-full rounded-md border border-border bg-elevated px-3 py-2 text-[15px] text-fg shadow-none placeholder:text-subtle focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-[var(--ri-ring)] md:text-sm disabled:opacity-60';
11 +
12 +export function Field({ label, name, error, hint, children, className }: { label: ReactNode; name: string; error?: string | null; hint?: ReactNode; children: ReactNode; className?: string }) {
13 + return (
14 + <div className={cn('space-y-1.5', className)}>
15 + <label htmlFor={name} className="block text-xs font-medium text-muted">
16 + {label}
17 + </label>
18 + {children}
19 + {error ? (
20 + <p className="text-xs text-loss" role="alert">
21 + {error}
22 + </p>
23 + ) : hint ? (
24 + <p className="text-xs text-subtle">{hint}</p>
25 + ) : null}
26 + </div>
27 + );
28 +}
29 +
30 +export function TextInput(props: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }) {
31 + const { invalid, className, ...rest } = props;
32 + return <input {...rest} id={rest.id ?? rest.name} className={cn(inputClass, invalid && 'border-loss', className)} aria-invalid={invalid || undefined} />;
33 +}
34 +
35 +export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
36 + const { className, ...rest } = props;
37 + return <select {...rest} id={rest.id ?? rest.name} className={cn(inputClass, 'pr-8', className)} />;
38 +}
39 +
40 +export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
41 + const { className, ...rest } = props;
42 + return <textarea {...rest} id={rest.id ?? rest.name} className={cn(inputClass, 'min-h-[88px] resize-y', className)} />;
43 +}
44 +
45 +export function Checkbox({ name, label, defaultChecked, description }: { name: string; label: ReactNode; defaultChecked?: boolean; description?: ReactNode }) {
46 + return (
47 + <label className="flex cursor-pointer items-start gap-2.5 text-sm">
48 + <input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5 h-4 w-4 rounded-sm border-border accent-[var(--ri-accent)]" />
49 + <span>
50 + <span className="block text-fg">{label}</span>
51 + {description ? <span className="block text-xs text-muted">{description}</span> : null}
52 + </span>
53 + </label>
54 + );
55 +}
56 +
57 +export function SubmitButton({ children, variant = 'primary', className, pendingText, disabled }: { children: ReactNode; variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; className?: string; pendingText?: string; disabled?: boolean }) {
58 + const { pending } = useFormStatus();
59 + const styles = {
60 + primary: 'bg-accent text-accent-fg hover:opacity-90',
61 + secondary: 'border border-border bg-elevated text-fg hover:bg-inset',
62 + danger: 'bg-loss text-white hover:opacity-90',
63 + ghost: 'text-muted hover:bg-inset hover:text-fg',
64 + }[variant];
65 + return (
66 + <button type="submit" disabled={pending || disabled} className={cn('inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60 md:h-9', styles, className)}>
67 + {pending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
68 + {pending && pendingText ? pendingText : children}
69 + </button>
70 + );
71 +}
72 +
73 +export function FormMessage({ state, className }: { state: ActionState; className?: string }) {
74 + if (state.error) {
75 + return (
76 + <div role="alert" className={cn('rounded-md border border-loss/30 bg-loss-bg px-3 py-2 text-sm text-loss', className)}>
77 + {state.error}
78 + </div>
79 + );
80 + }
81 + if (state.ok && state.message) {
82 + return (
83 + <div role="status" className={cn('rounded-md border border-gain/30 bg-gain-bg px-3 py-2 text-sm text-gain', className)}>
84 + {state.message}
85 + </div>
86 + );
87 + }
88 + return null;
89 +}
90 +
91 +/** Password input with reveal toggle and live strength meter. */
92 +export function PasswordField({ name = 'password', label = 'Password', autoComplete = 'new-password', error, email, showMeter = true, placeholder }: { name?: string; label?: string; autoComplete?: string; error?: string | null; email?: string; showMeter?: boolean; placeholder?: string }) {
93 + const [value, setValue] = useState('');
94 + const [show, setShow] = useState(false);
95 + const strength = useMemo(() => passwordStrength(value, email), [value, email]);
96 + const colors = ['bg-loss', 'bg-loss', 'bg-alert', 'bg-gain', 'bg-gain'];
97 + return (
98 + <Field label={label} name={name} error={error} hint={showMeter ? (value ? strength.hint : 'At least 10 characters. A passphrase works best.') : undefined}>
99 + <div className="relative">
100 + <TextInput name={name} type={show ? 'text' : 'password'} autoComplete={autoComplete} required minLength={showMeter ? 10 : 1} value={value} onChange={(e) => setValue(e.target.value)} invalid={Boolean(error)} className="pr-10" placeholder={placeholder} />
101 + <button type="button" aria-label={show ? 'Hide password' : 'Show password'} onClick={() => setShow((s) => !s)} className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-subtle hover:text-fg">
102 + {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
103 + </button>
104 + </div>
105 + {showMeter && value ? (
106 + <div className="flex items-center gap-2 pt-1" aria-live="polite">
107 + <div className="flex flex-1 gap-1">
108 + {[0, 1, 2, 3].map((i) => (
109 + <span key={i} className={cn('h-1 flex-1 rounded-full bg-inset', i < strength.score && colors[strength.score])} />
110 + ))}
111 + </div>
112 + <span className="text-[11px] text-muted">{strength.label}</span>
113 + </div>
114 + ) : null}
115 + </Field>
116 + );
117 +}
118 +
119 +/** Six-digit code input: numeric keyboard, one-time-code autofill, auto-submit when complete. */
120 +export function CodeInput({ name = 'code', autoSubmit = true, error, label = 'Code' }: { name?: string; autoSubmit?: boolean; error?: string | null; label?: string }) {
121 + const [v, setV] = useState('');
122 + useEffect(() => {
123 + if (autoSubmit && v.length === 6) {
124 + const el = document.getElementById(name) as HTMLInputElement | null;
125 + el?.form?.requestSubmit();
126 + }
127 + }, [v, autoSubmit, name]);
128 + return (
129 + <Field label={label} name={name} error={error}>
130 + <input id={name} name={name} inputMode="numeric" pattern="[0-9]*" autoComplete="one-time-code" maxLength={6} required value={v} onChange={(e) => setV(e.target.value.replace(/\D/g, '').slice(0, 6))} className={cn(inputClass, 'mono-num text-center text-2xl tracking-[0.5em] md:text-2xl', error && 'border-loss')} aria-invalid={Boolean(error) || undefined} autoFocus />
131 + </Field>
132 + );
133 +}
134 +
135 +/** Countdown-enabled resend button (client-side cooldown mirrors the server's). */
136 +export function Cooldown({ seconds, children }: { seconds: number; children: (ready: boolean, left: number) => ReactNode }) {
137 + return <CooldownInner key={seconds} seconds={seconds}>{children}</CooldownInner>;
138 +}
139 +
140 +function CooldownInner({ seconds, children }: { seconds: number; children: (ready: boolean, left: number) => ReactNode }) {
141 + const [left, setLeft] = useState(seconds);
142 + useEffect(() => {
143 + if (seconds <= 0) return;
144 + const t = setInterval(() => setLeft((l) => (l <= 1 ? 0 : l - 1)), 1000);
145 + return () => clearInterval(t);
146 + }, [seconds]);
147 + return <>{children(left <= 0, left)}</>;
148 +}
added apps/web/src/components/account/page-header.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import type { ReactNode } from 'react';
2 +
3 +export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
4 + return (
5 + <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
6 + <div>
7 + <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
8 + {description ? <p className="mt-1 max-w-2xl text-sm text-muted">{description}</p> : null}
9 + </div>
10 + {actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
11 + </div>
12 + );
13 +}
14 +
15 +export const btnPrimary = 'inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-[13px] font-medium text-accent-fg hover:opacity-90';
16 +export const btnSecondary = 'inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-elevated px-3 text-[13px] font-medium text-fg hover:bg-inset';
17 +export const btnGhost = 'inline-flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted hover:bg-inset hover:text-fg';
18 +export const btnDanger = 'inline-flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-loss hover:bg-loss-bg';
added apps/web/src/components/account/portfolio-widgets.tsx +73 −0
@@ -0,0 +1,73 @@
1 +import Link from 'next/link';
2 +import type { PortfolioSummary, ValuedItem } from '@/lib/account/portfolio';
3 +import type { Display } from '@/lib/account/display';
4 +import { Badge, Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';
5 +import { Donut, Bars } from './charts';
6 +import { cn, confidenceLabel, fmtPct } from '@/lib/format';
7 +
8 +export function SummaryStats({ s, d, className }: { s: PortfolioSummary; d: Display; className?: string }) {
9 + return (
10 + <div className={cn('grid grid-cols-2 gap-4 sm:grid-cols-4', className)}>
11 + <Stat label="Collection value" value={s.valuedCount ? d.money(s.valueUsd) : '—'} sub={s.valuedCount ? <>{s.valuedCount}/{s.itemCount} items valued · confidence {confidenceLabel(s.confidence)}</> : `${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · no valuation yet`} />
12 + <Stat label="Cost basis" value={s.costBasisUsd > 0 ? d.money(s.costBasisUsd) : '—'} sub={s.costBasisUsd > 0 ? 'purchase prices at acquisition-date FX' : 'add purchase prices to track returns'} />
13 + <Stat label="Unrealized gain" value={s.gainUsd === null ? '—' : <span className={s.gainUsd >= 0 ? 'text-gain' : 'text-loss'}>{d.money(s.gainUsd)}</span>} sub={s.gainUsd === null ? 'needs value + cost' : 'items with both value and cost'} />
14 + <Stat label="Return" value={<Delta value={s.returnPct} className="text-lg" />} sub={s.manualValueUsd > 0 ? `${d.money(s.manualValueUsd)} from manual values` : 'vs cost basis'} />
15 + </div>
16 + );
17 +}
18 +
19 +export function AllocationCards({ s, d }: { s: PortfolioSummary; d: Display }) {
20 + return (
21 + <div className="grid gap-4 lg:grid-cols-3">
22 + <Card>
23 + <CardHeader title="Allocation by category" subtitle="Share of valued items" />
24 + <div className="p-4">
25 + <Donut data={s.allocationByCategory.map((b) => ({ label: b.label, value: b.valueUsd }))} centerValue={s.allocationByCategory.length ? String(s.allocationByCategory.length) : undefined} centerLabel="categories" />
26 + </div>
27 + </Card>
28 + <Card>
29 + <CardHeader title="Concentration & mix" subtitle="Where the risk sits" />
30 + <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs">
31 + <dt className="text-subtle">Largest position</dt>
32 + <dd className="num text-right">{fmtPct(s.concentration.topItemShare, 0, false)}</dd>
33 + <dt className="text-subtle">Top 5 positions</dt>
34 + <dd className="num text-right">{fmtPct(s.concentration.top5Share, 0, false)}</dd>
35 + <dt className="text-subtle">HHI (0–1)</dt>
36 + <dd className="num text-right">{s.concentration.hhi === null ? '—' : s.concentration.hhi.toFixed(2)}</dd>
37 + <dt className="text-subtle">Graded share</dt>
38 + <dd className="num text-right">{fmtPct(s.gradingMix.filter((b) => b.key !== 'raw').reduce((a, b) => a + b.share, 0), 0, false)}</dd>
39 + <dt className="text-subtle">High-liquidity share</dt>
40 + <dd className="num text-right">{fmtPct(s.liquidityMix.find((b) => b.key === 'high')?.share ?? 0, 0, false)}</dd>
41 + <dt className="text-subtle">High-rarity share</dt>
42 + <dd className="num text-right">{fmtPct(s.rarityMix.find((b) => b.key === 'high')?.share ?? 0, 0, false)}</dd>
43 + </dl>
44 + </Card>
45 + <Card>
46 + <CardHeader title="Best & worst" subtitle="By return on cost" />
47 + <div className="p-4">
48 + {s.best.length ? (
49 + <Bars data={[...s.best.slice(0, 3).map((i) => ({ label: i.title, value: (i.gainPct ?? 0) * 100, tone: 'gain' as const })), ...s.worst.slice(0, 3).map((i) => ({ label: i.title, value: (i.gainPct ?? 0) * 100, tone: 'loss' as const }))]} format={(v) => `${v > 0 ? '+' : ''}${v.toFixed(0)}%`} />
50 + ) : (
51 + <p className="text-xs text-subtle">Add purchase prices to see performers.</p>
52 + )}
53 + <p className="mt-3 text-[11px] text-subtle">Values in {d.currency}; RIV estimates carry a confidence level.</p>
54 + </div>
55 + </Card>
56 + </div>
57 + );
58 +}
59 +
60 +export function ValueSourceBadge({ item }: { item: ValuedItem }) {
61 + if (item.valueSource === 'variant_riv') return <Badge tone="index">RIV · {item.grader ? `${item.grader.toUpperCase()} ${item.grade ?? ''}`.trim() : 'variant'}</Badge>;
62 + if (item.valueSource === 'asset_riv') return <Badge tone="neutral">RIV · asset</Badge>;
63 + if (item.valueSource === 'manual') return <Badge tone="alert">manual</Badge>;
64 + return <Badge tone="neutral">no valuation</Badge>;
65 +}
66 +
67 +export function AssetLink({ slug, title, className }: { slug: string; title: string; className?: string }) {
68 + return (
69 + <Link href={`/asset/${slug}`} className={cn('hover:underline', className)}>
70 + {title}
71 + </Link>
72 + );
73 +}
added apps/web/src/components/account/user-menu.tsx +86 −0
@@ -0,0 +1,86 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useEffect, useState } from 'react';
5 +import { Bell } from 'lucide-react';
6 +
7 +interface SessionInfo {
8 + user: { id: string; email: string; name: string | null; handle: string | null; avatarUrl: string | null; displayCurrency: string; role: string } | null;
9 + unread?: number;
10 +}
11 +
12 +/**
13 + * Header user menu (drop-in for the site header): shows "Sign in" when anonymous, otherwise the
14 + * avatar, unread badge and a compact menu. Fetches /api/auth/session client-side so the header
15 + * stays static/cacheable.
16 + */
17 +export function UserMenu() {
18 + const [info, setInfo] = useState<SessionInfo | null>(null);
19 + useEffect(() => {
20 + let alive = true;
21 + fetch('/api/auth/session', { credentials: 'same-origin' })
22 + .then((r) => (r.ok ? r.json() : { user: null }))
23 + .then((j: SessionInfo) => {
24 + if (alive) setInfo(j);
25 + })
26 + .catch(() => {
27 + if (alive) setInfo({ user: null });
28 + });
29 + return () => {
30 + alive = false;
31 + };
32 + }, []);
33 + if (!info?.user) {
34 + return (
35 + <Link href="/login" className="hidden rounded-md border border-border px-2.5 py-1.5 text-[13px] font-medium text-fg hover:bg-inset sm:inline-flex">
36 + Sign in
37 + </Link>
38 + );
39 + }
40 + const u = info.user;
41 + const initial = (u.name ?? u.email).slice(0, 1).toUpperCase();
42 + return (
43 + <div className="flex items-center gap-1">
44 + <Link href="/notifications" aria-label="Inbox" className="relative inline-flex h-8 w-8 items-center justify-center rounded-md text-muted hover:bg-inset hover:text-fg">
45 + <Bell className="h-4 w-4" />
46 + {info.unread ? <span className="absolute -right-0.5 -top-0.5 min-w-[16px] rounded-full bg-alert px-1 text-center text-[10px] font-semibold leading-4 text-white">{info.unread > 99 ? '99+' : info.unread}</span> : null}
47 + </Link>
48 + <details className="relative">
49 + <summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-1.5 py-1 hover:bg-inset [&::-webkit-details-marker]:hidden">
50 + {u.avatarUrl ? (
51 + // eslint-disable-next-line @next/next/no-img-element
52 + <img src={u.avatarUrl} alt="" className="h-7 w-7 rounded-full border border-border object-cover" />
53 + ) : (
54 + <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-accent-fg">{initial}</span>
55 + )}
56 + <span className="hidden max-w-[120px] truncate text-[13px] font-medium md:inline">{u.name ?? u.email.split('@')[0]}</span>
57 + </summary>
58 + <div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-lg border border-border bg-elevated p-1.5 shadow-pop">
59 + <div className="px-2 py-1.5">
60 + <p className="truncate text-[13px] font-medium">{u.name ?? 'Collector'}</p>
61 + <p className="truncate text-[11px] text-subtle">{u.email}</p>
62 + </div>
63 + <div className="my-1 border-t border-border" />
64 + {[
65 + ['/collections', 'Collections'],
66 + ['/watchlist', 'Watchlist'],
67 + ['/alerts', 'Alerts'],
68 + ['/deals', 'Deal Radar'],
69 + ['/account/settings', 'Settings'],
70 + ...(u.handle ? [[`/u/${u.handle}`, 'Public profile']] : []),
71 + ].map(([href, label]) => (
72 + <Link key={href} href={href!} className="block rounded-sm px-2 py-1.5 text-[13px] text-muted hover:bg-inset hover:text-fg">
73 + {label}
74 + </Link>
75 + ))}
76 + <div className="my-1 border-t border-border" />
77 + <form action="/api/auth/logout" method="post">
78 + <button type="submit" className="block w-full rounded-sm px-2 py-1.5 text-left text-[13px] text-muted hover:bg-inset hover:text-fg">
79 + Sign out
80 + </button>
81 + </form>
82 + </div>
83 + </details>
84 + </div>
85 + );
86 +}
added apps/web/src/lib/account/actions.ts +487 −0
@@ -0,0 +1,487 @@
1 +'use server';
2 +
3 +import { redirect } from 'next/navigation';
4 +import { revalidatePath } from 'next/cache';
5 +import { z } from 'zod';
6 +import { and, eq, inArray, isNull, sql } from '@/lib/db';
7 +import { db, collections, collectionItems, assets, assetStats, assetVariants, watchlistItems, alerts, notifications, savedSearches, priceTargets } from '@/lib/db';
8 +import { newId, slugify } from '@rareindex/shared';
9 +import { requireUser } from '@/lib/auth/session';
10 +import { fieldErrorsFrom, type ActionState } from '@/lib/auth/state';
11 +import { toUsdAt } from './fx';
12 +import { getCollection, getOrCreateWatchlist } from './queries';
13 +import { parseCsv } from './portfolio';
14 +import { storeUpload, deleteUpload } from './uploads';
15 +
16 +const fail = (error: string, extra: Partial<ActionState> = {}): ActionState => ({ ok: false, error, ...extra });
17 +const CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD'] as const;
18 +
19 +// ---------------------------------------------------------------- collections
20 +
21 +const collectionSchema = z.object({
22 + name: z.string().trim().min(1, 'Name is required').max(80),
23 + description: z.string().trim().max(500).optional().or(z.literal('')),
24 + kind: z.enum(['collection', 'wishlist', 'vault', 'sold']).default('collection'),
25 + budgetUsd: z.coerce.number().nonnegative().optional().nullable(),
26 + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().or(z.literal('')),
27 +});
28 +
29 +export async function createCollectionAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
30 + const u = await requireUser('/collections');
31 + const parsed = collectionSchema.safeParse({ name: formData.get('name'), description: formData.get('description') ?? '', kind: formData.get('kind') ?? 'collection', budgetUsd: formData.get('budgetUsd') ? formData.get('budgetUsd') : null, color: formData.get('color') ?? '' });
32 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
33 + const id = newId('collection');
34 + await db().insert(collections).values({ id, userId: u.id, name: parsed.data.name, description: parsed.data.description || null, kind: parsed.data.kind, budgetUsd: parsed.data.budgetUsd ?? null, color: parsed.data.color || null });
35 + revalidatePath('/collections');
36 + redirect(`/collections/${id}`);
37 +}
38 +
39 +export async function updateCollectionAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
40 + const u = await requireUser('/collections');
41 + const id = String(formData.get('collectionId') ?? '');
42 + const col = await getCollection(u.id, id);
43 + if (!col) return fail('Collection not found.');
44 + const parsed = collectionSchema.safeParse({ name: formData.get('name'), description: formData.get('description') ?? '', kind: formData.get('kind') ?? col.kind, budgetUsd: formData.get('budgetUsd') ? formData.get('budgetUsd') : null, color: formData.get('color') ?? '' });
45 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
46 + await db().update(collections).set({ name: parsed.data.name, description: parsed.data.description || null, kind: parsed.data.kind, budgetUsd: parsed.data.budgetUsd ?? null, color: parsed.data.color || null, updatedAt: new Date() }).where(eq(collections.id, id));
47 + revalidatePath(`/collections/${id}`);
48 + return { ok: true, message: 'Collection updated.' };
49 +}
50 +
51 +export async function toggleCollectionPublicAction(formData: FormData): Promise<void> {
52 + const u = await requireUser('/collections');
53 + const id = String(formData.get('collectionId') ?? '');
54 + const col = await getCollection(u.id, id);
55 + if (!col) return;
56 + const makePublic = formData.get('public') === 'on';
57 + let publicSlug = col.publicSlug;
58 + if (makePublic && !publicSlug) publicSlug = `${slugify(col.name) || 'collection'}-${id.slice(-6)}`;
59 + await db().update(collections).set({ isPublic: makePublic, publicSlug, updatedAt: new Date() }).where(eq(collections.id, id));
60 + revalidatePath(`/collections/${id}`);
61 +}
62 +
63 +export async function deleteCollectionAction(formData: FormData): Promise<void> {
64 + const u = await requireUser('/collections');
65 + const id = String(formData.get('collectionId') ?? '');
66 + const col = await getCollection(u.id, id);
67 + if (!col) return;
68 + await db().delete(collectionItems).where(eq(collectionItems.collectionId, id));
69 + await db().delete(collections).where(eq(collections.id, id));
70 + revalidatePath('/collections');
71 + redirect('/collections');
72 +}
73 +
74 +// ---------------------------------------------------------------- items
75 +
76 +const itemSchema = z.object({
77 + assetId: z.string().min(1, 'Pick an asset'),
78 + variantId: z.string().optional().or(z.literal('')),
79 + quantity: z.coerce.number().int().min(1).max(10_000).default(1),
80 + acquiredAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')),
81 + purchasePrice: z.coerce.number().nonnegative().optional().nullable(),
82 + purchaseCurrency: z.enum(CURRENCIES).default('USD'),
83 + source: z.string().trim().max(120).optional().or(z.literal('')),
84 + grader: z.string().trim().max(20).optional().or(z.literal('')),
85 + grade: z.string().trim().max(12).optional().or(z.literal('')),
86 + certificationNumber: z.string().trim().max(40).optional().or(z.literal('')),
87 + serial: z.string().trim().max(60).optional().or(z.literal('')),
88 + condition: z.string().trim().max(40).optional().or(z.literal('')),
89 + notes: z.string().trim().max(2000).optional().or(z.literal('')),
90 + tags: z.string().trim().max(200).optional().or(z.literal('')),
91 + manualValueUsd: z.coerce.number().nonnegative().optional().nullable(),
92 +});
93 +
94 +function tagsFrom(s: string | undefined): string[] {
95 + return [...new Set((s ?? '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean))].slice(0, 12);
96 +}
97 +
98 +async function parseItem(formData: FormData) {
99 + return itemSchema.safeParse({
100 + assetId: formData.get('assetId'),
101 + variantId: formData.get('variantId') ?? '',
102 + quantity: formData.get('quantity') || 1,
103 + acquiredAt: formData.get('acquiredAt') ?? '',
104 + purchasePrice: formData.get('purchasePrice') ? formData.get('purchasePrice') : null,
105 + purchaseCurrency: formData.get('purchaseCurrency') || 'USD',
106 + source: formData.get('source') ?? '',
107 + grader: formData.get('grader') ?? '',
108 + grade: formData.get('grade') ?? '',
109 + certificationNumber: formData.get('certificationNumber') ?? '',
110 + serial: formData.get('serial') ?? '',
111 + condition: formData.get('condition') ?? '',
112 + notes: formData.get('notes') ?? '',
113 + tags: formData.get('tags') ?? '',
114 + manualValueUsd: formData.get('manualValueUsd') ? formData.get('manualValueUsd') : null,
115 + });
116 +}
117 +
118 +async function resolveVariant(assetId: string, variantId: string | undefined, grader: string | undefined, grade: string | undefined): Promise<{ variantId: string | null; grader: string | null; grade: string | null }> {
119 + if (variantId) {
120 + const v = await db().select().from(assetVariants).where(and(eq(assetVariants.id, variantId), eq(assetVariants.assetId, assetId))).limit(1);
121 + if (v[0]) return { variantId: v[0].id, grader: v[0].grader ?? (grader || null), grade: v[0].grade ?? (grade || null) };
122 + }
123 + if (grader && grade) {
124 + const v = await db().select().from(assetVariants).where(and(eq(assetVariants.assetId, assetId), eq(assetVariants.grader, grader.toLowerCase()), eq(assetVariants.grade, grade))).limit(1);
125 + if (v[0]) return { variantId: v[0].id, grader: v[0].grader, grade: v[0].grade };
126 + }
127 + return { variantId: null, grader: grader ? grader.toLowerCase() : null, grade: grade || null };
128 +}
129 +
130 +export async function addItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
131 + const u = await requireUser('/collections');
132 + const collectionId = String(formData.get('collectionId') ?? '');
133 + const col = await getCollection(u.id, collectionId);
134 + if (!col) return fail('Collection not found.');
135 + const parsed = await parseItem(formData);
136 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
137 + const d = parsed.data;
138 + const asset = await db().select({ id: assets.id }).from(assets).where(eq(assets.id, d.assetId)).limit(1);
139 + if (!asset[0]) return fail('That asset does not exist.', { fieldErrors: { assetId: 'Unknown asset' } });
140 + const v = await resolveVariant(d.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined);
141 + const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null;
142 + const notes: string[] = [];
143 + if (d.purchasePrice && d.purchaseCurrency !== 'USD' && !fx) notes.push(`No ${d.purchaseCurrency} FX rate is loaded yet; the USD cost basis will be filled when rates arrive.`);
144 + const id = newId('collectionItem');
145 + await db().insert(collectionItems).values({
146 + id,
147 + collectionId,
148 + assetId: d.assetId,
149 + variantId: v.variantId,
150 + quantity: d.quantity,
151 + acquiredAt: d.acquiredAt || null,
152 + purchasePrice: d.purchasePrice ?? null,
153 + purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null,
154 + purchasePriceUsd: fx?.usd ?? null,
155 + source: d.source || null,
156 + grader: v.grader,
157 + grade: v.grade,
158 + certificationNumber: d.certificationNumber || null,
159 + serial: d.serial || null,
160 + condition: d.condition || null,
161 + notes: d.notes || null,
162 + tags: tagsFrom(d.tags),
163 + manualValueUsd: d.manualValueUsd ?? null,
164 + });
165 + await db().update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
166 + revalidatePath(`/collections/${collectionId}`);
167 + revalidatePath('/collections');
168 + return { ok: true, message: notes.length ? `Item added. ${notes.join(' ')}` : 'Item added to the collection.', data: { itemId: id } };
169 +}
170 +
171 +export async function updateItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
172 + const u = await requireUser('/collections');
173 + const itemId = String(formData.get('itemId') ?? '');
174 + const row = await ownedItem(u.id, itemId);
175 + if (!row) return fail('Item not found.');
176 + const parsed = await parseItem(formData);
177 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
178 + const d = parsed.data;
179 + const v = await resolveVariant(row.item.assetId, d.variantId || undefined, d.grader || undefined, d.grade || undefined);
180 + const fx = d.purchasePrice !== null && d.purchasePrice !== undefined ? await toUsdAt(d.purchasePrice, d.purchaseCurrency, d.acquiredAt || null) : null;
181 + const soldAt = String(formData.get('soldAt') ?? '');
182 + const soldPrice = formData.get('soldPriceUsd') ? Number(formData.get('soldPriceUsd')) : null;
183 + await db()
184 + .update(collectionItems)
185 + .set({
186 + variantId: v.variantId,
187 + quantity: d.quantity,
188 + acquiredAt: d.acquiredAt || null,
189 + purchasePrice: d.purchasePrice ?? null,
190 + purchaseCurrency: d.purchasePrice !== null && d.purchasePrice !== undefined ? d.purchaseCurrency : null,
191 + purchasePriceUsd: fx?.usd ?? null,
192 + source: d.source || null,
193 + grader: v.grader,
194 + grade: v.grade,
195 + certificationNumber: d.certificationNumber || null,
196 + serial: d.serial || null,
197 + condition: d.condition || null,
198 + notes: d.notes || null,
199 + tags: tagsFrom(d.tags),
200 + manualValueUsd: d.manualValueUsd ?? null,
201 + soldAt: /^\d{4}-\d{2}-\d{2}$/.test(soldAt) ? soldAt : null,
202 + soldPriceUsd: soldPrice !== null && Number.isFinite(soldPrice) && soldPrice >= 0 ? soldPrice : null,
203 + updatedAt: new Date(),
204 + })
205 + .where(eq(collectionItems.id, itemId));
206 + revalidatePath(`/collections/${row.item.collectionId}`);
207 + return { ok: true, message: 'Item saved.' };
208 +}
209 +
210 +async function ownedItem(userId: string, itemId: string) {
211 + const rows = await db().select({ item: collectionItems, col: collections }).from(collectionItems).innerJoin(collections, eq(collections.id, collectionItems.collectionId)).where(and(eq(collectionItems.id, itemId), eq(collections.userId, userId))).limit(1);
212 + return rows[0] ?? null;
213 +}
214 +
215 +export async function deleteItemAction(formData: FormData): Promise<void> {
216 + const u = await requireUser('/collections');
217 + const itemId = String(formData.get('itemId') ?? '');
218 + const row = await ownedItem(u.id, itemId);
219 + if (!row) return;
220 + for (const p of row.item.photos ?? []) {
221 + const m = p.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/);
222 + if (m) await deleteUpload(u.id, m[1]!);
223 + }
224 + await db().delete(collectionItems).where(eq(collectionItems.id, itemId));
225 + revalidatePath(`/collections/${row.item.collectionId}`);
226 + redirect(`/collections/${row.item.collectionId}`);
227 +}
228 +
229 +export async function moveItemAction(formData: FormData): Promise<void> {
230 + const u = await requireUser('/collections');
231 + const itemId = String(formData.get('itemId') ?? '');
232 + const to = String(formData.get('toCollectionId') ?? '');
233 + const row = await ownedItem(u.id, itemId);
234 + const dest = await getCollection(u.id, to);
235 + if (!row || !dest) return;
236 + await db().update(collectionItems).set({ collectionId: to, updatedAt: new Date() }).where(eq(collectionItems.id, itemId));
237 + revalidatePath(`/collections/${row.item.collectionId}`);
238 + revalidatePath(`/collections/${to}`);
239 +}
240 +
241 +export async function addItemPhotoAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
242 + const u = await requireUser('/collections');
243 + const itemId = String(formData.get('itemId') ?? '');
244 + const row = await ownedItem(u.id, itemId);
245 + if (!row) return fail('Item not found.');
246 + const files = formData.getAll('files').filter((f): f is File => f instanceof File && f.size > 0);
247 + if (!files.length) return fail('Choose at least one image.');
248 + if ((row.item.photos?.length ?? 0) + files.length > 12) return fail('At most 12 photos per item.');
249 + const urls: string[] = [];
250 + for (const f of files.slice(0, 12)) {
251 + const res = await storeUpload(u.id, 'item_photo', f);
252 + if ('error' in res) return fail(res.error);
253 + urls.push(res.url);
254 + }
255 + await db().update(collectionItems).set({ photos: [...(row.item.photos ?? []), ...urls], updatedAt: new Date() }).where(eq(collectionItems.id, itemId));
256 + revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`);
257 + return { ok: true, message: `${urls.length} photo${urls.length === 1 ? '' : 's'} added.` };
258 +}
259 +
260 +export async function removeItemPhotoAction(formData: FormData): Promise<void> {
261 + const u = await requireUser('/collections');
262 + const itemId = String(formData.get('itemId') ?? '');
263 + const url = String(formData.get('url') ?? '');
264 + const row = await ownedItem(u.id, itemId);
265 + if (!row) return;
266 + const m = url.match(/\/api\/account\/uploads\/(img_[0-9a-z]+)/);
267 + if (m) await deleteUpload(u.id, m[1]!);
268 + await db().update(collectionItems).set({ photos: (row.item.photos ?? []).filter((p) => p !== url), updatedAt: new Date() }).where(eq(collectionItems.id, itemId));
269 + revalidatePath(`/collections/${row.item.collectionId}/items/${itemId}`);
270 +}
271 +
272 +// ---------------------------------------------------------------- CSV import
273 +
274 +export async function importCsvAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
275 + const u = await requireUser('/collections');
276 + const collectionId = String(formData.get('collectionId') ?? '');
277 + const col = await getCollection(u.id, collectionId);
278 + if (!col) return fail('Collection not found.');
279 + const file = formData.get('file');
280 + if (!(file instanceof File) || file.size === 0) return fail('Choose a CSV file.');
281 + if (file.size > 2 * 1024 * 1024) return fail('CSV too large (max 2 MB).');
282 + const rows = parseCsv(await file.text());
283 + if (!rows.length) return fail('No rows found. Use the template: asset_id or asset_slug, quantity, acquired_at, purchase_price, currency, grader, grade, cert, source, notes, tags.');
284 + let added = 0;
285 + const errors: string[] = [];
286 + for (const [i, r] of rows.slice(0, 500).entries()) {
287 + const ref = r.asset_id || r.asset_slug || r.asset || '';
288 + if (!ref) {
289 + errors.push(`row ${i + 2}: missing asset_id/asset_slug`);
290 + continue;
291 + }
292 + const a = await db().select({ id: assets.id }).from(assets).where(ref.startsWith('rare_') ? eq(assets.id, ref) : eq(assets.slug, ref)).limit(1);
293 + if (!a[0]) {
294 + errors.push(`row ${i + 2}: asset "${ref}" not found`);
295 + continue;
296 + }
297 + const price = r.purchase_price ? Number(String(r.purchase_price).replace(/[^0-9.]/g, '')) : null;
298 + const currency = (r.currency || 'USD').toUpperCase();
299 + const acquiredAt = /^\d{4}-\d{2}-\d{2}$/.test(r.acquired_at ?? '') ? r.acquired_at! : null;
300 + const fx = price !== null && Number.isFinite(price) ? await toUsdAt(price, (CURRENCIES as readonly string[]).includes(currency) ? currency : 'USD', acquiredAt) : null;
301 + const v = await resolveVariant(a[0].id, undefined, r.grader || undefined, r.grade || undefined);
302 + await db().insert(collectionItems).values({
303 + id: newId('collectionItem'),
304 + collectionId,
305 + assetId: a[0].id,
306 + variantId: v.variantId,
307 + quantity: Math.max(1, Number(r.quantity) || 1),
308 + acquiredAt,
309 + purchasePrice: price !== null && Number.isFinite(price) ? price : null,
310 + purchaseCurrency: price !== null ? currency : null,
311 + purchasePriceUsd: fx?.usd ?? null,
312 + source: r.source || null,
313 + grader: v.grader,
314 + grade: v.grade,
315 + certificationNumber: r.cert || r.certification_number || null,
316 + serial: r.serial || null,
317 + notes: r.notes || null,
318 + tags: tagsFrom(r.tags),
319 + });
320 + added++;
321 + }
322 + revalidatePath(`/collections/${collectionId}`);
323 + return { ok: true, message: `${added} item${added === 1 ? '' : 's'} imported${errors.length ? `; ${errors.length} row${errors.length === 1 ? '' : 's'} skipped` : ''}.`, data: { errors: errors.slice(0, 20) } };
324 +}
325 +
326 +// ---------------------------------------------------------------- watchlist
327 +
328 +export async function toggleWatchAction(formData: FormData): Promise<void> {
329 + const u = await requireUser('/watchlist');
330 + const targetType = String(formData.get('targetType') ?? 'asset');
331 + const targetId = String(formData.get('targetId') ?? '');
332 + const back = String(formData.get('back') ?? '/watchlist');
333 + if (!targetId || !['asset', 'category', 'brand', 'set', 'source', 'auction'].includes(targetType)) return;
334 + const wl = await getOrCreateWatchlist(u.id);
335 + const existing = await db().select({ id: watchlistItems.id }).from(watchlistItems).where(and(eq(watchlistItems.watchlistId, wl.id), eq(watchlistItems.targetType, targetType), eq(watchlistItems.targetId, targetId))).limit(1);
336 + if (existing[0]) {
337 + await db().delete(watchlistItems).where(eq(watchlistItems.id, existing[0].id));
338 + if (targetType === 'asset') await db().update(assetStats).set({ watchers: sql`greatest(${assetStats.watchers} - 1, 0)` }).where(eq(assetStats.assetId, targetId));
339 + } else {
340 + let baseline: number | null = null;
341 + let label: string | null = null;
342 + if (targetType === 'asset') {
343 + const s = await db().select({ riv: assetStats.rivUsd, title: assets.title }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, targetId)).limit(1);
344 + baseline = s[0]?.riv ?? null;
345 + label = s[0]?.title ?? null;
346 + await db().execute(sql`insert into asset_stats (asset_id, watchers) values (${targetId}, 1) on conflict (asset_id) do update set watchers = asset_stats.watchers + 1`);
347 + }
348 + await db().insert(watchlistItems).values({ id: newId('event'), watchlistId: wl.id, targetType, targetId, baselineUsd: baseline, label });
349 + }
350 + revalidatePath('/watchlist');
351 + if (back.startsWith('/') && !back.startsWith('//')) revalidatePath(back);
352 +}
353 +
354 +export async function updateWatchItemAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
355 + const u = await requireUser('/watchlist');
356 + const id = String(formData.get('itemId') ?? '');
357 + const wl = await getOrCreateWatchlist(u.id);
358 + const note = String(formData.get('note') ?? '').trim().slice(0, 500);
359 + const tp = formData.get('targetPriceUsd') ? Number(formData.get('targetPriceUsd')) : null;
360 + await db().update(watchlistItems).set({ note: note || null, targetPriceUsd: tp !== null && Number.isFinite(tp) && tp >= 0 ? tp : null }).where(and(eq(watchlistItems.id, id), eq(watchlistItems.watchlistId, wl.id)));
361 + revalidatePath('/watchlist');
362 + return { ok: true, message: 'Saved.' };
363 +}
364 +
365 +// ---------------------------------------------------------------- alerts
366 +
367 +const ALERT_TYPES = ['price_below', 'price_above', 'new_listing', 'new_auction', 'auction_ending', 'record_sale', 'unusual_volume', 'market_move', 'rare_item', 'population_update'] as const;
368 +const alertSchema = z.object({
369 + alertType: z.enum(ALERT_TYPES),
370 + targetType: z.enum(['asset', 'category', 'index']),
371 + targetId: z.string().min(1, 'Choose a target'),
372 + threshold: z.coerce.number().nonnegative().optional().nullable(),
373 + channel: z.enum(['inapp', 'email', 'both']).default('both'),
374 + name: z.string().trim().max(80).optional().or(z.literal('')),
375 + cooldownMinutes: z.coerce.number().int().min(15).max(43200).default(1440),
376 +});
377 +
378 +export async function createAlertAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
379 + const u = await requireUser('/alerts');
380 + const parsed = alertSchema.safeParse({ alertType: formData.get('alertType'), targetType: formData.get('targetType') ?? 'asset', targetId: formData.get('targetId'), threshold: formData.get('threshold') ? formData.get('threshold') : null, channel: formData.get('channel') ?? 'both', name: formData.get('name') ?? '', cooldownMinutes: formData.get('cooldownMinutes') || 1440 });
381 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
382 + const d = parsed.data;
383 + if ((d.alertType === 'price_below' || d.alertType === 'price_above' || d.alertType === 'market_move' || d.alertType === 'unusual_volume') && (d.threshold === null || d.threshold === undefined)) return fail('This alert needs a threshold.', { fieldErrors: { threshold: 'Required' } });
384 + const existing = await db().select({ n: sql<number>`count(*)` }).from(alerts).where(and(eq(alerts.userId, u.id), eq(alerts.active, true)));
385 + if (Number(existing[0]?.n ?? 0) >= 200) return fail('You have reached the limit of 200 active alerts.');
386 + let name = d.name || null;
387 + if (!name && d.targetType === 'asset') {
388 + const a = await db().select({ title: assets.title }).from(assets).where(eq(assets.id, d.targetId)).limit(1);
389 + if (!a[0]) return fail('Asset not found.', { fieldErrors: { targetId: 'Unknown asset' } });
390 + name = a[0].title;
391 + }
392 + await db().insert(alerts).values({ id: newId('alert'), userId: u.id, alertType: d.alertType, targetType: d.targetType, targetId: d.targetId, threshold: d.threshold ?? null, currency: 'USD', channel: d.channel, name, cooldownMinutes: d.cooldownMinutes, params: {} });
393 + revalidatePath('/alerts');
394 + return { ok: true, message: 'Alert created.' };
395 +}
396 +
397 +export async function toggleAlertAction(formData: FormData): Promise<void> {
398 + const u = await requireUser('/alerts');
399 + const id = String(formData.get('alertId') ?? '');
400 + await db().update(alerts).set({ active: sql`not ${alerts.active}` }).where(and(eq(alerts.id, id), eq(alerts.userId, u.id)));
401 + revalidatePath('/alerts');
402 +}
403 +
404 +export async function deleteAlertAction(formData: FormData): Promise<void> {
405 + const u = await requireUser('/alerts');
406 + const id = String(formData.get('alertId') ?? '');
407 + await db().delete(alerts).where(and(eq(alerts.id, id), eq(alerts.userId, u.id)));
408 + revalidatePath('/alerts');
409 +}
410 +
411 +// ---------------------------------------------------------------- notifications
412 +
413 +export async function markReadAction(formData: FormData): Promise<void> {
414 + const u = await requireUser('/notifications');
415 + const id = String(formData.get('id') ?? '');
416 + if (id === 'all') await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.userId, u.id), isNull(notifications.readAt)));
417 + else await db().update(notifications).set({ readAt: new Date() }).where(and(eq(notifications.id, id), eq(notifications.userId, u.id)));
418 + revalidatePath('/notifications');
419 +}
420 +
421 +export async function clearNotificationsAction(): Promise<void> {
422 + const u = await requireUser('/notifications');
423 + await db().delete(notifications).where(and(eq(notifications.userId, u.id), sql`${notifications.readAt} is not null`));
424 + revalidatePath('/notifications');
425 +}
426 +
427 +// ---------------------------------------------------------------- saved searches
428 +
429 +export async function saveSearchAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
430 + const u = await requireUser('/saved');
431 + const name = z.string().trim().min(1).max(80).safeParse(formData.get('name'));
432 + const url = String(formData.get('url') ?? '').trim();
433 + if (!name.success) return fail('Give the search a name.', { fieldErrors: { name: 'Required' } });
434 + if (!url.startsWith('/') || url.startsWith('//') || url.length > 2000) return fail('Only RareIndex search URLs can be saved (start with /search or /explore).', { fieldErrors: { url: 'Invalid' } });
435 + const params = Object.fromEntries(new URL(url, 'https://x').searchParams.entries());
436 + const n = await db().select({ n: sql<number>`count(*)` }).from(savedSearches).where(eq(savedSearches.userId, u.id));
437 + if (Number(n[0]?.n ?? 0) >= 100) return fail('Limit of 100 saved searches reached.');
438 + await db().insert(savedSearches).values({ id: newId('event'), userId: u.id, name: name.data, url, params, notify: formData.get('notify') === 'on' });
439 + revalidatePath('/saved');
440 + return { ok: true, message: 'Search saved.' };
441 +}
442 +
443 +export async function deleteSavedSearchAction(formData: FormData): Promise<void> {
444 + const u = await requireUser('/saved');
445 + await db().delete(savedSearches).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id)));
446 + revalidatePath('/saved');
447 +}
448 +
449 +export async function toggleSavedSearchNotifyAction(formData: FormData): Promise<void> {
450 + const u = await requireUser('/saved');
451 + await db().update(savedSearches).set({ notify: sql`not ${savedSearches.notify}` }).where(and(eq(savedSearches.id, String(formData.get('id') ?? '')), eq(savedSearches.userId, u.id)));
452 + revalidatePath('/saved');
453 +}
454 +
455 +// ---------------------------------------------------------------- price targets
456 +
457 +export async function createTargetAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
458 + const u = await requireUser('/targets');
459 + const assetId = String(formData.get('assetId') ?? '');
460 + const target = Number(formData.get('targetUsd'));
461 + const direction = formData.get('direction') === 'below' ? 'below' : 'above';
462 + const note = String(formData.get('note') ?? '').trim().slice(0, 300);
463 + if (!assetId) return fail('Pick an asset.', { fieldErrors: { assetId: 'Required' } });
464 + if (!Number.isFinite(target) || target <= 0) return fail('Enter a target value in USD.', { fieldErrors: { targetUsd: 'Invalid' } });
465 + const s = await db().select({ riv: assetStats.rivUsd, id: assets.id }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, assetId)).limit(1);
466 + if (!s[0]) return fail('Asset not found.');
467 + await db().insert(priceTargets).values({ id: newId('event'), userId: u.id, assetId, direction, targetUsd: target, baselineUsd: s[0].riv ?? null, note: note || null });
468 + revalidatePath('/targets');
469 + return { ok: true, message: 'Target set.' };
470 +}
471 +
472 +export async function deleteTargetAction(formData: FormData): Promise<void> {
473 + const u = await requireUser('/targets');
474 + await db().delete(priceTargets).where(and(eq(priceTargets.id, String(formData.get('id') ?? '')), eq(priceTargets.userId, u.id)));
475 + revalidatePath('/targets');
476 +}
477 +
478 +/** Bulk: add every asset of a category to the watchlist is intentionally NOT offered (noise). */
479 +export async function bulkDeleteItemsAction(formData: FormData): Promise<void> {
480 + const u = await requireUser('/collections');
481 + const ids = formData.getAll('itemIds').map(String).filter(Boolean);
482 + const collectionId = String(formData.get('collectionId') ?? '');
483 + const col = await getCollection(u.id, collectionId);
484 + if (!col || !ids.length) return;
485 + await db().delete(collectionItems).where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.id, ids)));
486 + revalidatePath(`/collections/${collectionId}`);
487 +}
added apps/web/src/lib/account/badges.ts +14 −0
@@ -0,0 +1,14 @@
1 +/**
2 + * Badges are computed from the member's real data (never hand-assigned). The worker recomputes
3 + * them daily (workers/account/badges.ts) and stores the evidence used.
4 + */
5 +export const BADGE_DEFS: Record<string, { label: string; tone: 'neutral' | 'gain' | 'index' | 'rarity' | 'gold' | 'alert'; description: string }> = {
6 + early_member: { label: 'Early member', tone: 'gold', description: 'Joined RareIndex in its first year.' },
7 + ten_categories: { label: '10+ categories', tone: 'index', description: 'Holds items across ten or more categories.' },
8 + hundred_items: { label: '100+ items', tone: 'index', description: 'Tracks one hundred or more items.' },
9 + graded_collector: { label: 'Graded collector', tone: 'rarity', description: 'At least 10 items recorded with a grading certification number.' },
10 + documented: { label: 'Documented', tone: 'neutral', description: 'Every item has an acquisition date and price.' },
11 + public_profile: { label: 'Open vault', tone: 'gain', description: 'Shares at least one public collection.' },
12 + watcher: { label: 'Market watcher', tone: 'neutral', description: 'Follows 25+ assets or markets.' },
13 + two_factor: { label: 'Secured', tone: 'gain', description: 'Authenticator two-step verification enabled.' },
14 +};
added apps/web/src/lib/account/display.ts +26 −0
@@ -0,0 +1,26 @@
1 +import 'server-only';
2 +import { fmtMoney } from '@/lib/format';
3 +import { getDisplayCurrency } from '@/lib/auth/session';
4 +import { latestRate } from './fx';
5 +
6 +export interface Display {
7 + currency: string;
8 + rate: number;
9 + /** true when the requested currency had no FX rate and USD is shown instead */
10 + fallback: boolean;
11 + money: (usd: number | null | undefined, opts?: { compact?: boolean; digits?: number }) => string;
12 +}
13 +
14 +/** Resolve the member's display currency and a formatter converting USD values at the latest rate. */
15 +export async function getDisplay(): Promise<Display> {
16 + const currency = await getDisplayCurrency();
17 + const rate = await latestRate(currency);
18 + const effective = rate ? currency : 'USD';
19 + const r = rate ?? 1;
20 + return {
21 + currency: effective,
22 + rate: r,
23 + fallback: !rate && currency !== 'USD',
24 + money: (usd, opts) => (usd === null || usd === undefined ? '—' : fmtMoney(usd * r, effective, opts)),
25 + };
26 +}
added apps/web/src/lib/account/fx.ts +34 −0
@@ -0,0 +1,34 @@
1 +import 'server-only';
2 +import { and, desc, eq, lte, sql } from '@/lib/db';
3 +import { db, fxRates } from '@/lib/db';
4 +
5 +/**
6 + * Convert an amount to USD at the rate of a given date (never today's rate for historical values).
7 + * fx_rates convention: base='USD', quote=<currency>, rate = units of quote per 1 USD.
8 + * Falls back to the latest rate on/before the date; returns null when no rate exists.
9 + */
10 +export async function toUsdAt(amount: number, currency: string, date: string | null): Promise<{ usd: number; rate: number; fxDate: string } | null> {
11 + if (currency === 'USD') return { usd: amount, rate: 1, fxDate: date ?? new Date().toISOString().slice(0, 10) };
12 + const d = date ?? new Date().toISOString().slice(0, 10);
13 + const rows = await db()
14 + .select({ rate: fxRates.rate, date: fxRates.date })
15 + .from(fxRates)
16 + .where(and(eq(fxRates.base, 'USD'), eq(fxRates.quote, currency), lte(fxRates.date, d)))
17 + .orderBy(desc(fxRates.date))
18 + .limit(1);
19 + const r = rows[0];
20 + if (!r || !r.rate) return null;
21 + return { usd: amount / r.rate, rate: r.rate, fxDate: String(r.date) };
22 +}
23 +
24 +/** Latest USD→currency rate for display conversion. 1 when unknown (caller shows USD then). */
25 +export async function latestRate(currency: string): Promise<number | null> {
26 + if (currency === 'USD') return 1;
27 + const rows = await db().select({ rate: fxRates.rate }).from(fxRates).where(and(eq(fxRates.base, 'USD'), eq(fxRates.quote, currency))).orderBy(desc(fxRates.date)).limit(1);
28 + return rows[0]?.rate ?? null;
29 +}
30 +
31 +export async function fxCoverage(): Promise<number> {
32 + const rows = (await db().execute(sql`select count(*)::int as n from fx_rates`)) as unknown as Array<{ n: number }>;
33 + return rows[0]?.n ?? 0;
34 +}
added apps/web/src/lib/account/portfolio.test.ts +61 −0
@@ -0,0 +1,61 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { summarizePortfolio, valueItem, parseCsv, toCsv, rebase, type PortfolioItemInput } from './portfolio';
3 +
4 +const base: PortfolioItemInput = {
5 + id: 'ci_1',
6 + assetId: 'rare_1',
7 + title: 'Charizard',
8 + categorySlug: 'pokemon',
9 + familySlug: 'trading_cards',
10 + quantity: 1,
11 + purchasePriceUsd: 1000,
12 + acquiredAt: '2024-01-01',
13 + grader: 'psa',
14 + grade: '10',
15 + variantRivUsd: 1500,
16 + variantConfidence: 0.8,
17 + assetRivUsd: 900,
18 + assetConfidence: 0.6,
19 + manualValueUsd: null,
20 + liquidityScore: 75,
21 + rarityScore: 50,
22 + change30d: 0.05,
23 +};
24 +
25 +describe('portfolio math', () => {
26 + it('prefers variant RIV, then asset RIV, then manual', () => {
27 + expect(valueItem(base).valueSource).toBe('variant_riv');
28 + expect(valueItem({ ...base, variantRivUsd: null }).valueSource).toBe('asset_riv');
29 + expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null, manualValueUsd: 50 })).toMatchObject({ valueSource: 'manual', valueUsd: 50, confidence: null });
30 + expect(valueItem({ ...base, variantRivUsd: null, assetRivUsd: null }).valueUsd).toBeNull();
31 + });
32 + it('computes gain, return and annualised return', () => {
33 + const v = valueItem(base, new Date('2025-01-01T00:00:00Z'));
34 + expect(v.gainUsd).toBe(500);
35 + expect(v.gainPct).toBeCloseTo(0.5);
36 + expect(v.holdingDays).toBe(366);
37 + expect(v.annualizedReturn).toBeCloseTo(0.499, 1);
38 + });
39 + it('summarises allocation and concentration', () => {
40 + const s = summarizePortfolio([base, { ...base, id: 'ci_2', assetId: 'rare_2', title: 'Rolex', categorySlug: 'rolex', familySlug: 'watches', quantity: 1, purchasePriceUsd: 10000, variantRivUsd: null, assetRivUsd: 8000, grader: null, grade: null, liquidityScore: 30 }, { ...base, id: 'ci_3', assetId: 'rare_3', purchasePriceUsd: null, variantRivUsd: null, assetRivUsd: null }]);
41 + expect(s.itemCount).toBe(3);
42 + expect(s.valuedCount).toBe(2);
43 + expect(s.unvaluedCount).toBe(1);
44 + expect(s.valueUsd).toBe(9500);
45 + expect(s.costBasisUsd).toBe(11000);
46 + expect(s.gainUsd).toBe(-1500);
47 + expect(s.returnPct).toBeCloseTo(-1500 / 11000);
48 + expect(s.allocationByFamily[0]).toMatchObject({ key: 'watches', share: 8000 / 9500 });
49 + expect(s.concentration.topItemShare).toBeCloseTo(8000 / 9500);
50 + expect(s.gradingMix.find((b) => b.key === 'psa')?.valueUsd).toBe(1500);
51 + expect(s.best[0]?.id).toBe('ci_1');
52 + expect(s.worst[0]?.id).toBe('ci_2');
53 + expect(s.confidence).toBeCloseTo((1500 * 0.8 + 8000 * 0.6) / 9500);
54 + });
55 + it('csv roundtrip and rebase', () => {
56 + const csv = toCsv([{ a: 'x,y', b: 1 }, { a: 'q"r', b: null }], ['a', 'b']);
57 + const back = parseCsv(csv);
58 + expect(back).toEqual([{ a: 'x,y', b: '1' }, { a: 'q"r', b: '' }]);
59 + expect(rebase([{ date: 'd1', value: 50 }, { date: 'd2', value: 75 }])[1]?.value).toBe(1500);
60 + });
61 +});
added apps/web/src/lib/account/portfolio.ts +230 −0
@@ -0,0 +1,230 @@
1 +/**
2 + * Pure portfolio mathematics (§129–§130). No I/O — unit-tested. Values are USD.
3 + * Valuation source order: variant RIV → asset RIV → member manual value (flagged as such).
4 + */
5 +
6 +export interface PortfolioItemInput {
7 + id: string;
8 + assetId: string;
9 + title: string;
10 + categorySlug: string;
11 + familySlug: string;
12 + quantity: number;
13 + purchasePriceUsd: number | null;
14 + acquiredAt: string | null; // YYYY-MM-DD
15 + grader: string | null;
16 + grade: string | null;
17 + variantRivUsd: number | null;
18 + variantConfidence: number | null;
19 + assetRivUsd: number | null;
20 + assetConfidence: number | null;
21 + manualValueUsd: number | null;
22 + liquidityScore: number | null;
23 + rarityScore: number | null;
24 + change30d: number | null;
25 + soldPriceUsd?: number | null;
26 +}
27 +
28 +export type ValueSource = 'variant_riv' | 'asset_riv' | 'manual' | 'none';
29 +
30 +export interface ValuedItem extends PortfolioItemInput {
31 + unitValueUsd: number | null;
32 + valueUsd: number | null;
33 + valueSource: ValueSource;
34 + confidence: number | null;
35 + costUsd: number | null;
36 + gainUsd: number | null;
37 + gainPct: number | null;
38 + holdingDays: number | null;
39 + annualizedReturn: number | null;
40 +}
41 +
42 +export function valueItem(it: PortfolioItemInput, now = new Date()): ValuedItem {
43 + let unit: number | null = null;
44 + let source: ValueSource = 'none';
45 + let confidence: number | null = null;
46 + if (it.variantRivUsd !== null && it.variantRivUsd > 0) {
47 + unit = it.variantRivUsd;
48 + source = 'variant_riv';
49 + confidence = it.variantConfidence;
50 + } else if (it.assetRivUsd !== null && it.assetRivUsd > 0) {
51 + unit = it.assetRivUsd;
52 + source = 'asset_riv';
53 + confidence = it.assetConfidence;
54 + } else if (it.manualValueUsd !== null && it.manualValueUsd > 0) {
55 + unit = it.manualValueUsd;
56 + source = 'manual';
57 + confidence = null;
58 + }
59 + const qty = Math.max(1, it.quantity || 1);
60 + const value = unit === null ? null : unit * qty;
61 + const cost = it.purchasePriceUsd === null ? null : it.purchasePriceUsd * qty;
62 + const gain = value !== null && cost !== null ? value - cost : null;
63 + const gainPct = gain !== null && cost && cost > 0 ? gain / cost : null;
64 + let holdingDays: number | null = null;
65 + let annualized: number | null = null;
66 + if (it.acquiredAt) {
67 + const d = new Date(`${it.acquiredAt}T00:00:00Z`);
68 + if (!Number.isNaN(d.getTime())) {
69 + holdingDays = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 86_400_000));
70 + if (gainPct !== null && holdingDays >= 30 && value !== null && cost && cost > 0) {
71 + annualized = Math.pow(value / cost, 365 / holdingDays) - 1;
72 + }
73 + }
74 + }
75 + return { ...it, unitValueUsd: unit, valueUsd: value, valueSource: source, confidence, costUsd: cost, gainUsd: gain, gainPct, holdingDays, annualizedReturn: annualized };
76 +}
77 +
78 +export interface Bucket {
79 + key: string;
80 + label: string;
81 + valueUsd: number;
82 + count: number;
83 + share: number;
84 +}
85 +
86 +export interface PortfolioSummary {
87 + items: ValuedItem[];
88 + itemCount: number;
89 + unitCount: number;
90 + valuedCount: number;
91 + unvaluedCount: number;
92 + valueUsd: number;
93 + costBasisUsd: number;
94 + costKnownValueUsd: number; // value of items that also have a cost (for the return %)
95 + gainUsd: number | null;
96 + returnPct: number | null;
97 + /** value-weighted average confidence of the valued items (0–1) */
98 + confidence: number | null;
99 + allocationByFamily: Bucket[];
100 + allocationByCategory: Bucket[];
101 + gradingMix: Bucket[];
102 + liquidityMix: Bucket[];
103 + rarityMix: Bucket[];
104 + concentration: { topItemShare: number | null; top5Share: number | null; hhi: number | null };
105 + best: ValuedItem[];
106 + worst: ValuedItem[];
107 + manualValueUsd: number;
108 +}
109 +
110 +function buckets(items: ValuedItem[], keyFn: (i: ValuedItem) => [string, string] | null, total: number): Bucket[] {
111 + const m = new Map<string, Bucket>();
112 + for (const i of items) {
113 + if (i.valueUsd === null) continue;
114 + const k = keyFn(i);
115 + if (!k) continue;
116 + const b = m.get(k[0]) ?? { key: k[0], label: k[1], valueUsd: 0, count: 0, share: 0 };
117 + b.valueUsd += i.valueUsd;
118 + b.count += 1;
119 + m.set(k[0], b);
120 + }
121 + return [...m.values()].map((b) => ({ ...b, share: total > 0 ? b.valueUsd / total : 0 })).sort((a, b) => b.valueUsd - a.valueUsd);
122 +}
123 +
124 +function scoreBand(s: number | null): [string, string] | null {
125 + if (s === null) return ['unknown', 'Unknown'];
126 + if (s >= 70) return ['high', 'High'];
127 + if (s >= 40) return ['medium', 'Medium'];
128 + return ['low', 'Low'];
129 +}
130 +
131 +export function summarizePortfolio(inputs: PortfolioItemInput[], now = new Date()): PortfolioSummary {
132 + const items = inputs.map((i) => valueItem(i, now));
133 + const valued = items.filter((i) => i.valueUsd !== null);
134 + const valueUsd = valued.reduce((a, i) => a + (i.valueUsd ?? 0), 0);
135 + const withCost = items.filter((i) => i.costUsd !== null);
136 + const costBasisUsd = withCost.reduce((a, i) => a + (i.costUsd ?? 0), 0);
137 + const both = items.filter((i) => i.costUsd !== null && i.valueUsd !== null);
138 + const costKnownValueUsd = both.reduce((a, i) => a + (i.valueUsd ?? 0), 0);
139 + const costOfBoth = both.reduce((a, i) => a + (i.costUsd ?? 0), 0);
140 + const gainUsd = both.length ? costKnownValueUsd - costOfBoth : null;
141 + const returnPct = both.length && costOfBoth > 0 ? (costKnownValueUsd - costOfBoth) / costOfBoth : null;
142 + const confPairs = valued.filter((i) => i.confidence !== null);
143 + const confDen = confPairs.reduce((a, i) => a + (i.valueUsd ?? 0), 0);
144 + const confidence = confDen > 0 ? confPairs.reduce((a, i) => a + (i.valueUsd ?? 0) * (i.confidence ?? 0), 0) / confDen : null;
145 + const sorted = [...valued].sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0));
146 + const shares = sorted.map((i) => (valueUsd > 0 ? (i.valueUsd ?? 0) / valueUsd : 0));
147 + const perf = items.filter((i) => i.gainPct !== null).sort((a, b) => (b.gainPct ?? 0) - (a.gainPct ?? 0));
148 + return {
149 + items,
150 + itemCount: items.length,
151 + unitCount: items.reduce((a, i) => a + Math.max(1, i.quantity || 1), 0),
152 + valuedCount: valued.length,
153 + unvaluedCount: items.length - valued.length,
154 + valueUsd,
155 + costBasisUsd,
156 + costKnownValueUsd,
157 + gainUsd,
158 + returnPct,
159 + confidence,
160 + allocationByFamily: buckets(items, (i) => [i.familySlug, humanize(i.familySlug)], valueUsd),
161 + allocationByCategory: buckets(items, (i) => [i.categorySlug, humanize(i.categorySlug)], valueUsd),
162 + gradingMix: buckets(items, (i) => (i.grader && i.grader !== 'raw' ? [i.grader, `${i.grader.toUpperCase()}${i.grade ? ` ${i.grade}` : ''}`] : ['raw', 'Raw / ungraded']), valueUsd),
163 + liquidityMix: buckets(items, (i) => scoreBand(i.liquidityScore), valueUsd),
164 + rarityMix: buckets(items, (i) => scoreBand(i.rarityScore), valueUsd),
165 + concentration: {
166 + topItemShare: shares[0] ?? null,
167 + top5Share: shares.length ? shares.slice(0, 5).reduce((a, b) => a + b, 0) : null,
168 + hhi: shares.length ? shares.reduce((a, s) => a + s * s, 0) : null,
169 + },
170 + best: perf.slice(0, 5),
171 + worst: perf.length > 1 ? perf.slice(-5).reverse().filter((i) => (i.gainPct ?? 0) < 0) : [],
172 + manualValueUsd: items.filter((i) => i.valueSource === 'manual').reduce((a, i) => a + (i.valueUsd ?? 0), 0),
173 + };
174 +}
175 +
176 +export function humanize(slug: string): string {
177 + return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bTcg\b/, 'TCG').replace(/\bDc\b/, 'DC').replace(/\bLego\b/, 'LEGO');
178 +}
179 +
180 +/** Rebase a value series to 1000 at the first point (personal index vs RARE). */
181 +export function rebase(series: Array<{ date: string; value: number }>, base = 1000): Array<{ date: string; value: number }> {
182 + const first = series.find((p) => p.value > 0)?.value;
183 + if (!first) return [];
184 + return series.map((p) => ({ date: p.date, value: (p.value / first) * base }));
185 +}
186 +
187 +/** CSV helpers (RFC 4180-ish). */
188 +export function toCsv(rows: Array<Record<string, unknown>>, columns: string[]): string {
189 + const esc = (v: unknown) => {
190 + if (v === null || v === undefined) return '';
191 + const s = v instanceof Date ? v.toISOString() : String(v);
192 + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
193 + };
194 + return [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\r\n') + '\r\n';
195 +}
196 +
197 +export function parseCsv(text: string): Array<Record<string, string>> {
198 + const rows: string[][] = [];
199 + let cur: string[] = [];
200 + let field = '';
201 + let inQ = false;
202 + const src = text.replace(/^/, '');
203 + for (let i = 0; i < src.length; i++) {
204 + const ch = src[i]!;
205 + if (inQ) {
206 + if (ch === '"') {
207 + if (src[i + 1] === '"') {
208 + field += '"';
209 + i++;
210 + } else inQ = false;
211 + } else field += ch;
212 + } else if (ch === '"') inQ = true;
213 + else if (ch === ',') {
214 + cur.push(field);
215 + field = '';
216 + } else if (ch === '\n' || ch === '\r') {
217 + if (ch === '\r' && src[i + 1] === '\n') i++;
218 + cur.push(field);
219 + field = '';
220 + if (cur.some((c) => c.trim() !== '')) rows.push(cur);
221 + cur = [];
222 + } else field += ch;
223 + }
224 + if (field !== '' || cur.length) {
225 + cur.push(field);
226 + if (cur.some((c) => c.trim() !== '')) rows.push(cur);
227 + }
228 + const header = (rows.shift() ?? []).map((h) => h.trim().toLowerCase().replace(/\s+/g, '_'));
229 + return rows.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()])));
230 +}
added apps/web/src/lib/account/queries.ts +260 −0
@@ -0,0 +1,260 @@
1 +import 'server-only';
2 +import { and, asc, desc, eq, inArray, isNull, sql, count } from '@/lib/db';
3 +import { db, assets, assetStats, assetVariants, variantStats, collections, collectionItems, collectionSnapshots, watchlists, watchlistItems, alerts, notifications, savedSearches, priceTargets, listings, indexValues, indices, categories, users } from '@/lib/db';
4 +import { summarizePortfolio, type PortfolioItemInput, type PortfolioSummary } from './portfolio';
5 +
6 +// ---------------------------------------------------------------- assets (minimal local search)
7 +
8 +export interface AssetHit {
9 + id: string;
10 + slug: string;
11 + title: string;
12 + categorySlug: string;
13 + familySlug: string;
14 + year: number | null;
15 + heroImageUrl: string | null;
16 + rivUsd: number | null;
17 + rivConfidence: number | null;
18 + salesCount: number;
19 +}
20 +
21 +/** Trigram + prefix search over asset titles for the "add item" flow (packages/search may replace it). */
22 +export async function searchAssets(q: string, limit = 12, categorySlug?: string): Promise<AssetHit[]> {
23 + const term = q.trim();
24 + if (term.length < 2) return [];
25 + const rows = (await db().execute(sql`
26 + select a.id, a.slug, a.title, a.category_slug, a.family_slug, a.year, a.hero_image_url,
27 + s.riv_usd, s.riv_confidence, coalesce(s.sales_count, 0) as sales_count,
28 + greatest(similarity(a.title, ${term}), case when a.title ilike ${'%' + term + '%'} then 0.6 else 0 end) as score
29 + from assets a
30 + left join asset_stats s on s.asset_id = a.id
31 + where (a.title % ${term} or a.title ilike ${'%' + term + '%'} or a.search @@ plainto_tsquery('simple', ${term}))
32 + ${categorySlug ? sql`and a.category_slug = ${categorySlug}` : sql``}
33 + order by score desc, coalesce(s.sales_count, 0) desc
34 + limit ${limit}
35 + `)) as unknown as Array<Record<string, unknown>>;
36 + return rows.map((r) => ({
37 + id: String(r.id),
38 + slug: String(r.slug),
39 + title: String(r.title),
40 + categorySlug: String(r.category_slug),
41 + familySlug: String(r.family_slug),
42 + year: r.year === null ? null : Number(r.year),
43 + heroImageUrl: (r.hero_image_url as string | null) ?? null,
44 + rivUsd: r.riv_usd === null ? null : Number(r.riv_usd),
45 + rivConfidence: r.riv_confidence === null ? null : Number(r.riv_confidence),
46 + salesCount: Number(r.sales_count ?? 0),
47 + }));
48 +}
49 +
50 +export async function getAssetBrief(assetId: string) {
51 + const rows = await db().select({ asset: assets, stats: assetStats }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, assetId)).limit(1);
52 + return rows[0] ?? null;
53 +}
54 +
55 +export async function listVariants(assetId: string) {
56 + return db().select({ v: assetVariants, s: variantStats }).from(assetVariants).leftJoin(variantStats, eq(variantStats.variantId, assetVariants.id)).where(eq(assetVariants.assetId, assetId)).orderBy(asc(assetVariants.label));
57 +}
58 +
59 +// ---------------------------------------------------------------- collections
60 +
61 +export async function listCollections(userId: string) {
62 + const cols = await db().select().from(collections).where(eq(collections.userId, userId)).orderBy(asc(collections.createdAt));
63 + if (!cols.length) return [] as Array<{ collection: typeof collections.$inferSelect; summary: PortfolioSummary }>;
64 + const items = await loadItems(cols.map((c) => c.id));
65 + return cols.map((c) => ({ collection: c, summary: summarizePortfolio(items.filter((i) => i.collectionId === c.id)) }));
66 +}
67 +
68 +export type LoadedItem = PortfolioItemInput & { collectionId: string; variantId: string | null; variantLabel: string | null; acquiredCurrency: string | null; purchasePriceNative: number | null; source: string | null; certificationNumber: string | null; serial: string | null; notes: string | null; tags: string[]; photos: string[]; assetSlug: string; heroImageUrl: string | null; condition: string | null; createdAt: Date; soldAt: string | null };
69 +
70 +export async function loadItems(collectionIds: string[]): Promise<LoadedItem[]> {
71 + if (!collectionIds.length) return [];
72 + const rows = await db()
73 + .select({ item: collectionItems, asset: assets, stats: assetStats, variant: assetVariants, vstats: variantStats })
74 + .from(collectionItems)
75 + .innerJoin(assets, eq(assets.id, collectionItems.assetId))
76 + .leftJoin(assetStats, eq(assetStats.assetId, assets.id))
77 + .leftJoin(assetVariants, eq(assetVariants.id, collectionItems.variantId))
78 + .leftJoin(variantStats, eq(variantStats.variantId, collectionItems.variantId))
79 + .where(inArray(collectionItems.collectionId, collectionIds))
80 + .orderBy(desc(collectionItems.createdAt));
81 + return rows.map(({ item, asset, stats, variant, vstats }) => ({
82 + id: item.id,
83 + collectionId: item.collectionId,
84 + assetId: asset.id,
85 + assetSlug: asset.slug,
86 + heroImageUrl: asset.heroImageUrl,
87 + title: asset.title,
88 + categorySlug: asset.categorySlug,
89 + familySlug: asset.familySlug,
90 + quantity: item.quantity,
91 + purchasePriceUsd: item.purchasePriceUsd,
92 + purchasePriceNative: item.purchasePrice,
93 + acquiredCurrency: item.purchaseCurrency,
94 + acquiredAt: item.acquiredAt ? String(item.acquiredAt) : null,
95 + grader: item.grader ?? variant?.grader ?? null,
96 + grade: item.grade ?? variant?.grade ?? null,
97 + variantId: item.variantId,
98 + variantLabel: variant?.label ?? null,
99 + variantRivUsd: vstats?.rivUsd ?? null,
100 + variantConfidence: vstats?.rivConfidence ?? null,
101 + assetRivUsd: stats?.rivUsd ?? null,
102 + assetConfidence: stats?.rivConfidence ?? null,
103 + manualValueUsd: item.manualValueUsd,
104 + liquidityScore: stats?.liquidityScore ?? null,
105 + rarityScore: stats?.rarityScore ?? null,
106 + change30d: stats?.change30d ?? null,
107 + source: item.source,
108 + certificationNumber: item.certificationNumber,
109 + serial: item.serial,
110 + notes: item.notes,
111 + tags: item.tags ?? [],
112 + photos: item.photos ?? [],
113 + condition: item.condition,
114 + createdAt: item.createdAt,
115 + soldAt: item.soldAt ? String(item.soldAt) : null,
116 + soldPriceUsd: item.soldPriceUsd,
117 + }));
118 +}
119 +
120 +export async function getCollection(userId: string, id: string) {
121 + const rows = await db().select().from(collections).where(and(eq(collections.id, id), eq(collections.userId, userId))).limit(1);
122 + return rows[0] ?? null;
123 +}
124 +
125 +export async function getCollectionDetail(userId: string, id: string) {
126 + const col = await getCollection(userId, id);
127 + if (!col) return null;
128 + const items = await loadItems([id]);
129 + const summary = summarizePortfolio(items);
130 + const history = await db().select().from(collectionSnapshots).where(eq(collectionSnapshots.collectionId, id)).orderBy(asc(collectionSnapshots.date));
131 + return { collection: col, items, summary, history };
132 +}
133 +
134 +export async function portfolioHistory(userId: string): Promise<Array<{ date: string; valueUsd: number; costBasisUsd: number }>> {
135 + const rows = (await db().execute(sql`
136 + select s.date::text as date, sum(s.value_usd)::float as value_usd, sum(s.cost_basis_usd)::float as cost_basis_usd
137 + from collection_snapshots s join collections c on c.id = s.collection_id
138 + where c.user_id = ${userId}
139 + group by s.date order by s.date
140 + `)) as unknown as Array<{ date: string; value_usd: number; cost_basis_usd: number }>;
141 + return rows.map((r) => ({ date: r.date, valueUsd: Number(r.value_usd), costBasisUsd: Number(r.cost_basis_usd) }));
142 +}
143 +
144 +export async function indexSeries(ticker: string, since?: string) {
145 + const idx = await db().select({ id: indices.id }).from(indices).where(eq(indices.ticker, ticker)).limit(1);
146 + if (!idx[0]) return [];
147 + const rows = await db().select({ date: indexValues.date, value: indexValues.value }).from(indexValues).where(since ? and(eq(indexValues.indexId, idx[0].id), sql`${indexValues.date} >= ${since}`) : eq(indexValues.indexId, idx[0].id)).orderBy(asc(indexValues.date));
148 + return rows.map((r) => ({ date: String(r.date), value: r.value }));
149 +}
150 +
151 +// ---------------------------------------------------------------- watchlist
152 +
153 +export async function getOrCreateWatchlist(userId: string) {
154 + const rows = await db().select().from(watchlists).where(eq(watchlists.userId, userId)).orderBy(asc(watchlists.createdAt)).limit(1);
155 + if (rows[0]) return rows[0];
156 + const { newId } = await import('@rareindex/shared');
157 + const id = newId('watchlist');
158 + await db().insert(watchlists).values({ id, userId, name: 'Watchlist' });
159 + return (await db().select().from(watchlists).where(eq(watchlists.id, id)))[0]!;
160 +}
161 +
162 +export interface WatchRow {
163 + item: typeof watchlistItems.$inferSelect;
164 + asset: typeof assets.$inferSelect | null;
165 + stats: typeof assetStats.$inferSelect | null;
166 + category: typeof categories.$inferSelect | null;
167 +}
168 +
169 +export async function loadWatchlist(userId: string): Promise<WatchRow[]> {
170 + const wl = await getOrCreateWatchlist(userId);
171 + const items = await db().select().from(watchlistItems).where(eq(watchlistItems.watchlistId, wl.id)).orderBy(desc(watchlistItems.createdAt));
172 + const assetIds = items.filter((i) => i.targetType === 'asset').map((i) => i.targetId);
173 + const catIds = items.filter((i) => i.targetType === 'category').map((i) => i.targetId);
174 + const [assetRows, catRows] = await Promise.all([
175 + assetIds.length ? db().select({ asset: assets, stats: assetStats }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(inArray(assets.id, assetIds)) : Promise.resolve([]),
176 + catIds.length ? db().select().from(categories).where(inArray(categories.slug, catIds)) : Promise.resolve([]),
177 + ]);
178 + const aMap = new Map(assetRows.map((r) => [r.asset.id, r]));
179 + const cMap = new Map(catRows.map((c) => [c.slug, c]));
180 + return items.map((item) => ({ item, asset: aMap.get(item.targetId)?.asset ?? null, stats: aMap.get(item.targetId)?.stats ?? null, category: cMap.get(item.targetId) ?? null }));
181 +}
182 +
183 +export async function isWatched(userId: string, targetType: string, targetId: string): Promise<boolean> {
184 + const wl = await db().select({ id: watchlists.id }).from(watchlists).where(eq(watchlists.userId, userId));
185 + if (!wl.length) return false;
186 + const rows = await db().select({ id: watchlistItems.id }).from(watchlistItems).where(and(inArray(watchlistItems.watchlistId, wl.map((w) => w.id)), eq(watchlistItems.targetType, targetType), eq(watchlistItems.targetId, targetId))).limit(1);
187 + return Boolean(rows[0]);
188 +}
189 +
190 +// ---------------------------------------------------------------- alerts / notifications / saved / targets
191 +
192 +export async function listAlerts(userId: string) {
193 + const rows = await db().select().from(alerts).where(eq(alerts.userId, userId)).orderBy(desc(alerts.createdAt));
194 + const assetIds = rows.filter((a) => a.targetType === 'asset').map((a) => a.targetId);
195 + const aRows = assetIds.length ? await db().select({ id: assets.id, title: assets.title, slug: assets.slug }).from(assets).where(inArray(assets.id, assetIds)) : [];
196 + const aMap = new Map(aRows.map((a) => [a.id, a]));
197 + return rows.map((a) => ({ alert: a, asset: aMap.get(a.targetId) ?? null }));
198 +}
199 +
200 +export async function listNotifications(userId: string, limit = 50) {
201 + return db().select().from(notifications).where(eq(notifications.userId, userId)).orderBy(desc(notifications.createdAt)).limit(limit);
202 +}
203 +
204 +export async function unreadCount(userId: string): Promise<number> {
205 + const rows = await db().select({ n: count() }).from(notifications).where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));
206 + return Number(rows[0]?.n ?? 0);
207 +}
208 +
209 +export async function listSavedSearches(userId: string) {
210 + return db().select().from(savedSearches).where(eq(savedSearches.userId, userId)).orderBy(desc(savedSearches.createdAt));
211 +}
212 +
213 +export async function listTargets(userId: string) {
214 + const rows = await db().select({ target: priceTargets, asset: assets, stats: assetStats }).from(priceTargets).innerJoin(assets, eq(assets.id, priceTargets.assetId)).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(priceTargets.userId, userId)).orderBy(desc(priceTargets.createdAt));
215 + return rows;
216 +}
217 +
218 +/** Deal Radar: available listings priced materially below RIV inside the member's universe (§123). */
219 +export async function dealRadar(userId: string, opts: { minDiscount?: number; limit?: number } = {}) {
220 + const minDiscount = opts.minDiscount ?? 0.15;
221 + const limit = opts.limit ?? 50;
222 + const rows = (await db().execute(sql`
223 + with universe as (
224 + select distinct a.category_slug from collection_items ci join collections c on c.id = ci.collection_id join assets a on a.id = ci.asset_id where c.user_id = ${userId}
225 + union
226 + select distinct a.category_slug from watchlist_items wi join watchlists w on w.id = wi.watchlist_id join assets a on a.id = wi.target_id where w.user_id = ${userId} and wi.target_type = 'asset'
227 + union
228 + select wi.target_id from watchlist_items wi join watchlists w on w.id = wi.watchlist_id where w.user_id = ${userId} and wi.target_type = 'category'
229 + ), watched as (
230 + select wi.target_id as asset_id from watchlist_items wi join watchlists w on w.id = wi.watchlist_id where w.user_id = ${userId} and wi.target_type = 'asset'
231 + )
232 + select l.id, l.asset_id, a.slug, a.title, a.category_slug, a.hero_image_url, l.source_id, l.source_url, l.price_usd, l.currency, l.price, l.grader, l.grade, l.condition,
233 + l.discount_to_riv, s.riv_usd, s.riv_confidence, s.riv_sample_size, l.last_seen_at, l.ends_at,
234 + (a.id in (select asset_id from watched)) as watched
235 + from listings l
236 + join assets a on a.id = l.asset_id
237 + join asset_stats s on s.asset_id = a.id
238 + where l.availability = 'available'
239 + and l.discount_to_riv is not null and l.discount_to_riv <= ${-minDiscount}
240 + and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5
241 + and (a.category_slug in (select category_slug from universe) or a.id in (select asset_id from watched))
242 + order by watched desc, l.discount_to_riv asc
243 + limit ${limit}
244 + `)) as unknown as Array<Record<string, unknown>>;
245 + return rows;
246 +}
247 +
248 +export async function publicProfile(handle: string) {
249 + const rows = await db().select().from(users).where(and(eq(users.handle, handle.toLowerCase()), isNull(users.deletedAt))).limit(1);
250 + const u = rows[0];
251 + if (!u) return null;
252 + const cols = await db().select().from(collections).where(and(eq(collections.userId, u.id), eq(collections.isPublic, true))).orderBy(asc(collections.createdAt));
253 + const items = await loadItems(cols.map((c) => c.id));
254 + return { user: u, collections: cols.map((c) => ({ collection: c, summary: summarizePortfolio(items.filter((i) => i.collectionId === c.id)) })), items };
255 +}
256 +
257 +export async function activeListingsForAssets(assetIds: string[]) {
258 + if (!assetIds.length) return [];
259 + return db().select({ assetId: listings.assetId, n: count(), minAsk: sql<number | null>`min(${listings.priceUsd})` }).from(listings).where(and(inArray(listings.assetId, assetIds), eq(listings.availability, 'available'))).groupBy(listings.assetId);
260 +}
added apps/web/src/lib/account/upload-actions.ts +19 −0
@@ -0,0 +1,19 @@
1 +'use server';
2 +
3 +import { revalidatePath } from 'next/cache';
4 +import { eq } from '@/lib/db';
5 +import { db, users } from '@/lib/db';
6 +import { requireUser } from '@/lib/auth/session';
7 +import type { ActionState } from '@/lib/auth/state';
8 +import { storeUpload } from './uploads';
9 +
10 +export async function uploadAvatarAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
11 + const u = await requireUser('/account/settings');
12 + const file = formData.get('file');
13 + if (!(file instanceof File)) return { ok: false, error: 'Choose an image.' };
14 + const res = await storeUpload(u.id, 'avatar', file);
15 + if ('error' in res) return { ok: false, error: res.error };
16 + await db().update(users).set({ avatarUrl: res.url }).where(eq(users.id, u.id));
17 + revalidatePath('/account/settings');
18 + return { ok: true, message: 'Avatar updated.', data: { url: res.url } };
19 +}
added apps/web/src/lib/account/uploads.ts +83 −0
@@ -0,0 +1,83 @@
1 +import 'server-only';
2 +import { mkdir, writeFile, readFile, unlink } from 'node:fs/promises';
3 +import path from 'node:path';
4 +import { newId } from '@rareindex/shared';
5 +import { db, uploads } from '@/lib/db';
6 +
7 +const MAX_BYTES: Record<string, number> = { avatar: 2 * 1024 * 1024, item_photo: 8 * 1024 * 1024 };
8 +const ALLOWED: Record<string, string> = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' };
9 +
10 +function uploadsRoot(): string {
11 + return path.resolve(process.env.RI_DATA_DIR ?? './data', 'uploads');
12 +}
13 +
14 +/** Sniff the real type from magic bytes; never trust the declared MIME. */
15 +function sniff(buf: Buffer): string | null {
16 + if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
17 + if (buf.length > 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
18 + if (buf.length > 12 && buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
19 + return null;
20 +}
21 +
22 +/** Cheap dimension probe for PNG/JPEG/WebP (no image library needed). */
23 +function dimensions(buf: Buffer, mime: string): { width: number; height: number } | null {
24 + try {
25 + if (mime === 'image/png') return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
26 + if (mime === 'image/jpeg') {
27 + let i = 2;
28 + while (i < buf.length) {
29 + if (buf[i] !== 0xff) return null;
30 + const marker = buf[i + 1]!;
31 + const len = buf.readUInt16BE(i + 2);
32 + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
33 + i += 2 + len;
34 + }
35 + }
36 + if (mime === 'image/webp') {
37 + const chunk = buf.subarray(12, 16).toString('ascii');
38 + if (chunk === 'VP8X') return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
39 + if (chunk === 'VP8 ') return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
40 + }
41 + } catch {
42 + return null;
43 + }
44 + return null;
45 +}
46 +
47 +export async function storeUpload(userId: string, kind: 'avatar' | 'item_photo', file: File): Promise<{ id: string; url: string } | { error: string }> {
48 + const max = MAX_BYTES[kind]!;
49 + if (file.size === 0) return { error: 'Empty file.' };
50 + if (file.size > max) return { error: `File too large (max ${Math.round(max / 1024 / 1024)} MB).` };
51 + const buf = Buffer.from(await file.arrayBuffer());
52 + const mime = sniff(buf);
53 + if (!mime || !ALLOWED[mime]) return { error: 'Only JPEG, PNG and WebP images are accepted.' };
54 + const id = newId('image');
55 + const rel = path.join(userId, `${id}.${ALLOWED[mime]}`);
56 + const abs = path.join(uploadsRoot(), rel);
57 + await mkdir(path.dirname(abs), { recursive: true });
58 + await writeFile(abs, buf);
59 + const dim = dimensions(buf, mime);
60 + await db().insert(uploads).values({ id, userId, kind, path: rel, mime, bytes: buf.length, width: dim?.width ?? null, height: dim?.height ?? null });
61 + return { id, url: `/api/account/uploads/${id}` };
62 +}
63 +
64 +export async function readUpload(id: string): Promise<{ buf: Buffer; mime: string; userId: string } | null> {
65 + const rows = await db().select().from(uploads).where((await import('@/lib/db')).eq(uploads.id, id)).limit(1);
66 + const row = rows[0];
67 + if (!row) return null;
68 + try {
69 + const buf = await readFile(path.join(uploadsRoot(), row.path));
70 + return { buf, mime: row.mime, userId: row.userId };
71 + } catch {
72 + return null;
73 + }
74 +}
75 +
76 +export async function deleteUpload(userId: string, id: string): Promise<void> {
77 + const { and, eq } = await import('@/lib/db');
78 + const rows = await db().select().from(uploads).where(and(eq(uploads.id, id), eq(uploads.userId, userId))).limit(1);
79 + const row = rows[0];
80 + if (!row) return;
81 + await unlink(path.join(uploadsRoot(), row.path)).catch(() => {});
82 + await db().delete(uploads).where(eq(uploads.id, id));
83 +}
added apps/web/src/lib/auth/account-actions.ts +277 −0
@@ -0,0 +1,277 @@
1 +'use server';
2 +
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +import { revalidatePath } from 'next/cache';
6 +import { and, eq, isNull, ne } from '@/lib/db';
7 +import QRCode from 'qrcode';
8 +import { z } from 'zod';
9 +import { newId } from '@rareindex/shared';
10 +import { sendMail, changeEmailEmail, accountDeletionEmail } from '@rareindex/notify';
11 +import { db, users, sessions, recoveryCodes, trustedDevices, apiKeys } from '@/lib/db';
12 +import { hashPassword, verifyPassword, encryptSecret, decryptSecret, hmacToken, recoveryCode, randomToken, signPayload, verifyPayload, sha256Hex } from './crypto';
13 +import { issueCode, verifyCode } from './codes';
14 +import { CURRENCY_COOKIE, DISPLAY_CURRENCIES, currentSessionId, destroySession, requireUser, revokeAllSessions } from './session';
15 +import { revokeAllDevices, revokeDevice } from './devices';
16 +import { enforce, RateLimited } from './rate-limit';
17 +import { newTotpSecret, totpUri, verifyTotp } from './totp';
18 +import { emailSchema, handleSchema, isReservedHandle, passwordSchema } from './validation';
19 +import { fieldErrorsFrom, type ActionState } from './state';
20 +
21 +function fail(error: string, extra: Partial<ActionState> = {}): ActionState {
22 + return { ok: false, error, ...extra };
23 +}
24 +async function guarded<T>(fn: () => Promise<T>): Promise<T | ActionState> {
25 + try {
26 + return await fn();
27 + } catch (err) {
28 + if (err instanceof RateLimited) return fail(err.message);
29 + throw err;
30 + }
31 +}
32 +
33 +// ---------------------------------------------------------------- profile & preferences
34 +
35 +const profileSchema = z.object({
36 + name: z.string().trim().max(80).optional().or(z.literal('')),
37 + handle: z.union([z.literal(''), handleSchema]).optional(),
38 + bio: z.string().trim().max(280).optional().or(z.literal('')),
39 + displayCurrency: z.enum(DISPLAY_CURRENCIES),
40 +});
41 +
42 +export async function updateProfileAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
43 + const u = await requireUser('/account/settings');
44 + const parsed = profileSchema.safeParse({ name: formData.get('name') ?? '', handle: String(formData.get('handle') ?? '').trim().toLowerCase(), bio: formData.get('bio') ?? '', displayCurrency: formData.get('displayCurrency') ?? u.displayCurrency });
45 + if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
46 + const handle = parsed.data.handle ? parsed.data.handle : null;
47 + if (handle) {
48 + if (isReservedHandle(handle)) return fail('That handle is reserved.', { fieldErrors: { handle: 'Reserved' } });
49 + const taken = await db().select({ id: users.id }).from(users).where(and(eq(users.handle, handle), ne(users.id, u.id))).limit(1);
50 + if (taken[0]) return fail('That handle is taken.', { fieldErrors: { handle: 'Already taken' } });
51 + }
52 + await db().update(users).set({ name: parsed.data.name || null, handle, bio: parsed.data.bio || null, displayCurrency: parsed.data.displayCurrency }).where(eq(users.id, u.id));
53 + (await cookies()).set(CURRENCY_COOKIE, parsed.data.displayCurrency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' });
54 + revalidatePath('/account/settings');
55 + return { ok: true, message: 'Profile saved.' };
56 +}
57 +
58 +export async function setCurrencyAction(currency: string): Promise<void> {
59 + if (!(DISPLAY_CURRENCIES as readonly string[]).includes(currency)) return;
60 + (await cookies()).set(CURRENCY_COOKIE, currency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' });
61 + const u = await (await import('./session')).getCurrentUser();
62 + if (u) await db().update(users).set({ displayCurrency: currency }).where(eq(users.id, u.id));
63 +}
64 +
65 +const prefsSchema = z.object({
66 + emailAlerts: z.boolean(),
67 + newLoginEmails: z.boolean(),
68 + digest: z.enum(['off', 'daily', 'weekly']),
69 + digestWeekday: z.coerce.number().int().min(0).max(6),
70 + quietStart: z.coerce.number().int().min(0).max(23).nullable(),
71 + quietEnd: z.coerce.number().int().min(0).max(23).nullable(),
72 + marketMoves: z.boolean(),
73 +});
74 +
75 +export async function updateNotificationPrefsAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
76 + const u = await requireUser('/account/notifications');
77 + const quiet = formData.get('quiet') === 'on';
78 + const parsed = prefsSchema.safeParse({
79 + emailAlerts: formData.get('emailAlerts') === 'on',
80 + newLoginEmails: formData.get('newLoginEmails') === 'on',
81 + digest: formData.get('digest') ?? 'weekly',
82 + digestWeekday: formData.get('digestWeekday') ?? 1,
83 + quietStart: quiet ? formData.get('quietStart') : null,
84 + quietEnd: quiet ? formData.get('quietEnd') : null,
85 + marketMoves: formData.get('marketMoves') === 'on',
86 + });
87 + if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
88 + await db().update(users).set({ preferences: { ...(u.preferences ?? {}), ...parsed.data } }).where(eq(users.id, u.id));
89 + revalidatePath('/account/notifications');
90 + return { ok: true, message: 'Notification preferences saved.' };
91 +}
92 +
93 +// ---------------------------------------------------------------- password & e-mail
94 +
95 +export async function changePasswordAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
96 + return guarded(async () => {
97 + const u = await requireUser('/account/security');
98 + await enforce(`chpw:${u.id}`, 10, 3600);
99 + const current = String(formData.get('current') ?? '');
100 + const next = passwordSchema.safeParse(formData.get('password'));
101 + if (!next.success) return fail('Choose a stronger password.', { fieldErrors: { password: next.error.issues[0]?.message ?? 'Invalid' } });
102 + if (!(await verifyPassword(current, u.passwordHash))) return fail('Current password is incorrect.', { fieldErrors: { current: 'Incorrect' } });
103 + await db().update(users).set({ passwordHash: await hashPassword(next.data), passwordChangedAt: new Date() }).where(eq(users.id, u.id));
104 + const n = await revokeAllSessions(u.id, true);
105 + revalidatePath('/account/security');
106 + return { ok: true, message: `Password changed. ${n} other session${n === 1 ? '' : 's'} signed out.` };
107 + });
108 +}
109 +
110 +export async function startChangeEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
111 + return guarded(async () => {
112 + const u = await requireUser('/account/settings');
113 + await enforce(`chmail:${u.id}`, 5, 3600);
114 + const email = emailSchema.safeParse(formData.get('newEmail'));
115 + if (!email.success) return fail('Enter a valid e-mail.', { fieldErrors: { newEmail: 'Invalid e-mail' } });
116 + if (email.data === u.email) return fail('That is already your e-mail.');
117 + if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.', { fieldErrors: { password: 'Incorrect' } });
118 + const taken = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1);
119 + if (taken[0]) return fail('That e-mail is already in use.', { fieldErrors: { newEmail: 'In use' } });
120 + const issued = await issueCode({ email: email.data, purpose: 'change_email', userId: u.id, payload: { newEmail: email.data } });
121 + if ('cooldownSeconds' in issued) return fail(`Wait ${issued.cooldownSeconds}s before requesting another code.`);
122 + await sendMail({ to: email.data, ...changeEmailEmail({ code: issued.code, minutes: issued.minutes, newEmail: email.data }) });
123 + await db().update(users).set({ pendingEmail: email.data }).where(eq(users.id, u.id));
124 + revalidatePath('/account/settings');
125 + return { ok: true, message: `We sent a confirmation code to ${email.data}.`, data: { pendingEmail: email.data } };
126 + });
127 +}
128 +
129 +export async function confirmChangeEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
130 + return guarded(async () => {
131 + const u = await requireUser('/account/settings');
132 + if (!u.pendingEmail) return fail('No e-mail change in progress.');
133 + await enforce(`chmail-confirm:${u.id}`, 10, 900);
134 + const res = await verifyCode({ email: u.pendingEmail, purpose: 'change_email', code: String(formData.get('code') ?? '') });
135 + if (!res.ok) return fail('That code is not valid or expired.');
136 + await db().update(users).set({ email: u.pendingEmail, pendingEmail: null, emailVerifiedAt: new Date() }).where(eq(users.id, u.id));
137 + revalidatePath('/account/settings');
138 + return { ok: true, message: 'E-mail updated.' };
139 + });
140 +}
141 +
142 +export async function cancelChangeEmailAction(): Promise<void> {
143 + const u = await requireUser('/account/settings');
144 + await db().update(users).set({ pendingEmail: null }).where(eq(users.id, u.id));
145 + revalidatePath('/account/settings');
146 +}
147 +
148 +// ---------------------------------------------------------------- MFA
149 +
150 +const SETUP_COOKIE = 'ri_mfa_setup';
151 +
152 +export async function startMfaSetupAction(_prev: ActionState): Promise<ActionState> {
153 + const u = await requireUser('/account/security');
154 + if (u.mfaEnabled) return fail('Authenticator is already enabled.');
155 + const secret = newTotpSecret();
156 + const uri = totpUri(secret, u.email);
157 + const qr = await QRCode.toDataURL(uri, { margin: 1, width: 220, color: { dark: '#0b0b0c', light: '#ffffff' } });
158 + (await cookies()).set(SETUP_COOKIE, signPayload({ uid: u.id, enc: encryptSecret(secret) }, 15 * 60), { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 15 * 60 });
159 + return { ok: true, data: { qr, secret, uri } };
160 +}
161 +
162 +async function issueRecoveryCodes(userId: string): Promise<string[]> {
163 + await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, userId));
164 + const codes = Array.from({ length: 10 }, () => recoveryCode());
165 + await db().insert(recoveryCodes).values(codes.map((c) => ({ id: newId('event'), userId, codeHash: hmacToken(c, 'recovery') })));
166 + return codes;
167 +}
168 +
169 +export async function confirmMfaSetupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
170 + return guarded(async () => {
171 + const u = await requireUser('/account/security');
172 + await enforce(`mfa-setup:${u.id}`, 10, 900);
173 + const raw = (await cookies()).get(SETUP_COOKIE)?.value;
174 + const p = verifyPayload<{ uid: string; enc: string }>(raw);
175 + if (!p || p.uid !== u.id) return fail('Setup expired. Start again.');
176 + const secret = decryptSecret(p.enc);
177 + if (verifyTotp(secret, String(formData.get('code') ?? '')) === null) return fail('That code does not match. Check the time on your device and try again.');
178 + await db().update(users).set({ mfaEnabled: true, totpSecretEnc: p.enc }).where(eq(users.id, u.id));
179 + const codes = await issueRecoveryCodes(u.id);
180 + (await cookies()).delete(SETUP_COOKIE);
181 + revalidatePath('/account/security');
182 + return { ok: true, message: 'Authenticator enabled. Save your recovery codes now — they are shown once.', data: { recoveryCodes: codes } };
183 + });
184 +}
185 +
186 +export async function disableMfaAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
187 + return guarded(async () => {
188 + const u = await requireUser('/account/security');
189 + await enforce(`mfa-disable:${u.id}`, 10, 900);
190 + if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');
191 + if (!u.totpSecretEnc || verifyTotp(decryptSecret(u.totpSecretEnc), String(formData.get('code') ?? '')) === null) return fail('Authenticator code is not valid.');
192 + await db().update(users).set({ mfaEnabled: false, totpSecretEnc: null }).where(eq(users.id, u.id));
193 + await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, u.id));
194 + revalidatePath('/account/security');
195 + return { ok: true, message: 'Authenticator disabled. E-mail codes remain active for new devices.' };
196 + });
197 +}
198 +
199 +export async function regenerateRecoveryCodesAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
200 + return guarded(async () => {
201 + const u = await requireUser('/account/security');
202 + await enforce(`recov:${u.id}`, 5, 3600);
203 + if (!u.mfaEnabled) return fail('Enable the authenticator first.');
204 + if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');
205 + const codes = await issueRecoveryCodes(u.id);
206 + return { ok: true, message: 'New recovery codes generated. Previous codes no longer work.', data: { recoveryCodes: codes } };
207 + });
208 +}
209 +
210 +export async function toggleAlwaysAskCodeAction(formData: FormData): Promise<void> {
211 + const u = await requireUser('/account/security');
212 + await db().update(users).set({ alwaysAskCode: formData.get('alwaysAskCode') === 'on' }).where(eq(users.id, u.id));
213 + revalidatePath('/account/security');
214 +}
215 +
216 +export async function revokeSessionAction(formData: FormData): Promise<void> {
217 + const u = await requireUser('/account/security');
218 + const id = String(formData.get('sessionId') ?? '');
219 + const current = await currentSessionId();
220 + if (id && id !== current) await db().update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.id, id), eq(sessions.userId, u.id)));
221 + revalidatePath('/account/security');
222 +}
223 +
224 +export async function revokeDeviceAction(formData: FormData): Promise<void> {
225 + const u = await requireUser('/account/security');
226 + await revokeDevice(u.id, String(formData.get('deviceId') ?? ''));
227 + revalidatePath('/account/security');
228 +}
229 +
230 +export async function signOutEverywhereAction(): Promise<void> {
231 + const u = await requireUser('/account/security');
232 + await revokeAllSessions(u.id, true);
233 + await revokeAllDevices(u.id);
234 + revalidatePath('/account/security');
235 +}
236 +
237 +// ---------------------------------------------------------------- API keys
238 +
239 +export async function createApiKeyAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
240 + const u = await requireUser('/account/api-keys');
241 + const name = z.string().trim().min(1).max(60).safeParse(formData.get('name'));
242 + if (!name.success) return fail('Give the key a name.', { fieldErrors: { name: 'Required' } });
243 + const existing = await db().select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.userId, u.id), isNull(apiKeys.revokedAt)));
244 + if (existing.length >= 10) return fail('You can hold at most 10 active keys.');
245 + const secret = `ri_${u.role === 'admin' || u.role === 'pro' ? 'live' : 'free'}_${randomToken(24)}`;
246 + const prefix = secret.slice(0, 14);
247 + const tier = u.role === 'admin' ? 'enterprise' : u.role === 'pro' ? 'professional' : 'free';
248 + const limits = tier === 'enterprise' ? { rpm: 600, daily: 100_000 } : tier === 'professional' ? { rpm: 120, daily: 10_000 } : { rpm: 30, daily: 1_000 };
249 + await db().insert(apiKeys).values({ id: newId('apiKey'), userId: u.id, name: name.data, prefix, keyHash: sha256Hex(secret), tier, rateLimitPerMinute: limits.rpm, dailyQuota: limits.daily });
250 + revalidatePath('/account/api-keys');
251 + return { ok: true, message: 'Key created. Copy it now — it will not be shown again.', data: { secret } };
252 +}
253 +
254 +export async function revokeApiKeyAction(formData: FormData): Promise<void> {
255 + const u = await requireUser('/account/api-keys');
256 + await db().update(apiKeys).set({ revokedAt: new Date() }).where(and(eq(apiKeys.id, String(formData.get('keyId') ?? '')), eq(apiKeys.userId, u.id)));
257 + revalidatePath('/account/api-keys');
258 +}
259 +
260 +// ---------------------------------------------------------------- deletion
261 +
262 +export async function deleteAccountAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
263 + return guarded(async () => {
264 + const u = await requireUser('/account/data');
265 + await enforce(`delete:${u.id}`, 5, 3600);
266 + if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');
267 + if (String(formData.get('confirm') ?? '').trim().toUpperCase() !== 'DELETE') return fail('Type DELETE to confirm.');
268 + const purgeAfter = new Date(Date.now() + 30 * 86_400_000);
269 + await db().update(users).set({ deletedAt: new Date(), purgeAfter }).where(eq(users.id, u.id));
270 + await revokeAllSessions(u.id, false);
271 + await revokeAllDevices(u.id);
272 + await db().update(trustedDevices).set({ revokedAt: new Date() }).where(eq(trustedDevices.userId, u.id));
273 + void sendMail({ to: u.email, ...accountDeletionEmail({ purgeAt: purgeAfter }) }).catch(() => {});
274 + await destroySession();
275 + redirect('/?deleted=1');
276 + });
277 +}
added apps/web/src/lib/auth/actions.ts +291 −0
@@ -0,0 +1,291 @@
1 +'use server';
2 +
3 +import { redirect } from 'next/navigation';
4 +import { and, eq, isNull } from '@/lib/db';
5 +import { newId } from '@rareindex/shared';
6 +import { sendMail, verificationEmail, mfaCodeEmail, newLoginEmail, passwordResetEmail } from '@rareindex/notify';
7 +import { db, users, loginEvents, recoveryCodes, watchlists } from '@/lib/db';
8 +import { hashPassword, verifyPassword, decryptSecret, hmacToken, normalizeRecoveryCode, signPayload, verifyPayload } from './crypto';
9 +import { issueCode, verifyCode } from './codes';
10 +import { createSession, getCurrentUser, destroySession, revokeAllSessions } from './session';
11 +import { isTrustedDevice, trustThisDevice, revokeAllDevices } from './devices';
12 +import { clearPending, getPending, safeNext, setPending, type PendingStage } from './pending';
13 +import { enforce, RateLimited } from './rate-limit';
14 +import { clientInfo } from './request';
15 +import { verifyTotp } from './totp';
16 +import { codeSchema, emailSchema, loginSchema, passwordSchema, signupSchema } from './validation';
17 +import { fieldErrorsFrom, type ActionState } from './state';
18 +
19 +const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');
20 +
21 +async function logLogin(input: { userId?: string | null; email?: string | null; outcome: string; method?: string | null }) {
22 + const { ip, userAgent } = await clientInfo();
23 + await db().insert(loginEvents).values({ id: newId('event'), userId: input.userId ?? null, email: input.email ?? null, ip, userAgent, outcome: input.outcome, method: input.method ?? null });
24 +}
25 +
26 +function fail(error: string, extra: Partial<ActionState> = {}): ActionState {
27 + return { ok: false, error, ...extra };
28 +}
29 +
30 +async function guarded<T>(fn: () => Promise<T>): Promise<T | ActionState> {
31 + try {
32 + return await fn();
33 + } catch (err) {
34 + if (err instanceof RateLimited) return fail(err.message, { data: { retryAfter: err.retryAfterSeconds } });
35 + throw err;
36 + }
37 +}
38 +
39 +// ---------------------------------------------------------------- signup
40 +
41 +export async function signupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
42 + return guarded(async () => {
43 + const parsed = signupSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), name: formData.get('name') ?? '' });
44 + if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
45 + const { email, password, name } = parsed.data;
46 + const { ip } = await clientInfo();
47 + await enforce(`signup:ip:${ip ?? 'x'}`, 10, 3600);
48 + await enforce(`signup:email:${email}`, 5, 3600);
49 +
50 + const existing = await db().select({ id: users.id, verified: users.emailVerifiedAt, deletedAt: users.deletedAt }).from(users).where(eq(users.email, email)).limit(1);
51 + let userId: string;
52 + if (existing[0]) {
53 + if (existing[0].verified && !existing[0].deletedAt) return fail('An account with this e-mail already exists.', { fieldErrors: { email: 'Already registered — sign in instead.' } });
54 + // unverified (or soft-deleted) account: allow re-registration by resetting credentials
55 + userId = existing[0].id;
56 + await db().update(users).set({ passwordHash: await hashPassword(password), name: name || null, deletedAt: null, purgeAfter: null }).where(eq(users.id, userId));
57 + } else {
58 + userId = newId('user');
59 + await db().insert(users).values({ id: userId, email, passwordHash: await hashPassword(password), name: name || null, preferences: { emailAlerts: true, digest: 'weekly', digestWeekday: 1, newLoginEmails: true } });
60 + await db().insert(watchlists).values({ id: newId('watchlist'), userId, name: 'Watchlist' });
61 + }
62 + const issued = await issueCode({ email, purpose: 'verify_email', userId });
63 + if ('code' in issued) {
64 + const mail = verificationEmail({ code: issued.code, minutes: issued.minutes });
65 + await sendMail({ to: email, ...mail, tags: [{ name: 'kind', value: 'verify' }] });
66 + }
67 + await setPending({ uid: userId, email, stage: 'verify', next: safeNext(String(formData.get('next') ?? ''), '/collections?welcome=1'), newDevice: true });
68 + redirect('/verify');
69 + });
70 +}
71 +
72 +export async function resendCodeAction(_prev: ActionState): Promise<ActionState> {
73 + return guarded(async () => {
74 + const p = await getPending();
75 + if (!p) return fail('Your session expired. Start again.');
76 + const { ip } = await clientInfo();
77 + await enforce(`resend:${p.email}`, 6, 3600);
78 + await enforce(`resend:ip:${ip ?? 'x'}`, 20, 3600);
79 + const purpose = p.stage === 'verify' ? 'verify_email' : 'mfa_email';
80 + const issued = await issueCode({ email: p.email, purpose, userId: p.uid });
81 + if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`, { data: { retryAfter: issued.cooldownSeconds } });
82 + const { userAgent } = await clientInfo();
83 + const mail = purpose === 'verify_email' ? verificationEmail({ code: issued.code, minutes: issued.minutes }) : mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent });
84 + await sendMail({ to: p.email, ...mail });
85 + return { ok: true, message: 'A new code is on its way.' };
86 + });
87 +}
88 +
89 +export async function verifyEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
90 + return guarded(async () => {
91 + const p = await getPending();
92 + if (!p || p.stage !== 'verify') return fail('Your session expired. Sign in to get a new code.');
93 + const code = codeSchema.safeParse(formData.get('code'));
94 + if (!code.success) return fail('Enter the 6-digit code.', { fieldErrors: { code: code.error.issues[0]?.message ?? 'Invalid' } });
95 + await enforce(`verify:${p.email}`, 12, 900);
96 + const res = await verifyCode({ email: p.email, purpose: 'verify_email', code: code.data });
97 + if (!res.ok) return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' ? 'That code expired. Request a new one.' : res.reason === 'missing' ? 'No active code. Request a new one.' : 'That code is not right.');
98 + await db().update(users).set({ emailVerifiedAt: new Date(), lastLoginAt: new Date() }).where(eq(users.id, p.uid));
99 + await createSession(p.uid);
100 + await trustThisDevice(p.uid);
101 + await logLogin({ userId: p.uid, email: p.email, outcome: 'success', method: 'email_code' });
102 + await clearPending();
103 + redirect(safeNext(p.next, '/collections?welcome=1'));
104 + });
105 +}
106 +
107 +// ---------------------------------------------------------------- login
108 +
109 +export async function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
110 + return guarded(async () => {
111 + const parsed = loginSchema.safeParse({ email: formData.get('email'), password: formData.get('password') });
112 + if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });
113 + const { email, password } = parsed.data;
114 + const next = safeNext(String(formData.get('next') ?? ''));
115 + const { ip, userAgent } = await clientInfo();
116 + await enforce(`login:ip:${ip ?? 'x'}`, 30, 900);
117 + await enforce(`login:email:${email}`, 10, 900);
118 +
119 + const rows = await db().select().from(users).where(eq(users.email, email)).limit(1);
120 + const user = rows[0];
121 + // constant-ish time: always run a hash verification
122 + const good = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
123 + if (!user || !good) {
124 + await logLogin({ userId: user?.id ?? null, email, outcome: user ? 'bad_password' : 'unknown_user', method: 'password' });
125 + return fail('E-mail or password is incorrect.');
126 + }
127 + if (user.deletedAt && user.purgeAfter && user.purgeAfter.getTime() < Date.now()) return fail('This account has been deleted.');
128 +
129 + if (!user.emailVerifiedAt) {
130 + const issued = await issueCode({ email, purpose: 'verify_email', userId: user.id });
131 + if ('code' in issued) await sendMail({ to: email, ...verificationEmail({ code: issued.code, minutes: issued.minutes }) });
132 + await setPending({ uid: user.id, email, stage: 'verify', next, newDevice: true });
133 + redirect('/verify');
134 + }
135 +
136 + const trusted = await isTrustedDevice(user.id);
137 + let stage: PendingStage | null = null;
138 + if (user.mfaEnabled && !trusted) stage = 'totp';
139 + else if (!user.mfaEnabled && user.alwaysAskCode && !trusted) stage = 'email_code';
140 +
141 + if (stage) {
142 + if (stage === 'email_code') {
143 + const issued = await issueCode({ email, purpose: 'mfa_email', userId: user.id });
144 + if ('code' in issued) await sendMail({ to: email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });
145 + }
146 + await logLogin({ userId: user.id, email, outcome: 'mfa_required', method: 'password' });
147 + await setPending({ uid: user.id, email, stage, next, newDevice: !trusted });
148 + redirect('/mfa');
149 + }
150 +
151 + await completeLogin(user.id, email, 'password', !trusted, false);
152 + redirect(next);
153 + });
154 +}
155 +
156 +async function completeLogin(userId: string, email: string, method: string, newDevice: boolean, trust: boolean) {
157 + const rows = await db().select({ deletedAt: users.deletedAt, prefs: users.preferences, name: users.name }).from(users).where(eq(users.id, userId)).limit(1);
158 + const u = rows[0];
159 + await db().update(users).set({ lastLoginAt: new Date(), ...(u?.deletedAt ? { deletedAt: null, purgeAfter: null } : {}) }).where(eq(users.id, userId));
160 + await createSession(userId);
161 + if (trust) await trustThisDevice(userId);
162 + await logLogin({ userId, email, outcome: 'success', method });
163 + await clearPending();
164 + const prefs = (u?.prefs ?? {}) as { newLoginEmails?: boolean };
165 + if (newDevice && prefs.newLoginEmails !== false) {
166 + const { ip, userAgent } = await clientInfo();
167 + void sendMail({ to: email, ...newLoginEmail({ when: new Date(), ip, userAgent, method }) }).catch(() => {});
168 + }
169 +}
170 +
171 +export async function mfaVerifyAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
172 + return guarded(async () => {
173 + const p = await getPending();
174 + if (!p || (p.stage !== 'totp' && p.stage !== 'email_code')) return fail('Your sign-in session expired. Sign in again.');
175 + const method = String(formData.get('method') ?? (p.stage === 'totp' ? 'totp' : 'email_code'));
176 + const trust = formData.get('trust') === 'on';
177 + const raw = String(formData.get('code') ?? '');
178 + await enforce(`mfa:${p.uid}`, 12, 900);
179 + const rows = await db().select().from(users).where(eq(users.id, p.uid)).limit(1);
180 + const user = rows[0];
181 + if (!user) return fail('Account not found.');
182 +
183 + if (method === 'totp') {
184 + if (!user.mfaEnabled || !user.totpSecretEnc) return fail('Authenticator is not enabled on this account.');
185 + const delta = verifyTotp(decryptSecret(user.totpSecretEnc), raw);
186 + if (delta === null) {
187 + await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'totp' });
188 + return fail('That authenticator code is not valid.');
189 + }
190 + } else if (method === 'email_code') {
191 + const code = codeSchema.safeParse(raw);
192 + if (!code.success) return fail('Enter the 6-digit code.');
193 + const res = await verifyCode({ email: p.email, purpose: 'mfa_email', code: code.data });
194 + if (!res.ok) {
195 + await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'email_code' });
196 + return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' || res.reason === 'missing' ? 'That code expired or was not issued. Request a new one.' : 'That code is not right.');
197 + }
198 + } else if (method === 'recovery') {
199 + const norm = normalizeRecoveryCode(raw);
200 + const hash = hmacToken(norm, 'recovery');
201 + const rc = await db().select({ id: recoveryCodes.id }).from(recoveryCodes).where(and(eq(recoveryCodes.userId, user.id), eq(recoveryCodes.codeHash, hash), isNull(recoveryCodes.usedAt))).limit(1);
202 + if (!rc[0]) {
203 + await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'recovery_code' });
204 + return fail('That recovery code is not valid or was already used.');
205 + }
206 + await db().update(recoveryCodes).set({ usedAt: new Date() }).where(eq(recoveryCodes.id, rc[0].id));
207 + } else {
208 + return fail('Unknown method.');
209 + }
210 + await completeLogin(user.id, p.email, method === 'recovery' ? 'recovery_code' : method, p.newDevice, trust);
211 + redirect(safeNext(p.next));
212 + });
213 +}
214 +
215 +/** Switch a TOTP challenge to an e-mailed code (fallback). */
216 +export async function mfaUseEmailAction(_prev: ActionState): Promise<ActionState> {
217 + return guarded(async () => {
218 + const p = await getPending();
219 + if (!p || p.stage !== 'totp') return fail('Your sign-in session expired.');
220 + await enforce(`mfa-email:${p.uid}`, 5, 3600);
221 + const issued = await issueCode({ email: p.email, purpose: 'mfa_email', userId: p.uid });
222 + if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`);
223 + const { ip, userAgent } = await clientInfo();
224 + await sendMail({ to: p.email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });
225 + await setPending({ ...p, stage: 'email_code' });
226 + return { ok: true, message: 'We e-mailed you a sign-in code.' };
227 + });
228 +}
229 +
230 +export async function logoutAction(): Promise<void> {
231 + await destroySession();
232 + redirect('/');
233 +}
234 +
235 +export async function logoutEverywhereAction(): Promise<ActionState> {
236 + const u = await getCurrentUser();
237 + if (!u) return fail('Not signed in.');
238 + const n = await revokeAllSessions(u.id, true);
239 + await revokeAllDevices(u.id);
240 + return { ok: true, message: `Signed out of ${n} other session${n === 1 ? '' : 's'} and forgot all trusted devices.` };
241 +}
242 +
243 +// ---------------------------------------------------------------- password reset
244 +
245 +export async function forgotAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
246 + return guarded(async () => {
247 + const email = emailSchema.safeParse(formData.get('email'));
248 + if (!email.success) return fail('Enter a valid e-mail address.');
249 + const { ip } = await clientInfo();
250 + await enforce(`forgot:ip:${ip ?? 'x'}`, 10, 3600);
251 + await enforce(`forgot:${email.data}`, 4, 3600);
252 + const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1);
253 + if (rows[0]) {
254 + const issued = await issueCode({ email: email.data, purpose: 'password_reset', userId: rows[0].id });
255 + if ('code' in issued) {
256 + const t = signPayload({ email: email.data, code: issued.code }, issued.minutes * 60);
257 + await sendMail({ to: email.data, ...passwordResetEmail({ code: issued.code, link: `${SITE()}/reset?t=${encodeURIComponent(t)}`, minutes: issued.minutes }) });
258 + }
259 + }
260 + // Same response whether or not the account exists.
261 + return { ok: true, message: 'If an account exists for that address, a reset code is on its way.' };
262 + });
263 +}
264 +
265 +export async function resetAction(_prev: ActionState, formData: FormData): Promise<ActionState> {
266 + return guarded(async () => {
267 + let email = String(formData.get('email') ?? '');
268 + let code = String(formData.get('code') ?? '');
269 + const t = String(formData.get('t') ?? '');
270 + if (t) {
271 + const p = verifyPayload<{ email: string; code: string }>(t);
272 + if (!p) return fail('This reset link expired. Request a new one.');
273 + email = p.email;
274 + code = p.code;
275 + }
276 + const e = emailSchema.safeParse(email);
277 + const c = codeSchema.safeParse(code);
278 + const pw = passwordSchema.safeParse(formData.get('password'));
279 + if (!e.success || !c.success || !pw.success) return fail('Check the highlighted fields.', { fieldErrors: { ...(e.success ? {} : { email: 'Invalid e-mail' }), ...(c.success ? {} : { code: 'Enter the 6-digit code' }), ...(pw.success ? {} : { password: pw.error.issues[0]?.message ?? 'Invalid password' }) } });
280 + await enforce(`reset:${e.data}`, 10, 900);
281 + const res = await verifyCode({ email: e.data, purpose: 'password_reset', code: c.data });
282 + if (!res.ok) return fail(res.reason === 'locked' ? 'Too many attempts. Request a new code.' : 'That code is not valid or expired.');
283 + const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, e.data)).limit(1);
284 + if (!rows[0]) return fail('Account not found.');
285 + await db().update(users).set({ passwordHash: await hashPassword(pw.data), passwordChangedAt: new Date(), emailVerifiedAt: new Date() }).where(eq(users.id, rows[0].id));
286 + await revokeAllSessions(rows[0].id, false);
287 + await revokeAllDevices(rows[0].id);
288 + await logLogin({ userId: rows[0].id, email: e.data, outcome: 'success', method: 'password_reset' });
289 + redirect('/login?reset=1');
290 + });
291 +}
added apps/web/src/lib/auth/codes.ts +53 −0
@@ -0,0 +1,53 @@
1 +import 'server-only';
2 +import { and, desc, eq, gt, isNull } from '@/lib/db';
3 +import { newId } from '@rareindex/shared';
4 +import { authCodes, db } from '@/lib/db';
5 +import { hmacToken, numericCode, safeEqual } from './crypto';
6 +
7 +export type CodePurpose = 'verify_email' | 'mfa_email' | 'password_reset' | 'change_email' | 'new_device';
8 +
9 +export const CODE_TTL_MIN: Record<CodePurpose, number> = { verify_email: 10, mfa_email: 10, password_reset: 30, change_email: 15, new_device: 10 };
10 +const MAX_ATTEMPTS = 5;
11 +const RESEND_COOLDOWN_S = 45;
12 +
13 +export interface IssuedCode {
14 + code: string;
15 + expiresAt: Date;
16 + minutes: number;
17 +}
18 +
19 +/** Issue a fresh code, invalidating previous unconsumed codes for the same (email, purpose). */
20 +export async function issueCode(params: { email: string; purpose: CodePurpose; userId?: string | null; payload?: Record<string, unknown> }): Promise<IssuedCode | { cooldownSeconds: number }> {
21 + const email = params.email.toLowerCase();
22 + const last = await db().select({ createdAt: authCodes.createdAt }).from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1);
23 + const lastAt = last[0]?.createdAt;
24 + if (lastAt && Date.now() - lastAt.getTime() < RESEND_COOLDOWN_S * 1000) {
25 + return { cooldownSeconds: Math.ceil((RESEND_COOLDOWN_S * 1000 - (Date.now() - lastAt.getTime())) / 1000) };
26 + }
27 + await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt)));
28 + const code = numericCode(6);
29 + const minutes = CODE_TTL_MIN[params.purpose];
30 + const expiresAt = new Date(Date.now() + minutes * 60_000);
31 + await db().insert(authCodes).values({ id: newId('event'), userId: params.userId ?? null, email, purpose: params.purpose, codeHash: hmacToken(code, `code:${params.purpose}`), expiresAt, payload: params.payload ?? {} });
32 + return { code, expiresAt, minutes };
33 +}
34 +
35 +export type VerifyOutcome = { ok: true; payload: Record<string, unknown>; userId: string | null } | { ok: false; reason: 'invalid' | 'expired' | 'locked' | 'missing' };
36 +
37 +/** Verify and consume a code. Counts attempts; locks after MAX_ATTEMPTS. */
38 +export async function verifyCode(params: { email: string; purpose: CodePurpose; code: string }): Promise<VerifyOutcome> {
39 + const email = params.email.toLowerCase();
40 + const clean = params.code.replace(/\D/g, '');
41 + const rows = await db().select().from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1);
42 + const row = rows[0];
43 + if (!row) return { ok: false, reason: 'missing' };
44 + if (row.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'locked' };
45 + if (row.expiresAt.getTime() < Date.now()) return { ok: false, reason: 'expired' };
46 + const good = clean.length === 6 && safeEqual(hmacToken(clean, `code:${params.purpose}`), row.codeHash);
47 + if (!good) {
48 + await db().update(authCodes).set({ attempts: row.attempts + 1 }).where(eq(authCodes.id, row.id));
49 + return { ok: false, reason: row.attempts + 1 >= MAX_ATTEMPTS ? 'locked' : 'invalid' };
50 + }
51 + await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.id, row.id), isNull(authCodes.consumedAt), gt(authCodes.expiresAt, new Date())));
52 + return { ok: true, payload: row.payload ?? {}, userId: row.userId };
53 +}
added apps/web/src/lib/auth/crypto.test.ts +61 −0
@@ -0,0 +1,61 @@
1 +import { beforeAll, describe, expect, it } from 'vitest';
2 +import { hashPassword, verifyPassword, encryptSecret, decryptSecret, signPayload, verifyPayload, numericCode, recoveryCode, normalizeRecoveryCode, hmacToken } from './crypto';
3 +import { newTotpSecret, verifyTotp, currentTotp, totpUri } from './totp';
4 +import { passwordStrength } from './password-strength';
5 +
6 +beforeAll(() => {
7 + process.env.SESSION_SECRET = 'test-secret-test-secret-test-secret';
8 +});
9 +
10 +describe('password hashing', () => {
11 + it('hashes and verifies with scrypt', async () => {
12 + const h = await hashPassword('correct horse battery');
13 + expect(h.startsWith('scrypt$')).toBe(true);
14 + expect(await verifyPassword('correct horse battery', h)).toBe(true);
15 + expect(await verifyPassword('wrong', h)).toBe(false);
16 + expect(await verifyPassword('x', null)).toBe(false);
17 + });
18 + it('strength meter', () => {
19 + expect(passwordStrength('short').score).toBe(0);
20 + expect(passwordStrength('password12').score).toBe(0);
21 + expect(passwordStrength('aaaaaaaaaaaa').score).toBe(0);
22 + expect(passwordStrength('Tr0ub4dor&3xyz!!').score).toBeGreaterThanOrEqual(2);
23 + expect(passwordStrength('correct horse battery staple hymn').score).toBe(4);
24 + });
25 +});
26 +
27 +describe('codes & secrets', () => {
28 + it('numeric codes are 6 digits and hmac is stable', () => {
29 + for (let i = 0; i < 50; i++) expect(numericCode(6)).toMatch(/^\d{6}$/);
30 + expect(hmacToken('a', 'x')).toBe(hmacToken('a', 'x'));
31 + expect(hmacToken('a', 'x')).not.toBe(hmacToken('a', 'y'));
32 + });
33 + it('recovery codes normalise', () => {
34 + const c = recoveryCode();
35 + expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
36 + expect(normalizeRecoveryCode(c.toLowerCase().replace('-', ' '))).toBe(c);
37 + });
38 + it('aes-gcm roundtrip', () => {
39 + const enc = encryptSecret('JBSWY3DPEHPK3PXP');
40 + expect(enc).not.toContain('JBSWY3DPEHPK3PXP');
41 + expect(decryptSecret(enc)).toBe('JBSWY3DPEHPK3PXP');
42 + });
43 + it('signed payloads expire and detect tampering', () => {
44 + const t = signPayload({ uid: 'usr_1', stage: 'mfa' }, 60);
45 + expect(verifyPayload<{ uid: string }>(t)?.uid).toBe('usr_1');
46 + expect(verifyPayload(`${t}x`)).toBeNull();
47 + const expired = signPayload({ uid: 'usr_1' }, -1);
48 + expect(verifyPayload(expired)).toBeNull();
49 + });
50 +});
51 +
52 +describe('totp', () => {
53 + it('generates and validates', () => {
54 + const s = newTotpSecret();
55 + expect(totpUri(s, 'a@b.c')).toContain('otpauth://totp/RareIndex:a%40b.c');
56 + const code = currentTotp(s);
57 + expect(verifyTotp(s, code)).not.toBeNull();
58 + expect(verifyTotp(s, '000000') === null || code === '000000').toBe(true);
59 + expect(verifyTotp(s, 'abc')).toBeNull();
60 + });
61 +});
added apps/web/src/lib/auth/crypto.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
2 +import { promisify } from 'node:util';
3 +
4 +const scrypt = promisify(scryptCb) as (password: string | Buffer, salt: Buffer, keylen: number, opts: { N: number; r: number; p: number; maxmem: number }) => Promise<Buffer>;
5 +
6 +/** Zero-native-dependency password hashing: scrypt (N=2^15, r=8, p=1), self-describing format. */
7 +const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 } as const;
8 +
9 +export async function hashPassword(password: string): Promise<string> {
10 + const salt = randomBytes(16);
11 + const key = await scrypt(password.normalize('NFKC'), salt, SCRYPT.keylen, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, maxmem: SCRYPT.maxmem });
12 + return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('base64url')}$${key.toString('base64url')}`;
13 +}
14 +
15 +export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
16 + if (!stored) return false;
17 + const [algo, n, r, p, saltB64, hashB64] = stored.split('$');
18 + if (algo !== 'scrypt' || !n || !r || !p || !saltB64 || !hashB64) return false;
19 + const expected = Buffer.from(hashB64, 'base64url');
20 + const key = await scrypt(password.normalize('NFKC'), Buffer.from(saltB64, 'base64url'), expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem });
21 + return key.length === expected.length && timingSafeEqual(key, expected);
22 +}
23 +
24 +function secret(): string {
25 + const s = process.env.SESSION_SECRET;
26 + if (!s || s.length < 16) throw new Error('SESSION_SECRET must be set (≥ 32 bytes recommended)');
27 + return s;
28 +}
29 +
30 +/** Keyed hash for opaque tokens (session ids, device tokens, API keys, one-time codes). */
31 +export function hmacToken(value: string, purpose = 'token'): string {
32 + return createHmac('sha256', secret()).update(`${purpose}:${value}`).digest('base64url');
33 +}
34 +
35 +export function sha256Hex(value: string): string {
36 + return createHash('sha256').update(value).digest('hex');
37 +}
38 +
39 +export function randomToken(bytes = 32): string {
40 + return randomBytes(bytes).toString('base64url');
41 +}
42 +
43 +/** Uniformly random n-digit numeric code (no modulo bias: randomInt). */
44 +export function numericCode(digits = 6): string {
45 + const max = 10 ** digits;
46 + return String(randomInt(0, max)).padStart(digits, '0');
47 +}
48 +
49 +/** Human-friendly recovery code: 10 chars, groups of 5, unambiguous alphabet. */
50 +export function recoveryCode(): string {
51 + const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
52 + let out = '';
53 + for (let i = 0; i < 10; i++) out += alphabet[randomInt(0, alphabet.length)];
54 + return `${out.slice(0, 5)}-${out.slice(5)}`;
55 +}
56 +
57 +export function normalizeRecoveryCode(input: string): string {
58 + return input.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{5})(.{5})$/, '$1-$2');
59 +}
60 +
61 +function aesKey(): Buffer {
62 + return createHash('sha256').update(`aes:${secret()}`).digest();
63 +}
64 +
65 +/** AES-256-GCM encryption for secrets at rest (TOTP seeds). */
66 +export function encryptSecret(plain: string): string {
67 + const iv = randomBytes(12);
68 + const cipher = createCipheriv('aes-256-gcm', aesKey(), iv);
69 + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
70 + const tag = cipher.getAuthTag();
71 + return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${enc.toString('base64url')}`;
72 +}
73 +
74 +export function decryptSecret(payload: string): string {
75 + const [v, ivB, tagB, encB] = payload.split('.');
76 + if (v !== 'v1' || !ivB || !tagB || !encB) throw new Error('bad secret payload');
77 + const decipher = createDecipheriv('aes-256-gcm', aesKey(), Buffer.from(ivB, 'base64url'));
78 + decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
79 + return Buffer.concat([decipher.update(Buffer.from(encB, 'base64url')), decipher.final()]).toString('utf8');
80 +}
81 +
82 +/** Signed, expiring compact token (pending-login state, reset links). Payload is JSON, base64url. */
83 +export function signPayload(payload: Record<string, unknown>, ttlSeconds: number): string {
84 + const body = Buffer.from(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds })).toString('base64url');
85 + const sig = createHmac('sha256', secret()).update(body).digest('base64url');
86 + return `${body}.${sig}`;
87 +}
88 +
89 +export function verifyPayload<T extends Record<string, unknown>>(token: string | null | undefined): (T & { exp: number }) | null {
90 + if (!token) return null;
91 + const [body, sig] = token.split('.');
92 + if (!body || !sig) return null;
93 + const expected = createHmac('sha256', secret()).update(body).digest('base64url');
94 + const a = Buffer.from(sig);
95 + const b = Buffer.from(expected);
96 + if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
97 + try {
98 + const parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T & { exp: number };
99 + if (!parsed.exp || parsed.exp < Math.floor(Date.now() / 1000)) return null;
100 + return parsed;
101 + } catch {
102 + return null;
103 + }
104 +}
105 +
106 +export function safeEqual(a: string, b: string): boolean {
107 + const ba = Buffer.from(a);
108 + const bb = Buffer.from(b);
109 + return ba.length === bb.length && timingSafeEqual(ba, bb);
110 +}
added apps/web/src/lib/auth/devices.ts +40 −0
@@ -0,0 +1,40 @@
1 +import 'server-only';
2 +import { cookies } from 'next/headers';
3 +import { and, eq, gt, isNull } from '@/lib/db';
4 +import { newId } from '@rareindex/shared';
5 +import { db, trustedDevices } from '@/lib/db';
6 +import { hmacToken, randomToken } from './crypto';
7 +import { clientInfo, deviceLabel } from './request';
8 +import { DEVICE_COOKIE } from './session';
9 +
10 +const TRUST_DAYS = 30;
11 +
12 +/** Is the current browser a trusted device for this user? */
13 +export async function isTrustedDevice(userId: string): Promise<boolean> {
14 + const raw = (await cookies()).get(DEVICE_COOKIE)?.value;
15 + if (!raw) return false;
16 + const rows = await db()
17 + .select({ id: trustedDevices.id })
18 + .from(trustedDevices)
19 + .where(and(eq(trustedDevices.userId, userId), eq(trustedDevices.tokenHash, hmacToken(raw, 'device')), isNull(trustedDevices.revokedAt), gt(trustedDevices.expiresAt, new Date())))
20 + .limit(1);
21 + if (!rows[0]) return false;
22 + void db().update(trustedDevices).set({ lastUsedAt: new Date() }).where(eq(trustedDevices.id, rows[0].id)).catch(() => {});
23 + return true;
24 +}
25 +
26 +export async function trustThisDevice(userId: string): Promise<void> {
27 + const raw = randomToken(32);
28 + const { ip, userAgent } = await clientInfo();
29 + const expiresAt = new Date(Date.now() + TRUST_DAYS * 86_400_000);
30 + await db().insert(trustedDevices).values({ id: newId('event'), userId, tokenHash: hmacToken(raw, 'device'), label: deviceLabel(userAgent), userAgent, ip, expiresAt, lastUsedAt: new Date() });
31 + (await cookies()).set(DEVICE_COOKIE, raw, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', expires: expiresAt });
32 +}
33 +
34 +export async function revokeDevice(userId: string, deviceId: string): Promise<void> {
35 + await db().update(trustedDevices).set({ revokedAt: new Date() }).where(and(eq(trustedDevices.id, deviceId), eq(trustedDevices.userId, userId)));
36 +}
37 +
38 +export async function revokeAllDevices(userId: string): Promise<void> {
39 + await db().update(trustedDevices).set({ revokedAt: new Date() }).where(and(eq(trustedDevices.userId, userId), isNull(trustedDevices.revokedAt)));
40 +}
added apps/web/src/lib/auth/password-strength.ts +28 −0
@@ -0,0 +1,28 @@
1 +/** Client-safe password strength estimate (no deps). Returns 0–4 and a reason. */
2 +const COMMON = new Set(['password', 'password1', 'qwerty', '123456', '12345678', 'iloveyou', 'letmein', 'welcome', 'admin', 'rareindex', 'collectibles', 'charizard', 'pokemon']);
3 +
4 +export interface Strength {
5 + score: 0 | 1 | 2 | 3 | 4;
6 + label: 'Too weak' | 'Weak' | 'Fair' | 'Strong' | 'Excellent';
7 + hint: string | null;
8 +}
9 +
10 +export function passwordStrength(pw: string, email?: string): Strength {
11 + const s = pw ?? '';
12 + if (s.length < 10) return { score: 0, label: 'Too weak', hint: 'Use at least 10 characters.' };
13 + const lower = s.toLowerCase();
14 + const stem = lower.replace(/[\d!@#$%^&*._-]+$/, '');
15 + if (COMMON.has(lower) || COMMON.has(stem) || /^(.)\1+$/.test(s) || /^(?:0123456789|1234567890|abcdefghij)/.test(lower)) return { score: 0, label: 'Too weak', hint: 'That password is too common.' };
16 + if (email && lower.includes(email.split('@')[0]!.toLowerCase()) && email.split('@')[0]!.length >= 4) return { score: 1, label: 'Weak', hint: 'Avoid using your e-mail in the password.' };
17 + let pool = 0;
18 + if (/[a-z]/.test(s)) pool += 26;
19 + if (/[A-Z]/.test(s)) pool += 26;
20 + if (/\d/.test(s)) pool += 10;
21 + if (/[^A-Za-z0-9]/.test(s)) pool += 33;
22 + const uniq = new Set(s).size;
23 + const entropy = Math.log2(pool || 1) * s.length * Math.min(1, uniq / 6);
24 + if (entropy < 45) return { score: 1, label: 'Weak', hint: 'Add more variety or length.' };
25 + if (entropy < 60) return { score: 2, label: 'Fair', hint: 'A passphrase of 4+ words is stronger.' };
26 + if (entropy < 80) return { score: 3, label: 'Strong', hint: null };
27 + return { score: 4, label: 'Excellent', hint: null };
28 +}
added apps/web/src/lib/auth/pending.ts +38 −0
@@ -0,0 +1,38 @@
1 +import 'server-only';
2 +import { cookies } from 'next/headers';
3 +import { signPayload, verifyPayload } from './crypto';
4 +
5 +export const PENDING_COOKIE = 'ri_pending';
6 +const TTL_S = 15 * 60;
7 +
8 +export type PendingStage = 'verify' | 'totp' | 'email_code';
9 +
10 +export interface Pending {
11 + uid: string;
12 + email: string;
13 + stage: PendingStage;
14 + next: string | null;
15 + /** true when the device was not trusted at password time (drives new-login e-mail) */
16 + newDevice: boolean;
17 +}
18 +
19 +export async function setPending(p: Pending): Promise<void> {
20 + (await cookies()).set(PENDING_COOKIE, signPayload(p as unknown as Record<string, unknown>, TTL_S), { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: TTL_S });
21 +}
22 +
23 +export async function getPending(): Promise<Pending | null> {
24 + const raw = (await cookies()).get(PENDING_COOKIE)?.value;
25 + const p = verifyPayload<Record<string, unknown>>(raw);
26 + if (!p || typeof p.uid !== 'string' || typeof p.email !== 'string' || typeof p.stage !== 'string') return null;
27 + return { uid: p.uid, email: p.email, stage: p.stage as PendingStage, next: typeof p.next === 'string' ? p.next : null, newDevice: Boolean(p.newDevice) };
28 +}
29 +
30 +export async function clearPending(): Promise<void> {
31 + (await cookies()).delete(PENDING_COOKIE);
32 +}
33 +
34 +/** Only allow same-origin relative redirects. */
35 +export function safeNext(next: string | null | undefined, fallback = '/collections'): string {
36 + if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return fallback;
37 + return next;
38 +}
added apps/web/src/lib/auth/rate-limit.ts +36 −0
@@ -0,0 +1,36 @@
1 +import 'server-only';
2 +import { sql } from '@/lib/db';
3 +import { db } from '@/lib/db';
4 +
5 +/**
6 + * Fixed-window counter stored in Postgres so limits hold across PM2 instances.
7 + * Returns whether the call is allowed and the seconds until the window resets.
8 + */
9 +export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<{ ok: boolean; remaining: number; retryAfterSeconds: number }> {
10 + const now = new Date();
11 + const resetAt = new Date(now.getTime() + windowSeconds * 1000);
12 + const nowIso = now.toISOString();
13 + const resetIso = resetAt.toISOString();
14 + const rows = (await db().execute(sql`
15 + insert into rate_limits (key, count, reset_at) values (${key}, 1, ${resetIso}::timestamptz)
16 + on conflict (key) do update set
17 + count = case when rate_limits.reset_at <= ${nowIso}::timestamptz then 1 else rate_limits.count + 1 end,
18 + reset_at = case when rate_limits.reset_at <= ${nowIso}::timestamptz then ${resetIso}::timestamptz else rate_limits.reset_at end
19 + returning count, reset_at
20 + `)) as unknown as Array<{ count: number; reset_at: Date | string }>;
21 + const row = rows[0]!;
22 + const count = Number(row.count);
23 + const reset = new Date(row.reset_at);
24 + return { ok: count <= limit, remaining: Math.max(0, limit - count), retryAfterSeconds: Math.max(1, Math.ceil((reset.getTime() - now.getTime()) / 1000)) };
25 +}
26 +
27 +export class RateLimited extends Error {
28 + constructor(public retryAfterSeconds: number) {
29 + super(`Too many attempts. Try again in ${retryAfterSeconds}s.`);
30 + }
31 +}
32 +
33 +export async function enforce(key: string, limit: number, windowSeconds: number): Promise<void> {
34 + const r = await rateLimit(key, limit, windowSeconds);
35 + if (!r.ok) throw new RateLimited(r.retryAfterSeconds);
36 +}
added apps/web/src/lib/auth/request.ts +18 −0
@@ -0,0 +1,18 @@
1 +import 'server-only';
2 +import { headers } from 'next/headers';
3 +
4 +export async function clientInfo(): Promise<{ ip: string | null; userAgent: string | null }> {
5 + const h = await headers();
6 + const xff = h.get('x-forwarded-for');
7 + const ip = (xff ? xff.split(',').pop()?.trim() : null) ?? h.get('x-real-ip') ?? null;
8 + const userAgent = h.get('user-agent')?.slice(0, 200) ?? null;
9 + return { ip, userAgent };
10 +}
11 +
12 +/** Short device label from a user agent, for security lists. */
13 +export function deviceLabel(ua: string | null | undefined): string {
14 + if (!ua) return 'Unknown device';
15 + const os = /iPhone|iPad/.test(ua) ? 'iOS' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'Unknown OS';
16 + const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /Firefox\//.test(ua) ? 'Firefox' : /curl|node|undici/i.test(ua) ? 'API client' : 'Browser';
17 + return `${browser} · ${os}`;
18 +}
added apps/web/src/lib/auth/session.ts +98 −0
@@ -0,0 +1,98 @@
1 +import 'server-only';
2 +import { cache } from 'react';
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +import { and, eq, gt, isNull } from '@/lib/db';
6 +import { newId } from '@rareindex/shared';
7 +import { db, sessions, users } from '@/lib/db';
8 +import { hmacToken, randomToken } from './crypto';
9 +import { clientInfo } from './request';
10 +
11 +export const SESSION_COOKIE = 'ri_session';
12 +export const DEVICE_COOKIE = 'ri_device';
13 +export const CURRENCY_COOKIE = 'ri_currency';
14 +const SESSION_DAYS = 30;
15 +
16 +export type SessionUser = typeof users.$inferSelect;
17 +
18 +const isProd = () => process.env.NODE_ENV === 'production';
19 +
20 +export async function createSession(userId: string): Promise<void> {
21 + const raw = randomToken(32);
22 + const { ip, userAgent } = await clientInfo();
23 + const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
24 + await db().insert(sessions).values({ id: hmacToken(raw, 'session'), userId, expiresAt, userAgent, ip, lastSeenAt: new Date() });
25 + const jar = await cookies();
26 + jar.set(SESSION_COOKIE, raw, { httpOnly: true, secure: isProd(), sameSite: 'lax', path: '/', expires: expiresAt });
27 +}
28 +
29 +export async function destroySession(): Promise<void> {
30 + const jar = await cookies();
31 + const raw = jar.get(SESSION_COOKIE)?.value;
32 + if (raw) await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, hmacToken(raw, 'session')));
33 + jar.delete(SESSION_COOKIE);
34 +}
35 +
36 +export async function revokeAllSessions(userId: string, keepCurrent = true): Promise<number> {
37 + const jar = await cookies();
38 + const raw = jar.get(SESSION_COOKIE)?.value;
39 + const keepId = keepCurrent && raw ? hmacToken(raw, 'session') : null;
40 + const rows = await db().select({ id: sessions.id }).from(sessions).where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
41 + let n = 0;
42 + for (const r of rows) {
43 + if (r.id === keepId) continue;
44 + await db().update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, r.id));
45 + n++;
46 + }
47 + return n;
48 +}
49 +
50 +export async function currentSessionId(): Promise<string | null> {
51 + const jar = await cookies();
52 + const raw = jar.get(SESSION_COOKIE)?.value;
53 + return raw ? hmacToken(raw, 'session') : null;
54 +}
55 +
56 +/** Resolve the signed-in user for this request (memoised per request). Null when anonymous. */
57 +export const getCurrentUser = cache(async (): Promise<SessionUser | null> => {
58 + const jar = await cookies();
59 + const raw = jar.get(SESSION_COOKIE)?.value;
60 + if (!raw) return null;
61 + const id = hmacToken(raw, 'session');
62 + const rows = await db()
63 + .select({ user: users, sessionId: sessions.id, lastSeenAt: sessions.lastSeenAt })
64 + .from(sessions)
65 + .innerJoin(users, eq(users.id, sessions.userId))
66 + .where(and(eq(sessions.id, id), isNull(sessions.revokedAt), gt(sessions.expiresAt, new Date())))
67 + .limit(1);
68 + const row = rows[0];
69 + if (!row) return null;
70 + if (row.user.deletedAt) return null;
71 + // touch at most every 10 minutes (cheap activity tracking)
72 + if (!row.lastSeenAt || Date.now() - row.lastSeenAt.getTime() > 600_000) {
73 + void db().update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, id)).catch(() => {});
74 + }
75 + return row.user;
76 +});
77 +
78 +export async function requireUser(next?: string): Promise<SessionUser> {
79 + const u = await getCurrentUser();
80 + if (!u) redirect(`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`);
81 + return u;
82 +}
83 +
84 +export function newUserId(): string {
85 + return newId('user');
86 +}
87 +
88 +export const DISPLAY_CURRENCIES = ['USD', 'CAD', 'EUR', 'GBP', 'JPY'] as const;
89 +export type DisplayCurrency = (typeof DISPLAY_CURRENCIES)[number];
90 +
91 +/** Display currency for this request: user preference → cookie → USD. */
92 +export async function getDisplayCurrency(): Promise<DisplayCurrency> {
93 + const u = await getCurrentUser();
94 + const pref = u?.displayCurrency;
95 + if (pref && (DISPLAY_CURRENCIES as readonly string[]).includes(pref)) return pref as DisplayCurrency;
96 + const c = (await cookies()).get(CURRENCY_COOKIE)?.value;
97 + return c && (DISPLAY_CURRENCIES as readonly string[]).includes(c) ? (c as DisplayCurrency) : 'USD';
98 +}
added apps/web/src/lib/auth/state.ts +20 −0
@@ -0,0 +1,20 @@
1 +/** Shared shape for server-action results consumed by useActionState forms. */
2 +export interface ActionState {
3 + ok?: boolean;
4 + error?: string | null;
5 + message?: string | null;
6 + fieldErrors?: Record<string, string>;
7 + /** arbitrary data returned to the form (e.g. cooldown seconds, QR data URL) */
8 + data?: Record<string, unknown>;
9 +}
10 +
11 +export const idle: ActionState = { ok: false, error: null };
12 +
13 +export function fieldErrorsFrom(issues: Array<{ path: PropertyKey[]; message: string }>): Record<string, string> {
14 + const out: Record<string, string> = {};
15 + for (const i of issues) {
16 + const k = String(i.path[0] ?? '_');
17 + if (!out[k]) out[k] = i.message;
18 + }
19 + return out;
20 +}
added apps/web/src/lib/auth/totp.ts +23 −0
@@ -0,0 +1,23 @@
1 +import * as OTPAuth from 'otpauth';
2 +
3 +const ISSUER = 'RareIndex';
4 +
5 +export function newTotpSecret(): string {
6 + return new OTPAuth.Secret({ size: 20 }).base32;
7 +}
8 +
9 +export function totpUri(secretB32: string, email: string): string {
10 + return new OTPAuth.TOTP({ issuer: ISSUER, label: email, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secretB32) }).toString();
11 +}
12 +
13 +/** Verify a 6-digit TOTP with ±1 step tolerance. Returns the matched step delta or null. */
14 +export function verifyTotp(secretB32: string, token: string, window = 1): number | null {
15 + const clean = token.replace(/\s+/g, '');
16 + if (!/^\d{6}$/.test(clean)) return null;
17 + const totp = new OTPAuth.TOTP({ issuer: ISSUER, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secretB32) });
18 + return totp.validate({ token: clean, window });
19 +}
20 +
21 +export function currentTotp(secretB32: string): string {
22 + return new OTPAuth.TOTP({ issuer: ISSUER, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secretB32) }).generate();
23 +}
added apps/web/src/lib/auth/validation.ts +31 −0
@@ -0,0 +1,31 @@
1 +import { z } from 'zod';
2 +import { passwordStrength } from './password-strength';
3 +
4 +export const emailSchema = z.string().trim().toLowerCase().email('Enter a valid e-mail address').max(254);
5 +
6 +export const passwordSchema = z
7 + .string()
8 + .min(10, 'Use at least 10 characters')
9 + .max(200, 'Password is too long')
10 + .refine((p) => passwordStrength(p).score >= 1, 'That password is too common or too simple');
11 +
12 +export const codeSchema = z
13 + .string()
14 + .transform((s) => s.replace(/\D/g, ''))
15 + .pipe(z.string().length(6, 'Enter the 6-digit code'));
16 +
17 +export const handleSchema = z
18 + .string()
19 + .trim()
20 + .toLowerCase()
21 + .min(3, 'At least 3 characters')
22 + .max(24, 'At most 24 characters')
23 + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, 'Letters, numbers and hyphens only');
24 +
25 +const RESERVED = new Set(['admin', 'rareindex', 'api', 'support', 'help', 'about', 'login', 'signup', 'account', 'collections', 'watchlist', 'root', 'system', 'null', 'undefined']);
26 +export function isReservedHandle(h: string): boolean {
27 + return RESERVED.has(h);
28 +}
29 +
30 +export const signupSchema = z.object({ email: emailSchema, password: passwordSchema, name: z.string().trim().max(80).optional().or(z.literal('')) });
31 +export const loginSchema = z.object({ email: emailSchema, password: z.string().min(1, 'Enter your password').max(200), remember: z.boolean().optional() });
modified apps/web/src/lib/db.ts +74 −2
@@ -1,6 +1,78 @@
1 1 import 'server-only';
2 −import { getDb } from '@rareindex/database';
2 +import { getDb, getSql, closeDb, schema } from '@rareindex/database';
3 3
4 4 /** Server-side database handle for server components, route handlers and server actions. */
5 5 export const db = () => getDb();
6 −export * from '@rareindex/database';
6 +export { getDb, getSql, closeDb, schema };
7 +export type { Database } from '@rareindex/database';
8 +export { sql, eq, and, or, desc, asc, gte, lte, lt, gt, inArray, isNull, isNotNull, ilike, count, sum, avg, max, min, ne, between, notInArray } from '@rareindex/database';
9 +
10 +// Tables are re-exported from the `schema` namespace explicitly: Turbopack cannot statically
11 +// enumerate `export *` chains that cross the package's `.js`-suffixed internal re-exports.
12 +export const {
13 + categories,
14 + brands,
15 + franchises,
16 + sets,
17 + graders,
18 + taxonomyProposals,
19 + sources,
20 + connectors,
21 + connectorRuns,
22 + connectorHealth,
23 + crawlState,
24 + costs,
25 + rawRecords,
26 + normalizedRecords,
27 + events,
28 + auditLog,
29 + assets,
30 + assetVariants,
31 + assetStats,
32 + variantStats,
33 + images,
34 + assetEmbeddings,
35 + populationReports,
36 + gradePremiums,
37 + sales,
38 + listings,
39 + listingEvents,
40 + auctions,
41 + auctionLots,
42 + priceObservations,
43 + crossListingGroups,
44 + fxRates,
45 + news,
46 + valuations,
47 + priceSnapshots,
48 + indices,
49 + indexValues,
50 + indexConstituents,
51 + categorySnapshots,
52 + radarFindings,
53 + correlations,
54 + benchmarks,
55 + users,
56 + sessions,
57 + collections,
58 + collectionItems,
59 + collectionSnapshots,
60 + watchlists,
61 + watchlistItems,
62 + alerts,
63 + alertEvents,
64 + apiKeys,
65 + apiUsage,
66 + assetViews,
67 + searchLog,
68 + authCodes,
69 + recoveryCodes,
70 + trustedDevices,
71 + loginEvents,
72 + rateLimits,
73 + notifications,
74 + savedSearches,
75 + priceTargets,
76 + uploads,
77 + userBadges,
78 +} = schema;
added docs/PENDING-SCHEMA-account.md +41 −0
@@ -0,0 +1,41 @@
1 +# Pending schema changes — member accounts (agent F1)
2 +
3 +Applied locally with `drizzle-kit push` on the `rareindex_account` database; **no migration file was
4 +generated** (per instructions). Generate one migration after merging (`pnpm db:generate`).
5 +
6 +## Modified tables (`packages/database/src/schema/users.ts`)
7 +
8 +- `users`: + `handle text unique`, `avatar_url text`, `bio text`, `mfa_enabled boolean not null default false`,
9 + `totp_secret_enc text` (AES-256-GCM, key derived from `SESSION_SECRET`), `always_ask_code boolean not null default true`,
10 + `password_changed_at timestamptz`, `pending_email text`, `deleted_at timestamptz`, `purge_after timestamptz`.
11 +- `sessions`: + `ip text`, `last_seen_at timestamptz`, `revoked_at timestamptz`.
12 +- `collections`: + `kind text not null default 'collection'` (collection | wishlist | vault | sold), `budget_usd numeric(18,4)`, `color text`.
13 +- `collection_items`: + `tags jsonb not null default '[]'`, `condition text`, `manual_value_usd numeric(18,4)`, `sold_at date`, `sold_price_usd numeric(18,4)`.
14 +- `watchlist_items`: + `label text`, `note text`, `target_price_usd numeric(18,4)`, `baseline_usd numeric(18,4)`.
15 +- `alerts`: + `name text`, `cooldown_minutes integer not null default 1440`, `trigger_count integer not null default 0`; `channel` now accepts `both`.
16 +
17 +## New tables (`packages/database/src/schema/account.ts`, exported from `schema/index.ts`)
18 +
19 +| table | purpose |
20 +|---|---|
21 +| `auth_codes` | hashed one-time codes: verify_email, mfa_email, password_reset, change_email, new_device (expiry, attempts, consumed_at, payload) |
22 +| `recovery_codes` | 10 single-use MFA recovery codes per user (hashed) |
23 +| `trusted_devices` | 30-day second-factor skip per browser (hashed token, label, ip, expiry, revoked_at) |
24 +| `login_events` | sign-in history (outcome, method, ip, user agent) |
25 +| `rate_limits` | fixed-window counters keyed by string (works across PM2 instances) |
26 +| `notifications` | in-app inbox (kind, title, body, href, payload, read_at, emailed_at) |
27 +| `saved_searches` | named search URLs, notify flag, last run/count |
28 +| `price_targets` | per-user buy/sell RIV targets with baseline, hit_at, notified_at |
29 +| `uploads` | member files (avatars, item photos) stored under `RI_DATA_DIR/uploads/<userId>/` |
30 +| `user_badges` | data-derived badges with evidence (recomputed daily) |
31 +
32 +## Notes for the merge
33 +
34 +- `apps/web/src/lib/db.ts` now re-exports tables explicitly from the `schema` namespace (Turbopack could
35 + not enumerate `export *` through the packages' `.js`-suffixed re-exports) and exports `Database` type.
36 +- `apps/web/next.config.ts`: `@rareindex/notify` added to `transpilePackages`; root `.env` loaded via
37 + `process.loadEnvFile`; `webpack.resolve.extensionAlias` so `next dev/build --webpack` resolves
38 + `./x.js` → `x.ts` inside workspace packages. **Turbopack cannot resolve those imports** — build with
39 + `next build --webpack` or drop the `.js` suffixes in packages (tsconfig `moduleResolution: bundler`).
40 +- `packages/notify/src/index.ts` uses explicit named exports for the same reason.
41 +- `workers/package.json`: + `@rareindex/database`, `@rareindex/notify`, `drizzle-orm`; `workers/tsconfig.json` added.
added packages/database/src/schema/account.ts +166 −0
@@ -0,0 +1,166 @@
1 +import { pgTable, text, integer, boolean, index, uniqueIndex, jsonb } from 'drizzle-orm/pg-core';
2 +import { createdAt, ts, money, jsonObject, textArray } from './_common.js';
3 +
4 +/**
5 + * Member-account tables (auth flows, security, personal tooling). Everything a member creates is
6 + * private by default (§176); public sharing is an explicit opt-in on the parent record.
7 + */
8 +
9 +/** One-time codes: e-mail verification, MFA e-mail fallback, password reset, e-mail change, new device. */
10 +export const authCodes = pgTable(
11 + 'auth_codes',
12 + {
13 + id: text('id').primaryKey(),
14 + userId: text('user_id'),
15 + email: text('email').notNull(),
16 + purpose: text('purpose').notNull(), // verify_email | mfa_email | password_reset | change_email | new_device
17 + codeHash: text('code_hash').notNull(),
18 + expiresAt: ts('expires_at').notNull(),
19 + attempts: integer('attempts').notNull().default(0),
20 + consumedAt: ts('consumed_at'),
21 + /** arbitrary payload, e.g. { newEmail } for change_email */
22 + payload: jsonObject<Record<string, unknown>>('payload'),
23 + createdAt: createdAt(),
24 + },
25 + (t) => [index('auth_codes_lookup_idx').on(t.email, t.purpose, t.createdAt)],
26 +);
27 +
28 +/** MFA recovery codes (hashed, single use). */
29 +export const recoveryCodes = pgTable(
30 + 'recovery_codes',
31 + {
32 + id: text('id').primaryKey(),
33 + userId: text('user_id').notNull(),
34 + codeHash: text('code_hash').notNull(),
35 + usedAt: ts('used_at'),
36 + createdAt: createdAt(),
37 + },
38 + (t) => [index('recovery_codes_user_idx').on(t.userId)],
39 +);
40 +
41 +/** Devices that may skip the second factor for 30 days. */
42 +export const trustedDevices = pgTable(
43 + 'trusted_devices',
44 + {
45 + id: text('id').primaryKey(),
46 + userId: text('user_id').notNull(),
47 + tokenHash: text('token_hash').notNull(),
48 + label: text('label'),
49 + userAgent: text('user_agent'),
50 + ip: text('ip'),
51 + createdAt: createdAt(),
52 + lastUsedAt: ts('last_used_at'),
53 + expiresAt: ts('expires_at').notNull(),
54 + revokedAt: ts('revoked_at'),
55 + },
56 + (t) => [index('trusted_devices_user_idx').on(t.userId), uniqueIndex('trusted_devices_token_uq').on(t.tokenHash)],
57 +);
58 +
59 +/** Login history shown in security settings. */
60 +export const loginEvents = pgTable(
61 + 'login_events',
62 + {
63 + id: text('id').primaryKey(),
64 + userId: text('user_id'),
65 + email: text('email'),
66 + ip: text('ip'),
67 + userAgent: text('user_agent'),
68 + outcome: text('outcome').notNull(), // success | bad_password | mfa_required | mfa_failed | locked | unknown_user
69 + method: text('method'), // password | totp | email_code | recovery_code | trusted_device
70 + createdAt: createdAt(),
71 + },
72 + (t) => [index('login_events_user_idx').on(t.userId, t.createdAt)],
73 +);
74 +
75 +/** Fixed-window rate limit counters (works across PM2 instances). */
76 +export const rateLimits = pgTable('rate_limits', {
77 + key: text('key').primaryKey(),
78 + count: integer('count').notNull().default(0),
79 + resetAt: ts('reset_at').notNull(),
80 +});
81 +
82 +/** In-app notification inbox (alerts, security, digests, system). */
83 +export const notifications = pgTable(
84 + 'notifications',
85 + {
86 + id: text('id').primaryKey(),
87 + userId: text('user_id').notNull(),
88 + kind: text('kind').notNull(), // alert | security | system | digest | target_hit
89 + title: text('title').notNull(),
90 + body: text('body'),
91 + href: text('href'),
92 + payload: jsonObject<Record<string, unknown>>('payload'),
93 + readAt: ts('read_at'),
94 + emailedAt: ts('emailed_at'),
95 + createdAt: createdAt(),
96 + },
97 + (t) => [index('notifications_user_idx').on(t.userId, t.createdAt), index('notifications_unread_idx').on(t.userId, t.readAt)],
98 +);
99 +
100 +/** Saved searches (Explore / Search filter URLs) — powers Deal Radar and digests. */
101 +export const savedSearches = pgTable(
102 + 'saved_searches',
103 + {
104 + id: text('id').primaryKey(),
105 + userId: text('user_id').notNull(),
106 + name: text('name').notNull(),
107 + url: text('url').notNull(),
108 + params: jsonObject<Record<string, unknown>>('params'),
109 + notify: boolean('notify').notNull().default(false),
110 + lastRunAt: ts('last_run_at'),
111 + lastCount: integer('last_count'),
112 + createdAt: createdAt(),
113 + },
114 + (t) => [index('saved_searches_user_idx').on(t.userId)],
115 +);
116 +
117 +/** Personal price targets with progress tracking. */
118 +export const priceTargets = pgTable(
119 + 'price_targets',
120 + {
121 + id: text('id').primaryKey(),
122 + userId: text('user_id').notNull(),
123 + assetId: text('asset_id').notNull(),
124 + variantId: text('variant_id'),
125 + direction: text('direction').notNull().default('above'), // above | below
126 + targetUsd: money('target_usd').notNull(),
127 + /** RIV in USD when the target was set — used for progress */
128 + baselineUsd: money('baseline_usd'),
129 + note: text('note'),
130 + hitAt: ts('hit_at'),
131 + notifiedAt: ts('notified_at'),
132 + createdAt: createdAt(),
133 + },
134 + (t) => [index('price_targets_user_idx').on(t.userId), index('price_targets_asset_idx').on(t.assetId, t.hitAt)],
135 +);
136 +
137 +/** Files uploaded by members (collection photos, avatars). Stored under RI_DATA_DIR/uploads. */
138 +export const uploads = pgTable(
139 + 'uploads',
140 + {
141 + id: text('id').primaryKey(),
142 + userId: text('user_id').notNull(),
143 + kind: text('kind').notNull(), // item_photo | avatar
144 + path: text('path').notNull(),
145 + mime: text('mime').notNull(),
146 + bytes: integer('bytes').notNull(),
147 + width: integer('width'),
148 + height: integer('height'),
149 + createdAt: createdAt(),
150 + },
151 + (t) => [index('uploads_user_idx').on(t.userId)],
152 +);
153 +
154 +/** Badges are computed from data, never hand-assigned (no fake badges). Cached here for profile pages. */
155 +export const userBadges = pgTable(
156 + 'user_badges',
157 + {
158 + userId: text('user_id').notNull(),
159 + badge: text('badge').notNull(),
160 + evidence: jsonb('evidence').$type<Record<string, unknown>>().notNull().default({}),
161 + awardedAt: createdAt(),
162 + },
163 + (t) => [uniqueIndex('user_badges_uq').on(t.userId, t.badge)],
164 +);
165 +
166 +export const collectionTags = textArray;
modified packages/database/src/schema/index.ts +1 −0
@@ -5,3 +5,4 @@ export * from './assets.js';
5 5 export * from './market.js';
6 6 export * from './analytics.js';
7 7 export * from './users.js';
8 +export * from './account.js';
modified packages/database/src/schema/users.ts +35 −1
@@ -12,6 +12,19 @@ export const users = pgTable('users', {
12 12 /** oauth/passkey providers linked (§175) */
13 13 providers: jsonb('providers').$type<Array<{ provider: string; subject: string }>>().notNull().default([]),
14 14 preferences: jsonObject<Record<string, unknown>>('preferences'),
15 + /** public collector handle (lowercase, unique) */
16 + handle: text('handle').unique(),
17 + avatarUrl: text('avatar_url'),
18 + bio: text('bio'),
19 + mfaEnabled: boolean('mfa_enabled').notNull().default(false),
20 + /** TOTP secret, encrypted at rest with SESSION_SECRET-derived key */
21 + totpSecretEnc: text('totp_secret_enc'),
22 + /** ask an e-mail code on every new device even without TOTP */
23 + alwaysAskCode: boolean('always_ask_code').notNull().default(true),
24 + passwordChangedAt: ts('password_changed_at'),
25 + pendingEmail: text('pending_email'),
26 + deletedAt: ts('deleted_at'),
27 + purgeAfter: ts('purge_after'),
15 28 createdAt: createdAt(),
16 29 lastLoginAt: ts('last_login_at'),
17 30 });
@@ -23,6 +36,9 @@ export const sessions = pgTable(
23 36 userId: text('user_id').notNull(),
24 37 expiresAt: ts('expires_at').notNull(),
25 38 userAgent: text('user_agent'),
39 + ip: text('ip'),
40 + lastSeenAt: ts('last_seen_at'),
41 + revokedAt: ts('revoked_at'),
26 42 createdAt: createdAt(),
27 43 },
28 44 (t) => [index('sessions_user_idx').on(t.userId)],
@@ -37,6 +53,10 @@ export const collections = pgTable(
37 53 description: text('description'),
38 54 isPublic: boolean('is_public').notNull().default(false),
39 55 publicSlug: text('public_slug'),
56 + /** default | wishlist | vault … free-form */
57 + kind: text('kind').notNull().default('collection'),
58 + budgetUsd: money('budget_usd'),
59 + color: text('color'),
40 60 createdAt: createdAt(),
41 61 updatedAt: updatedAt(),
42 62 },
@@ -62,6 +82,12 @@ export const collectionItems = pgTable(
62 82 serial: text('serial'),
63 83 photos: jsonb('photos').$type<string[]>().notNull().default([]),
64 84 notes: text('notes'),
85 + tags: jsonb('tags').$type<string[]>().notNull().default([]),
86 + condition: text('condition'),
87 + /** member-entered fallback value when RareIndex has no valuation yet (never shown as RIV) */
88 + manualValueUsd: money('manual_value_usd'),
89 + soldAt: date('sold_at'),
90 + soldPriceUsd: money('sold_price_usd'),
65 91 createdAt: createdAt(),
66 92 updatedAt: updatedAt(),
67 93 },
@@ -99,6 +125,11 @@ export const watchlistItems = pgTable(
99 125 watchlistId: text('watchlist_id').notNull(),
100 126 targetType: text('target_type').notNull(), // asset | category | brand | set | source | auction
101 127 targetId: text('target_id').notNull(),
128 + label: text('label'),
129 + note: text('note'),
130 + targetPriceUsd: money('target_price_usd'),
131 + /** RIV when added, to show change since watching */
132 + baselineUsd: money('baseline_usd'),
102 133 createdAt: createdAt(),
103 134 },
104 135 (t) => [uniqueIndex('watchlist_items_uq').on(t.watchlistId, t.targetType, t.targetId)],
@@ -115,9 +146,12 @@ export const alerts = pgTable(
115 146 threshold: money('threshold'),
116 147 currency: text('currency'),
117 148 params: jsonObject<Record<string, unknown>>('params'),
118 − channel: text('channel').notNull().default('inapp'), // inapp | email
149 + channel: text('channel').notNull().default('inapp'), // inapp | email | both
119 150 active: boolean('active').notNull().default(true),
151 + name: text('name'),
152 + cooldownMinutes: integer('cooldown_minutes').notNull().default(1440),
120 153 lastTriggeredAt: ts('last_triggered_at'),
154 + triggerCount: integer('trigger_count').notNull().default(0),
121 155 createdAt: createdAt(),
122 156 },
123 157 (t) => [index('alerts_user_idx').on(t.userId), index('alerts_target_idx').on(t.targetType, t.targetId, t.active)],
added packages/notify/package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "@rareindex/notify",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "exports": {
7 + ".": {
8 + "types": "./src/index.ts",
9 + "default": "./src/index.ts"
10 + }
11 + },
12 + "scripts": {
13 + "build": "tsc -p tsconfig.json --noEmit",
14 + "typecheck": "tsc -p tsconfig.json --noEmit",
15 + "test": "vitest run --passWithNoTests"
16 + },
17 + "dependencies": {
18 + "@rareindex/shared": "workspace:*"
19 + },
20 + "devDependencies": {
21 + "@types/node": "^24.0.0",
22 + "typescript": "^5.9.3",
23 + "vitest": "^3.2.0"
24 + }
25 +}
added packages/notify/src/index.ts +5 −0
@@ -0,0 +1,5 @@
1 +// Explicit named exports (Turbopack cannot statically follow `export *` through `.js`-suffixed TS re-exports).
2 +export { sendMail, resolveTransport } from './transport.js';
3 +export type { Mail, SendResult } from './transport.js';
4 +export { verificationEmail, mfaCodeEmail, newLoginEmail, passwordResetEmail, changeEmailEmail, alertEmail, digestEmail, accountDeletionEmail } from './templates.js';
5 +export type { Rendered, AlertMail, DigestSection } from './templates.js';
added packages/notify/src/templates.test.ts +23 −0
@@ -0,0 +1,23 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { verificationEmail, alertEmail, digestEmail } from './templates.js';
3 +import { resolveTransport, sendMail } from './transport.js';
4 +
5 +describe('templates', () => {
6 + it('render code and escape html', () => {
7 + const m = verificationEmail({ code: '123456', minutes: 10 });
8 + expect(m.subject).toContain('123456');
9 + expect(m.html).toContain('123456');
10 + expect(m.text).toContain('10 minutes');
11 + const a = alertEmail({ title: '<b>x</b>', body: 'b', href: 'https://x', facts: [['RIV', '$1,000']] });
12 + expect(a.html).not.toContain('<b>x</b>');
13 + expect(a.html).toContain('&lt;b&gt;x&lt;/b&gt;');
14 + const d = digestEmail({ name: null, period: 'weekly', sections: [{ heading: 'Movers', rows: [{ label: 'A', value: '$1', delta: '-2%' }] }] });
15 + expect(d.text).toContain('MOVERS');
16 + });
17 + it('console transport works without keys', async () => {
18 + process.env.EMAIL_TRANSPORT = 'console';
19 + expect(resolveTransport()).toBe('console');
20 + const r = await sendMail({ to: 'a@b.c', subject: 's', html: '<p>h</p>', text: 't' });
21 + expect(r.ok).toBe(true);
22 + });
23 +});
added packages/notify/src/templates.ts +129 −0
@@ -0,0 +1,129 @@
1 +/**
2 + * Branded transactional templates: text + HTML for every message. Neutral palette, one accent,
3 + * no images (deliverability), numbers always with context. Keep copy short and factual.
4 + */
5 +
6 +const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');
7 +
8 +function esc(s: string): string {
9 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10 +}
11 +
12 +export interface Rendered {
13 + subject: string;
14 + html: string;
15 + text: string;
16 +}
17 +
18 +function layout(opts: { title: string; preheader?: string; blocks: string[]; footer?: string }): string {
19 + const pre = opts.preheader ? `<span style="display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden">${esc(opts.preheader)}</span>` : '';
20 + return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${esc(opts.title)}</title></head>
21 +<body style="margin:0;padding:0;background:#f4f4f3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;color:#0b0b0c">
22 +${pre}
23 +<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f4f3;padding:32px 12px"><tr><td align="center">
24 +<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border:1px solid #e4e4e2;border-radius:8px">
25 +<tr><td style="padding:22px 28px 0 28px">
26 + <table role="presentation" cellspacing="0" cellpadding="0"><tr>
27 + <td style="width:24px;height:24px;background:#0b0b0c;color:#fff;border-radius:4px;text-align:center;font-weight:700;font-size:12px;line-height:24px">R</td>
28 + <td style="padding-left:8px;font-size:15px;font-weight:600;letter-spacing:-0.01em">Rare<span style="color:#55555b">Index</span></td>
29 + </tr></table>
30 +</td></tr>
31 +<tr><td style="padding:20px 28px 8px 28px;font-size:20px;font-weight:600;letter-spacing:-0.015em">${esc(opts.title)}</td></tr>
32 +${opts.blocks.map((b) => `<tr><td style="padding:6px 28px;font-size:14px;line-height:22px;color:#2a2a2e">${b}</td></tr>`).join('')}
33 +<tr><td style="padding:22px 28px 26px 28px;font-size:12px;line-height:18px;color:#8a8a91;border-top:1px solid #e4e4e2">${opts.footer ?? `Sent by RareIndex · <a href="${SITE()}" style="color:#8a8a91">rareindex.io</a>. Valuations are estimates derived from observed public data; listing prices are not confirmed transactions.`}</td></tr>
34 +</table>
35 +<p style="font-size:11px;color:#8a8a91;margin:14px 0 0 0">RareIndex does not authenticate items. Manage e-mail preferences in <a href="${SITE()}/account/notifications" style="color:#8a8a91">account settings</a>.</p>
36 +</td></tr></table></body></html>`;
37 +}
38 +
39 +function codeBlock(code: string): string {
40 + return `<div style="margin:10px 0 4px 0;padding:14px 18px;background:#fafaf9;border:1px solid #e4e4e2;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;letter-spacing:0.28em;font-weight:600;text-align:center">${esc(code)}</div>`;
41 +}
42 +
43 +function button(href: string, label: string): string {
44 + return `<a href="${esc(href)}" style="display:inline-block;margin:8px 0;padding:10px 16px;background:#0b0b0c;color:#ffffff;text-decoration:none;border-radius:6px;font-size:13px;font-weight:600">${esc(label)}</a>`;
45 +}
46 +
47 +export function verificationEmail(p: { code: string; minutes: number }): Rendered {
48 + const subject = `${p.code} is your RareIndex verification code`;
49 + return {
50 + subject,
51 + text: `Your RareIndex verification code is ${p.code}. It expires in ${p.minutes} minutes.\n\nIf you did not create an account, ignore this message.`,
52 + html: layout({ title: 'Confirm your e-mail', preheader: `Code ${p.code} · expires in ${p.minutes} min`, blocks: [`Enter this code to activate your RareIndex account. It expires in <strong>${p.minutes} minutes</strong>.`, codeBlock(p.code), `If you did not create an account, you can ignore this message.`] }),
53 + };
54 +}
55 +
56 +export function mfaCodeEmail(p: { code: string; minutes: number; ip?: string | null; userAgent?: string | null }): Rendered {
57 + const ctx = [p.ip ? `IP ${esc(p.ip)}` : null, p.userAgent ? esc(p.userAgent.slice(0, 80)) : null].filter(Boolean).join(' · ');
58 + return {
59 + subject: `${p.code} is your RareIndex sign-in code`,
60 + text: `Your sign-in code is ${p.code} (valid ${p.minutes} minutes).${ctx ? `\nSign-in attempt: ${ctx}` : ''}\n\nIf this was not you, change your password immediately.`,
61 + html: layout({ title: 'Your sign-in code', preheader: `Code ${p.code}`, blocks: [`Use this code to finish signing in. Valid for <strong>${p.minutes} minutes</strong>.`, codeBlock(p.code), ctx ? `<span style="color:#55555b;font-size:12px">Attempt: ${ctx}</span>` : '', `If this was not you, <a href="${SITE()}/account/security">secure your account</a> now.`] }),
62 + };
63 +}
64 +
65 +export function newLoginEmail(p: { when: Date; ip?: string | null; userAgent?: string | null; method: string }): Rendered {
66 + const line = `${p.when.toUTCString()}${p.ip ? ` · IP ${p.ip}` : ''}${p.userAgent ? ` · ${p.userAgent.slice(0, 90)}` : ''} · ${p.method}`;
67 + return {
68 + subject: 'New sign-in to your RareIndex account',
69 + text: `A new sign-in to your RareIndex account was completed.\n${line}\n\nIf this was not you, reset your password and review trusted devices: ${SITE()}/account/security`,
70 + html: layout({ title: 'New sign-in', blocks: [`A new sign-in to your account was completed from a device we had not seen before.`, `<span style="color:#55555b;font-size:12px">${esc(line)}</span>`, `If this was not you, reset your password and revoke devices.`, button(`${SITE()}/account/security`, 'Review security')] }),
71 + };
72 +}
73 +
74 +export function passwordResetEmail(p: { code: string; link: string; minutes: number }): Rendered {
75 + return {
76 + subject: 'Reset your RareIndex password',
77 + text: `Reset your password: ${p.link}\n\nOr enter code ${p.code}. The link and code expire in ${p.minutes} minutes. If you did not request this, ignore this message.`,
78 + html: layout({ title: 'Reset your password', preheader: `Code ${p.code}`, blocks: [`Click the button or enter the code below. Expires in <strong>${p.minutes} minutes</strong>.`, button(p.link, 'Choose a new password'), codeBlock(p.code), `If you did not request a reset, nothing changes.`] }),
79 + };
80 +}
81 +
82 +export function changeEmailEmail(p: { code: string; minutes: number; newEmail: string }): Rendered {
83 + return {
84 + subject: `${p.code} — confirm your new RareIndex e-mail`,
85 + text: `Confirm ${p.newEmail} as your new e-mail with code ${p.code} (valid ${p.minutes} minutes).`,
86 + html: layout({ title: 'Confirm your new e-mail', blocks: [`Enter this code to switch your account to <strong>${esc(p.newEmail)}</strong>.`, codeBlock(p.code)] }),
87 + };
88 +}
89 +
90 +export interface AlertMail {
91 + title: string;
92 + body: string;
93 + href: string;
94 + facts?: Array<[string, string]>;
95 +}
96 +
97 +export function alertEmail(p: AlertMail): Rendered {
98 + const facts = p.facts?.length ? `<table role="presentation" cellspacing="0" cellpadding="0" style="margin:8px 0;font-size:13px">${p.facts.map(([k, v]) => `<tr><td style="padding:3px 14px 3px 0;color:#55555b">${esc(k)}</td><td style="padding:3px 0;font-variant-numeric:tabular-nums;font-weight:600">${esc(v)}</td></tr>`).join('')}</table>` : '';
99 + return {
100 + subject: `Alert · ${p.title}`,
101 + text: `${p.title}\n${p.body}\n${(p.facts ?? []).map(([k, v]) => `${k}: ${v}`).join('\n')}\n\n${p.href}`,
102 + html: layout({ title: p.title, preheader: p.body, blocks: [esc(p.body), facts, button(p.href, 'Open in RareIndex')] }),
103 + };
104 +}
105 +
106 +export interface DigestSection {
107 + heading: string;
108 + rows: Array<{ label: string; value: string; delta?: string; href?: string }>;
109 +}
110 +
111 +export function digestEmail(p: { name: string | null; period: string; sections: DigestSection[]; portfolio?: { valueUsd: string; change: string } | null }): Rendered {
112 + const blocks: string[] = [`${p.name ? `Hi ${esc(p.name)}, here` : 'Here'} is your ${esc(p.period)} RareIndex digest.`];
113 + if (p.portfolio) blocks.push(`<div style="padding:12px 16px;background:#fafaf9;border:1px solid #e4e4e2;border-radius:6px"><div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#8a8a91">Collection value</div><div style="font-size:22px;font-weight:600">${esc(p.portfolio.valueUsd)} <span style="font-size:13px;font-weight:500;color:#55555b">${esc(p.portfolio.change)} this period</span></div></div>`);
114 + for (const s of p.sections) {
115 + if (!s.rows.length) continue;
116 + blocks.push(`<div style="margin-top:10px;font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:#8a8a91">${esc(s.heading)}</div><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="font-size:13px">${s.rows.map((r) => `<tr><td style="padding:5px 0;border-bottom:1px solid #eeeeec">${r.href ? `<a href="${esc(r.href)}" style="color:#0b0b0c;text-decoration:none">${esc(r.label)}</a>` : esc(r.label)}</td><td align="right" style="padding:5px 0;border-bottom:1px solid #eeeeec;font-variant-numeric:tabular-nums;font-weight:600">${esc(r.value)}</td><td align="right" style="padding:5px 0 5px 10px;border-bottom:1px solid #eeeeec;font-variant-numeric:tabular-nums;color:${r.delta?.startsWith('-') ? '#c8262c' : '#0f8a4a'}">${esc(r.delta ?? '')}</td></tr>`).join('')}</table>`);
117 + }
118 + blocks.push(button(`${SITE()}/collections`, 'Open my RareIndex'));
119 + const text = [`Your ${p.period} RareIndex digest`, p.portfolio ? `Collection value ${p.portfolio.valueUsd} (${p.portfolio.change})` : '', ...p.sections.flatMap((s) => [``, s.heading.toUpperCase(), ...s.rows.map((r) => `- ${r.label}: ${r.value} ${r.delta ?? ''}`)])].join('\n');
120 + return { subject: `Your ${p.period} RareIndex digest`, text, html: layout({ title: `${p.period[0]?.toUpperCase()}${p.period.slice(1)} digest`, blocks }) };
121 +}
122 +
123 +export function accountDeletionEmail(p: { purgeAt: Date }): Rendered {
124 + return {
125 + subject: 'Your RareIndex account is scheduled for deletion',
126 + text: `Your account will be permanently deleted on ${p.purgeAt.toUTCString()}. Sign in before then to cancel.`,
127 + html: layout({ title: 'Account deletion scheduled', blocks: [`Your account and all private data will be permanently deleted on <strong>${esc(p.purgeAt.toUTCString())}</strong>.`, `Signing in before that date cancels the deletion.`, button(`${SITE()}/login`, 'Sign in to keep my account')] }),
128 + };
129 +}
added packages/notify/src/transport.ts +65 −0
@@ -0,0 +1,65 @@
1 +import { logger } from '@rareindex/shared';
2 +
3 +export interface Mail {
4 + to: string | string[];
5 + subject: string;
6 + html: string;
7 + text: string;
8 + replyTo?: string;
9 + tags?: Array<{ name: string; value: string }>;
10 + /** idempotency key to avoid double-sends on retries */
11 + idempotencyKey?: string;
12 +}
13 +
14 +export interface SendResult {
15 + ok: boolean;
16 + id: string | null;
17 + transport: 'resend' | 'console' | 'noop';
18 + error: string | null;
19 +}
20 +
21 +/**
22 + * Transactional e-mail transport. `EMAIL_TRANSPORT=console` logs mails instead of sending (used in
23 + * development and tests); `resend` (default when RESEND_API_KEY is set) posts to the Resend API.
24 + * The API key is read server-side only and never logged.
25 + */
26 +export function resolveTransport(): 'resend' | 'console' | 'noop' {
27 + const forced = process.env.EMAIL_TRANSPORT;
28 + if (forced === 'console' || forced === 'noop' || forced === 'resend') return forced;
29 + return process.env.RESEND_API_KEY ? 'resend' : 'console';
30 +}
31 +
32 +export async function sendMail(mail: Mail): Promise<SendResult> {
33 + const transport = resolveTransport();
34 + const from = process.env.EMAIL_FROM ?? 'RareIndex <no-reply@rareindex.io>';
35 + const to = Array.isArray(mail.to) ? mail.to : [mail.to];
36 + if (transport === 'console') {
37 + // eslint-disable-next-line no-console
38 + console.log(`\n──── MAIL (console transport) ────\nTo: ${to.join(', ')}\nSubject: ${mail.subject}\n\n${mail.text}\n──────────────────────────────────\n`);
39 + return { ok: true, id: `console_${Date.now()}`, transport, error: null };
40 + }
41 + if (transport === 'noop') return { ok: true, id: null, transport, error: null };
42 + const key = process.env.RESEND_API_KEY;
43 + if (!key) return { ok: false, id: null, transport, error: 'RESEND_API_KEY missing' };
44 + try {
45 + const res = await fetch('https://api.resend.com/emails', {
46 + method: 'POST',
47 + headers: {
48 + authorization: `Bearer ${key}`,
49 + 'content-type': 'application/json',
50 + ...(mail.idempotencyKey ? { 'idempotency-key': mail.idempotencyKey } : {}),
51 + },
52 + body: JSON.stringify({ from, to, subject: mail.subject, html: mail.html, text: mail.text, reply_to: mail.replyTo, tags: mail.tags }),
53 + });
54 + const body = (await res.json().catch(() => ({}))) as { id?: string; message?: string; name?: string };
55 + if (!res.ok) {
56 + logger.error({ status: res.status, err: body.message ?? body.name }, 'resend send failed');
57 + return { ok: false, id: null, transport, error: body.message ?? `HTTP ${res.status}` };
58 + }
59 + return { ok: true, id: body.id ?? null, transport, error: null };
60 + } catch (err) {
61 + const msg = err instanceof Error ? err.message : String(err);
62 + logger.error({ err: msg }, 'resend send threw');
63 + return { ok: false, id: null, transport, error: msg };
64 + }
65 +}
added packages/notify/tsconfig.json +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "rootDir": "src",
5 + "outDir": "dist",
6 + "noEmit": true
7 + },
8 + "include": ["src"]
9 +}
added packages/notify/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { defineConfig } from 'vitest/config';
2 +
3 +export default defineConfig({
4 + test: { include: ['src/**/*.test.ts'] },
5 +});
modified pnpm-lock.yaml +243 −0
@@ -45,6 +45,9 @@ importers:
45 45 '@rareindex/database':
46 46 specifier: workspace:*
47 47 version: link:../../packages/database
48 + '@rareindex/notify':
49 + specifier: workspace:*
50 + version: link:../../packages/notify
48 51 '@rareindex/shared':
49 52 specifier: workspace:*
50 53 version: link:../../packages/shared
@@ -60,6 +63,12 @@ importers:
60 63 next:
61 64 specifier: 16.3.4
62 65 version: 16.3.4(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
66 + otpauth:
67 + specifier: ^9.5.2
68 + version: 9.5.2
69 + qrcode:
70 + specifier: ^1.5.4
71 + version: 1.5.4
63 72 react:
64 73 specifier: 19.2.8
65 74 version: 19.2.8
@@ -79,6 +88,9 @@ importers:
79 88 '@types/node':
80 89 specifier: ^24.0.0
81 90 version: 24.13.3
91 + '@types/qrcode':
92 + specifier: ^1.5.6
93 + version: 1.5.6
82 94 '@types/react':
83 95 specifier: ^19
84 96 version: 19.2.18
@@ -182,6 +194,22 @@ importers:
182 194 specifier: ^3.2.0
183 195 version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
184 196
197 + packages/notify:
198 + dependencies:
199 + '@rareindex/shared':
200 + specifier: workspace:*
201 + version: link:../shared
202 + devDependencies:
203 + '@types/node':
204 + specifier: ^24.0.0
205 + version: 24.13.3
206 + typescript:
207 + specifier: ^5.9.3
208 + version: 5.9.3
209 + vitest:
210 + specifier: ^3.2.0
211 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
212 +
185 213 packages/shared:
186 214 dependencies:
187 215 pino:
@@ -222,9 +250,18 @@ importers:
222 250
223 251 workers:
224 252 dependencies:
253 + '@rareindex/database':
254 + specifier: workspace:*
255 + version: link:../packages/database
256 + '@rareindex/notify':
257 + specifier: workspace:*
258 + version: link:../packages/notify
225 259 '@rareindex/shared':
226 260 specifier: workspace:*
227 261 version: link:../packages/shared
262 + drizzle-orm:
263 + specifier: ^0.45.0
264 + version: 0.45.2(postgres@3.4.9)
228 265 devDependencies:
229 266 '@types/node':
230 267 specifier: ^24.0.0
@@ -1090,6 +1127,10 @@ packages:
1090 1127 cpu: [x64]
1091 1128 os: [win32]
1092 1129
1130 + '@noble/hashes@2.4.0':
1131 + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
1132 + engines: {node: '>= 20.19.0'}
1133 +
1093 1134 '@nodelib/fs.scandir@2.1.5':
1094 1135 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
1095 1136 engines: {node: '>= 8'}
@@ -1374,6 +1415,9 @@ packages:
1374 1415 '@types/node@24.13.3':
1375 1416 resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
1376 1417
1418 + '@types/qrcode@1.5.6':
1419 + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
1420 +
1377 1421 '@types/react-dom@19.2.7':
1378 1422 resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
1379 1423 peerDependencies:
@@ -1603,6 +1647,10 @@ packages:
1603 1647 ajv@6.15.0:
1604 1648 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
1605 1649
1650 + ansi-regex@5.0.1:
1651 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
1652 + engines: {node: '>=8'}
1653 +
1606 1654 ansi-styles@4.3.0:
1607 1655 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
1608 1656 engines: {node: '>=8'}
@@ -1727,6 +1775,10 @@ packages:
1727 1775 resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
1728 1776 engines: {node: '>=6'}
1729 1777
1778 + camelcase@5.3.1:
1779 + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
1780 + engines: {node: '>=6'}
1781 +
1730 1782 caniuse-lite@1.0.30001810:
1731 1783 resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
1732 1784
@@ -1752,6 +1804,9 @@ packages:
1752 1804 client-only@0.0.1:
1753 1805 resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
1754 1806
1807 + cliui@6.0.0:
1808 + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
1809 +
1755 1810 color-convert@2.0.1:
1756 1811 resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
1757 1812 engines: {node: '>=7.0.0'}
@@ -1811,6 +1866,10 @@ packages:
1811 1866 supports-color:
1812 1867 optional: true
1813 1868
1869 + decamelize@1.2.0:
1870 + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
1871 + engines: {node: '>=0.10.0'}
1872 +
1814 1873 deep-eql@5.0.2:
1815 1874 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
1816 1875 engines: {node: '>=6'}
@@ -1830,6 +1889,9 @@ packages:
1830 1889 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1831 1890 engines: {node: '>=8'}
1832 1891
1892 + dijkstrajs@1.0.3:
1893 + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
1894 +
1833 1895 doctrine@2.1.0:
1834 1896 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
1835 1897 engines: {node: '>=0.10.0'}
@@ -1950,6 +2012,9 @@ packages:
1950 2012 electron-to-chromium@1.5.422:
1951 2013 resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==}
1952 2014
2015 + emoji-regex@8.0.0:
2016 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
2017 +
1953 2018 emoji-regex@9.2.2:
1954 2019 resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
1955 2020
@@ -2195,6 +2260,10 @@ packages:
2195 2260 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
2196 2261 engines: {node: '>=8'}
2197 2262
2263 + find-up@4.1.0:
2264 + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
2265 + engines: {node: '>=8'}
2266 +
2198 2267 find-up@5.0.0:
2199 2268 resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
2200 2269 engines: {node: '>=10'}
@@ -2233,6 +2302,10 @@ packages:
2233 2302 resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
2234 2303 engines: {node: '>=6.9.0'}
2235 2304
2305 + get-caller-file@2.0.5:
2306 + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
2307 + engines: {node: 6.* || 8.* || >= 10.*}
2308 +
2236 2309 get-intrinsic@1.3.0:
2237 2310 resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
2238 2311 engines: {node: '>= 0.4'}
@@ -2382,6 +2455,10 @@ packages:
2382 2455 resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
2383 2456 engines: {node: '>= 0.4'}
2384 2457
2458 + is-fullwidth-code-point@3.0.0:
2459 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
2460 + engines: {node: '>=8'}
2461 +
2385 2462 is-generator-function@1.1.2:
2386 2463 resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
2387 2464 engines: {node: '>= 0.4'}
@@ -2581,6 +2658,10 @@ packages:
2581 2658 resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
2582 2659 engines: {node: '>= 12.0.0'}
2583 2660
2661 + locate-path@5.0.0:
2662 + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
2663 + engines: {node: '>=8'}
2664 +
2584 2665 locate-path@6.0.0:
2585 2666 resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
2586 2667 engines: {node: '>=10'}
@@ -2716,18 +2797,33 @@ packages:
2716 2797 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
2717 2798 engines: {node: '>= 0.8.0'}
2718 2799
2800 + otpauth@9.5.2:
2801 + resolution: {integrity: sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==}
2802 +
2719 2803 own-keys@1.0.2:
2720 2804 resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
2721 2805 engines: {node: '>= 0.4'}
2722 2806
2807 + p-limit@2.3.0:
2808 + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
2809 + engines: {node: '>=6'}
2810 +
2723 2811 p-limit@3.1.0:
2724 2812 resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
2725 2813 engines: {node: '>=10'}
2726 2814
2815 + p-locate@4.1.0:
2816 + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
2817 + engines: {node: '>=8'}
2818 +
2727 2819 p-locate@5.0.0:
2728 2820 resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
2729 2821 engines: {node: '>=10'}
2730 2822
2823 + p-try@2.2.0:
2824 + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
2825 + engines: {node: '>=6'}
2826 +
2731 2827 parent-module@1.0.1:
2732 2828 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
2733 2829 engines: {node: '>=6'}
@@ -2780,6 +2876,10 @@ packages:
2780 2876 resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
2781 2877 hasBin: true
2782 2878
2879 + pngjs@5.0.0:
2880 + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
2881 + engines: {node: '>=10.13.0'}
2882 +
2783 2883 possible-typed-array-names@1.1.0:
2784 2884 resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
2785 2885 engines: {node: '>= 0.4'}
@@ -2810,6 +2910,11 @@ packages:
2810 2910 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
2811 2911 engines: {node: '>=6'}
2812 2912
2913 + qrcode@1.5.4:
2914 + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
2915 + engines: {node: '>=10.13.0'}
2916 + hasBin: true
2917 +
2813 2918 queue-microtask@1.2.3:
2814 2919 resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
2815 2920
@@ -2840,6 +2945,13 @@ packages:
2840 2945 resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
2841 2946 engines: {node: '>= 0.4'}
2842 2947
2948 + require-directory@2.1.1:
2949 + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
2950 + engines: {node: '>=0.10.0'}
2951 +
2952 + require-main-filename@2.0.0:
2953 + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
2954 +
2843 2955 resolve-from@4.0.0:
2844 2956 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2845 2957 engines: {node: '>=4'}
@@ -2898,6 +3010,9 @@ packages:
2898 3010 server-only@0.0.1:
2899 3011 resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
2900 3012
3013 + set-blocking@2.0.0:
3014 + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
3015 +
2901 3016 set-function-length@1.2.2:
2902 3017 resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
2903 3018 engines: {node: '>= 0.4'}
@@ -2977,6 +3092,10 @@ packages:
2977 3092 resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
2978 3093 engines: {node: '>= 0.4'}
2979 3094
3095 + string-width@4.2.3:
3096 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
3097 + engines: {node: '>=8'}
3098 +
2980 3099 string.prototype.includes@2.0.1:
2981 3100 resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
2982 3101 engines: {node: '>= 0.4'}
@@ -3000,6 +3119,10 @@ packages:
3000 3119 resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
3001 3120 engines: {node: '>= 0.4'}
3002 3121
3122 + strip-ansi@6.0.1:
3123 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
3124 + engines: {node: '>=8'}
3125 +
3003 3126 strip-bom@3.0.0:
3004 3127 resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
3005 3128 engines: {node: '>=4'}
@@ -3234,6 +3357,9 @@ packages:
3234 3357 resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
3235 3358 engines: {node: '>= 0.4'}
3236 3359
3360 + which-module@2.0.1:
3361 + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
3362 +
3237 3363 which-typed-array@1.1.22:
3238 3364 resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
3239 3365 engines: {node: '>= 0.4'}
@@ -3252,9 +3378,24 @@ packages:
3252 3378 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
3253 3379 engines: {node: '>=0.10.0'}
3254 3380
3381 + wrap-ansi@6.2.0:
3382 + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
3383 + engines: {node: '>=8'}
3384 +
3385 + y18n@4.0.3:
3386 + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
3387 +
3255 3388 yallist@3.1.1:
3256 3389 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
3257 3390
3391 + yargs-parser@18.1.3:
3392 + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
3393 + engines: {node: '>=6'}
3394 +
3395 + yargs@15.4.1:
3396 + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
3397 + engines: {node: '>=8'}
3398 +
3258 3399 yocto-queue@0.1.0:
3259 3400 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
3260 3401 engines: {node: '>=10'}
@@ -3863,6 +4004,8 @@ snapshots:
3863 4004 '@next/swc-win32-x64-msvc@16.3.4':
3864 4005 optional: true
3865 4006
4007 + '@noble/hashes@2.4.0': {}
4008 +
3866 4009 '@nodelib/fs.scandir@2.1.5':
3867 4010 dependencies:
3868 4011 '@nodelib/fs.stat': 2.0.5
@@ -4058,6 +4201,10 @@ snapshots:
4058 4201 dependencies:
4059 4202 undici-types: 7.18.2
4060 4203
4204 + '@types/qrcode@1.5.6':
4205 + dependencies:
4206 + '@types/node': 24.13.3
4207 +
4061 4208 '@types/react-dom@19.2.7(@types/react@19.2.18)':
4062 4209 dependencies:
4063 4210 '@types/react': 19.2.18
@@ -4282,6 +4429,8 @@ snapshots:
4282 4429 json-schema-traverse: 0.4.1
4283 4430 uri-js: 4.4.1
4284 4431
4432 + ansi-regex@5.0.1: {}
4433 +
4285 4434 ansi-styles@4.3.0:
4286 4435 dependencies:
4287 4436 color-convert: 2.0.1
@@ -4425,6 +4574,8 @@ snapshots:
4425 4574
4426 4575 callsites@3.1.0: {}
4427 4576
4577 + camelcase@5.3.1: {}
4578 +
4428 4579 caniuse-lite@1.0.30001810: {}
4429 4580
4430 4581 chai@5.3.3:
@@ -4467,6 +4618,12 @@ snapshots:
4467 4618
4468 4619 client-only@0.0.1: {}
4469 4620
4621 + cliui@6.0.0:
4622 + dependencies:
4623 + string-width: 4.2.3
4624 + strip-ansi: 6.0.1
4625 + wrap-ansi: 6.2.0
4626 +
4470 4627 color-convert@2.0.1:
4471 4628 dependencies:
4472 4629 color-name: 1.1.4
@@ -4523,6 +4680,8 @@ snapshots:
4523 4680 dependencies:
4524 4681 ms: 2.1.3
4525 4682
4683 + decamelize@1.2.0: {}
4684 +
4526 4685 deep-eql@5.0.2: {}
4527 4686
4528 4687 deep-is@0.1.4: {}
@@ -4541,6 +4700,8 @@ snapshots:
4541 4700
4542 4701 detect-libc@2.1.2: {}
4543 4702
4703 + dijkstrajs@1.0.3: {}
4704 +
4544 4705 doctrine@2.1.0:
4545 4706 dependencies:
4546 4707 esutils: 2.0.3
@@ -4582,6 +4743,8 @@ snapshots:
4582 4743
4583 4744 electron-to-chromium@1.5.422: {}
4584 4745
4746 + emoji-regex@8.0.0: {}
4747 +
4585 4748 emoji-regex@9.2.2: {}
4586 4749
4587 4750 encoding-sniffer@0.2.1:
@@ -5041,6 +5204,11 @@ snapshots:
5041 5204 dependencies:
5042 5205 to-regex-range: 5.0.1
5043 5206
5207 + find-up@4.1.0:
5208 + dependencies:
5209 + locate-path: 5.0.0
5210 + path-exists: 4.0.0
5211 +
5044 5212 find-up@5.0.0:
5045 5213 dependencies:
5046 5214 locate-path: 6.0.0
@@ -5080,6 +5248,8 @@ snapshots:
5080 5248
5081 5249 gensync@1.0.0-beta.2: {}
5082 5250
5251 + get-caller-file@2.0.5: {}
5252 +
5083 5253 get-intrinsic@1.3.0:
5084 5254 dependencies:
5085 5255 call-bind-apply-helpers: 1.0.2
@@ -5239,6 +5409,8 @@ snapshots:
5239 5409 dependencies:
5240 5410 call-bound: 1.0.4
5241 5411
5412 + is-fullwidth-code-point@3.0.0: {}
5413 +
5242 5414 is-generator-function@1.1.2:
5243 5415 dependencies:
5244 5416 call-bound: 1.0.4
@@ -5409,6 +5581,10 @@ snapshots:
5409 5581 lightningcss-win32-arm64-msvc: 1.32.0
5410 5582 lightningcss-win32-x64-msvc: 1.32.0
5411 5583
5584 + locate-path@5.0.0:
5585 + dependencies:
5586 + p-locate: 4.1.0
5587 +
5412 5588 locate-path@6.0.0:
5413 5589 dependencies:
5414 5590 p-locate: 5.0.0
@@ -5551,6 +5727,10 @@ snapshots:
5551 5727 type-check: 0.4.0
5552 5728 word-wrap: 1.2.5
5553 5729
5730 + otpauth@9.5.2:
5731 + dependencies:
5732 + '@noble/hashes': 2.4.0
5733 +
5554 5734 own-keys@1.0.2:
5555 5735 dependencies:
5556 5736 call-bound: 1.0.4
@@ -5558,14 +5738,24 @@ snapshots:
5558 5738 object-keys: 1.1.1
5559 5739 safe-push-apply: 1.0.0
5560 5740
5741 + p-limit@2.3.0:
5742 + dependencies:
5743 + p-try: 2.2.0
5744 +
5561 5745 p-limit@3.1.0:
5562 5746 dependencies:
5563 5747 yocto-queue: 0.1.0
5564 5748
5749 + p-locate@4.1.0:
5750 + dependencies:
5751 + p-limit: 2.3.0
5752 +
5565 5753 p-locate@5.0.0:
5566 5754 dependencies:
5567 5755 p-limit: 3.1.0
5568 5756
5757 + p-try@2.2.0: {}
5758 +
5569 5759 parent-module@1.0.1:
5570 5760 dependencies:
5571 5761 callsites: 3.1.0
@@ -5619,6 +5809,8 @@ snapshots:
5619 5809 sonic-boom: 4.2.1
5620 5810 thread-stream: 3.2.0
5621 5811
5812 + pngjs@5.0.0: {}
5813 +
5622 5814 possible-typed-array-names@1.1.0: {}
5623 5815
5624 5816 postcss@8.5.23:
@@ -5647,6 +5839,12 @@ snapshots:
5647 5839
5648 5840 punycode@2.3.1: {}
5649 5841
5842 + qrcode@1.5.4:
5843 + dependencies:
5844 + dijkstrajs: 1.0.3
5845 + pngjs: 5.0.0
5846 + yargs: 15.4.1
5847 +
5650 5848 queue-microtask@1.2.3: {}
5651 5849
5652 5850 quick-format-unescaped@4.0.4: {}
@@ -5682,6 +5880,10 @@ snapshots:
5682 5880 gopd: 1.2.0
5683 5881 set-function-name: 2.0.2
5684 5882
5883 + require-directory@2.1.1: {}
5884 +
5885 + require-main-filename@2.0.0: {}
5886 +
5685 5887 resolve-from@4.0.0: {}
5686 5888
5687 5889 resolve-pkg-maps@1.0.0: {}
@@ -5764,6 +5966,8 @@ snapshots:
5764 5966
5765 5967 server-only@0.0.1: {}
5766 5968
5969 + set-blocking@2.0.0: {}
5970 +
5767 5971 set-function-length@1.2.2:
5768 5972 dependencies:
5769 5973 define-data-property: 1.1.4
@@ -5882,6 +6086,12 @@ snapshots:
5882 6086 es-errors: 1.3.0
5883 6087 internal-slot: 1.1.0
5884 6088
6089 + string-width@4.2.3:
6090 + dependencies:
6091 + emoji-regex: 8.0.0
6092 + is-fullwidth-code-point: 3.0.0
6093 + strip-ansi: 6.0.1
6094 +
5885 6095 string.prototype.includes@2.0.1:
5886 6096 dependencies:
5887 6097 call-bind: 1.0.9
@@ -5933,6 +6143,10 @@ snapshots:
5933 6143 define-properties: 1.2.1
5934 6144 es-object-atoms: 1.1.2
5935 6145
6146 + strip-ansi@6.0.1:
6147 + dependencies:
6148 + ansi-regex: 5.0.1
6149 +
5936 6150 strip-bom@3.0.0: {}
5937 6151
5938 6152 strip-json-comments@3.1.1: {}
@@ -6212,6 +6426,8 @@ snapshots:
6212 6426 is-weakmap: 2.0.2
6213 6427 is-weakset: 2.0.4
6214 6428
6429 + which-module@2.0.1: {}
6430 +
6215 6431 which-typed-array@1.1.22:
6216 6432 dependencies:
6217 6433 available-typed-arrays: 1.0.7
@@ -6233,8 +6449,35 @@ snapshots:
6233 6449
6234 6450 word-wrap@1.2.5: {}
6235 6451
6452 + wrap-ansi@6.2.0:
6453 + dependencies:
6454 + ansi-styles: 4.3.0
6455 + string-width: 4.2.3
6456 + strip-ansi: 6.0.1
6457 +
6458 + y18n@4.0.3: {}
6459 +
6236 6460 yallist@3.1.1: {}
6237 6461
6462 + yargs-parser@18.1.3:
6463 + dependencies:
6464 + camelcase: 5.3.1
6465 + decamelize: 1.2.0
6466 +
6467 + yargs@15.4.1:
6468 + dependencies:
6469 + cliui: 6.0.0
6470 + decamelize: 1.2.0
6471 + find-up: 4.1.0
6472 + get-caller-file: 2.0.5
6473 + require-directory: 2.1.1
6474 + require-main-filename: 2.0.0
6475 + set-blocking: 2.0.0
6476 + string-width: 4.2.3
6477 + which-module: 2.0.1
6478 + y18n: 4.0.3
6479 + yargs-parser: 18.1.3
6480 +
6238 6481 yocto-queue@0.1.0: {}
6239 6482
6240 6483 zod-validation-error@4.0.2(zod@4.5.4):
added workers/account/alerts.ts +176 −0
@@ -0,0 +1,176 @@
1 +import { and, eq, gt, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { alerts, alertEvents, assets, assetStats, categories, categorySnapshots, indices, indexValues, listings, auctionLots, sales, radarFindings, populationReports, notifications, priceTargets, users } from '@rareindex/database';
4 +import { newId, logger } from '@rareindex/shared';
5 +import { sendMail, alertEmail } from '@rareindex/notify';
6 +import { evaluateAssetAlert, evaluateCategoryAlert, evaluateIndexAlert, inQuietHours, targetHit, type AlertRow, type AssetState, type CategoryState, type IndexState, type Trigger } from './evaluate.js';
7 +
8 +const log = logger.child({ job: 'account.alerts' });
9 +
10 +interface UserPrefs {
11 + email: string;
12 + emailAlerts: boolean;
13 + quietStart: number | null;
14 + quietEnd: number | null;
15 +}
16 +
17 +async function loadUsers(db: Database, ids: string[]): Promise<Map<string, UserPrefs>> {
18 + if (!ids.length) return new Map();
19 + const rows = await db.select({ id: users.id, email: users.email, prefs: users.preferences, deletedAt: users.deletedAt }).from(users).where(inArray(users.id, ids));
20 + return new Map(rows.filter((r) => !r.deletedAt).map((r) => {
21 + const p = (r.prefs ?? {}) as Record<string, unknown>;
22 + return [r.id, { email: r.email, emailAlerts: p.emailAlerts !== false, quietStart: (p.quietStart as number | null) ?? null, quietEnd: (p.quietEnd as number | null) ?? null }];
23 + }));
24 +}
25 +
26 +/** Persist a trigger: notification row, alert_events audit row, alert bookkeeping, optional e-mail. */
27 +export async function deliver(db: Database, opts: { userId: string; alertId: string | null; channel: string; trigger: Trigger; prefs: UserPrefs | undefined; kind?: string; now?: Date }): Promise<void> {
28 + const now = opts.now ?? new Date();
29 + const wantsEmail = (opts.channel === 'email' || opts.channel === 'both') && opts.prefs?.emailAlerts !== false && opts.prefs?.email;
30 + const hold = opts.prefs ? inQuietHours(opts.prefs, now) : false;
31 + let emailedAt: Date | null = null;
32 + if (wantsEmail && !hold) {
33 + const res = await sendMail({ to: opts.prefs!.email, ...alertEmail({ title: opts.trigger.title, body: opts.trigger.body, href: `${(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '')}${opts.trigger.href}`, facts: opts.trigger.facts }), tags: [{ name: 'kind', value: 'alert' }] });
34 + if (res.ok) emailedAt = now;
35 + else log.warn({ err: res.error, userId: opts.userId }, 'alert e-mail failed');
36 + }
37 + if (opts.channel !== 'email' || !emailedAt) {
38 + await db.insert(notifications).values({ id: newId('event'), userId: opts.userId, kind: opts.kind ?? 'alert', title: opts.trigger.title, body: opts.trigger.body, href: opts.trigger.href, payload: { facts: opts.trigger.facts, alertId: opts.alertId, heldForQuietHours: hold && Boolean(wantsEmail) }, emailedAt });
39 + }
40 + if (opts.alertId) {
41 + await db.insert(alertEvents).values({ id: newId('event'), alertId: opts.alertId, userId: opts.userId, message: opts.trigger.title, payload: { facts: opts.trigger.facts, href: opts.trigger.href } });
42 + await db.update(alerts).set({ lastTriggeredAt: now, triggerCount: sql`${alerts.triggerCount} + 1` }).where(eq(alerts.id, opts.alertId));
43 + }
44 +}
45 +
46 +async function assetState(db: Database, assetId: string, since: Date, now: Date): Promise<AssetState | null> {
47 + const a = await db.select({ asset: assets, stats: assetStats }).from(assets).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(eq(assets.id, assetId)).limit(1);
48 + const row = a[0];
49 + if (!row) return null;
50 + const [newListings, lots, ending, record, baseline, pop] = await Promise.all([
51 + db.select({ id: listings.id, priceUsd: listings.priceUsd, sourceId: listings.sourceId, firstSeenAt: listings.firstSeenAt }).from(listings).where(and(eq(listings.assetId, assetId), eq(listings.availability, 'available'), gte(listings.firstSeenAt, since))),
52 + db.select({ id: auctionLots.id, title: auctionLots.title, endsAt: auctionLots.endsAt, auctionHouse: sql<string>`coalesce(${auctionLots.sourceId}, '')` }).from(auctionLots).where(and(eq(auctionLots.assetId, assetId), gte(auctionLots.createdAt, since))),
53 + db.select({ id: auctionLots.id, title: auctionLots.title, endsAt: auctionLots.endsAt, auctionHouse: sql<string>`coalesce(${auctionLots.sourceId}, '')` }).from(auctionLots).where(and(eq(auctionLots.assetId, assetId), gt(auctionLots.endsAt, now), lt(auctionLots.endsAt, new Date(now.getTime() + 24 * 3600_000)))),
54 + db.select({ priceUsd: sales.priceUsd, saleDate: sales.saleDate, sourceId: sales.sourceId }).from(sales).where(and(eq(sales.assetId, assetId), eq(sales.status, 'valid'), gte(sales.createdAt, since))).orderBy(sql`${sales.priceUsd} desc`).limit(1),
55 + db.execute(sql`select count(*)::float / 3 as n from sales where asset_id = ${assetId} and status = 'valid' and sale_date >= ${new Date(now.getTime() - 120 * 86_400_000).toISOString()}::timestamptz and sale_date < ${new Date(now.getTime() - 30 * 86_400_000).toISOString()}::timestamptz`) as unknown as Promise<Array<{ n: number }>>,
56 + db.select().from(populationReports).where(and(eq(populationReports.assetId, assetId), gte(populationReports.createdAt, since))).orderBy(sql`${populationReports.reportDate} desc`).limit(2),
57 + ]);
58 + const s = row.stats;
59 + const priorAth = s?.athUsd ?? null;
60 + const rec = record[0];
61 + const newRecord = rec && (priorAth === null || rec.priceUsd >= priorAth) ? { priceUsd: rec.priceUsd, saleDate: rec.saleDate, sourceId: rec.sourceId } : null;
62 + let populationChange: AssetState['populationChange'] = null;
63 + if (pop.length) {
64 + const latest = pop[0]!;
65 + const prev = await db.select({ total: populationReports.total }).from(populationReports).where(and(eq(populationReports.assetId, assetId), eq(populationReports.grader, latest.grader), lt(populationReports.reportDate, latest.reportDate))).orderBy(sql`${populationReports.reportDate} desc`).limit(1);
66 + if (prev[0] && prev[0].total !== latest.total) populationChange = { grader: latest.grader, from: prev[0].total, to: latest.total, date: latest.reportDate };
67 + }
68 + return {
69 + title: row.asset.title,
70 + slug: row.asset.slug,
71 + rivUsd: s?.rivUsd ?? null,
72 + rivConfidence: s?.rivConfidence ?? null,
73 + rivSampleSize: s?.rivSampleSize ?? 0,
74 + athUsd: priorAth,
75 + latestSaleUsd: s?.latestSaleUsd ?? null,
76 + latestSaleAt: s?.latestSaleAt ?? null,
77 + sales30d: s?.sales30d ?? 0,
78 + baselineSales30d: baseline[0]?.n ?? null,
79 + newListings,
80 + newAuctionLots: lots,
81 + endingLots: ending.filter((l): l is typeof l & { endsAt: Date } => l.endsAt !== null),
82 + newRecordSale: newRecord,
83 + populationChange,
84 + };
85 +}
86 +
87 +async function categoryState(db: Database, slug: string, since: Date, now: Date): Promise<CategoryState | null> {
88 + const c = await db.select().from(categories).where(eq(categories.slug, slug)).limit(1);
89 + if (!c[0]) return null;
90 + const snap = await db.select().from(categorySnapshots).where(eq(categorySnapshots.categorySlug, slug)).orderBy(sql`${categorySnapshots.date} desc`).limit(1);
91 + const [rec, radar, lots, ending, base] = await Promise.all([
92 + db.execute(sql`select a.title, a.slug, s.price_usd, s.sale_date from sales s join assets a on a.id = s.asset_id join asset_stats st on st.asset_id = a.id where a.category_slug = ${slug} and s.status = 'valid' and s.created_at >= ${since.toISOString()}::timestamptz and (st.ath_usd is null or s.price_usd >= st.ath_usd) order by s.price_usd desc limit 1`) as unknown as Promise<Array<{ title: string; slug: string; price_usd: number; sale_date: Date }>>,
93 + db.execute(sql`select a.title, a.slug, r.kind, r.score from radar_findings r join assets a on a.id = r.asset_id where a.category_slug = ${slug} and r.detected_at >= ${since.toISOString()}::timestamptz order by r.score desc limit 5`) as unknown as Promise<Array<{ title: string; slug: string; kind: string; score: number }>>,
94 + db.execute(sql`select count(*)::int as n from auction_lots l join assets a on a.id = l.asset_id where a.category_slug = ${slug} and l.created_at >= ${since.toISOString()}::timestamptz`) as unknown as Promise<Array<{ n: number }>>,
95 + db.execute(sql`select count(*)::int as n from auction_lots l join assets a on a.id = l.asset_id where a.category_slug = ${slug} and l.ends_at > ${now.toISOString()}::timestamptz and l.ends_at < ${new Date(now.getTime() + 24 * 3600_000).toISOString()}::timestamptz`) as unknown as Promise<Array<{ n: number }>>,
96 + db.execute(sql`select count(*)::float / 3 as n from sales s join assets a on a.id = s.asset_id where a.category_slug = ${slug} and s.status = 'valid' and s.sale_date >= ${new Date(now.getTime() - 120 * 86_400_000).toISOString()}::timestamptz and s.sale_date < ${new Date(now.getTime() - 30 * 86_400_000).toISOString()}::timestamptz`) as unknown as Promise<Array<{ n: number }>>,
97 + ]);
98 + const r = rec[0];
99 + return {
100 + name: c[0].name,
101 + slug,
102 + change1d: snap[0]?.change1d ?? null,
103 + newRecordSale: r ? { assetTitle: r.title, assetSlug: r.slug, priceUsd: Number(r.price_usd), saleDate: new Date(r.sale_date) } : null,
104 + radarFindings: radar.map((x) => ({ assetTitle: x.title, assetSlug: x.slug, kind: x.kind, score: Number(x.score) })),
105 + newAuctionLots: lots[0]?.n ?? 0,
106 + endingLots: ending[0]?.n ?? 0,
107 + sales30d: snap[0]?.sales ?? 0,
108 + baselineSales30d: base[0]?.n ?? null,
109 + };
110 +}
111 +
112 +async function indexState(db: Database, ticker: string): Promise<IndexState | null> {
113 + const i = await db.select().from(indices).where(eq(indices.ticker, ticker)).limit(1);
114 + if (!i[0]) return null;
115 + const vals = await db.select({ date: indexValues.date, value: indexValues.value }).from(indexValues).where(eq(indexValues.indexId, i[0].id)).orderBy(sql`${indexValues.date} desc`).limit(2);
116 + const change1d = vals.length === 2 && vals[1]!.value > 0 ? vals[0]!.value / vals[1]!.value - 1 : null;
117 + return { ticker, name: i[0].name, change1d, value: vals[0]?.value ?? null };
118 +}
119 +
120 +/** Evaluate every active alert. `since` = last run time (defaults to 1 hour ago). */
121 +export async function runAlerts(db: Database, opts: { since?: Date; now?: Date } = {}): Promise<{ evaluated: number; triggered: number }> {
122 + const now = opts.now ?? new Date();
123 + const since = opts.since ?? new Date(now.getTime() - 3600_000);
124 + const rows = await db.select().from(alerts).where(eq(alerts.active, true));
125 + const prefs = await loadUsers(db, [...new Set(rows.map((r) => r.userId))]);
126 + const cacheA = new Map<string, AssetState | null>();
127 + const cacheC = new Map<string, CategoryState | null>();
128 + const cacheI = new Map<string, IndexState | null>();
129 + let triggered = 0;
130 + for (const a of rows) {
131 + if (!prefs.has(a.userId)) continue;
132 + const row: AlertRow = { id: a.id, userId: a.userId, alertType: a.alertType, targetType: a.targetType, targetId: a.targetId, threshold: a.threshold, active: a.active, lastTriggeredAt: a.lastTriggeredAt, cooldownMinutes: a.cooldownMinutes, name: a.name, channel: a.channel };
133 + let trigger: Trigger | null = null;
134 + try {
135 + if (a.targetType === 'asset') {
136 + if (!cacheA.has(a.targetId)) cacheA.set(a.targetId, await assetState(db, a.targetId, since, now));
137 + const s = cacheA.get(a.targetId);
138 + if (s) trigger = evaluateAssetAlert(row, s, now);
139 + } else if (a.targetType === 'category') {
140 + if (!cacheC.has(a.targetId)) cacheC.set(a.targetId, await categoryState(db, a.targetId, since, now));
141 + const s = cacheC.get(a.targetId);
142 + if (s) trigger = evaluateCategoryAlert(row, s, now);
143 + } else if (a.targetType === 'index') {
144 + if (!cacheI.has(a.targetId)) cacheI.set(a.targetId, await indexState(db, a.targetId));
145 + const s = cacheI.get(a.targetId);
146 + if (s) trigger = evaluateIndexAlert(row, s, now);
147 + }
148 + } catch (err) {
149 + log.error({ err: err instanceof Error ? err.message : String(err), alertId: a.id }, 'alert evaluation failed');
150 + continue;
151 + }
152 + if (trigger) {
153 + await deliver(db, { userId: a.userId, alertId: a.id, channel: a.channel, trigger, prefs: prefs.get(a.userId), now });
154 + triggered++;
155 + }
156 + }
157 + log.info({ evaluated: rows.length, triggered }, 'alerts evaluated');
158 + return { evaluated: rows.length, triggered };
159 +}
160 +
161 +/** Price targets: notify once when reached (in-app + e-mail per prefs). */
162 +export async function runTargets(db: Database, now = new Date()): Promise<number> {
163 + const rows = await db.select({ t: priceTargets, asset: assets, stats: assetStats }).from(priceTargets).innerJoin(assets, eq(assets.id, priceTargets.assetId)).leftJoin(assetStats, eq(assetStats.assetId, assets.id)).where(isNull(priceTargets.notifiedAt));
164 + const prefs = await loadUsers(db, [...new Set(rows.map((r) => r.t.userId))]);
165 + let n = 0;
166 + for (const { t, asset, stats } of rows) {
167 + const riv = stats?.rivUsd ?? null;
168 + if (!targetHit(t.direction as 'above' | 'below', t.targetUsd, riv)) continue;
169 + const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);
170 + const trigger: Trigger = { kind: 'alert', title: `Target reached: ${asset.title}`, body: `RIV is ${usd(riv!)} — your ${t.direction === 'above' ? 'sell' : 'buy'} target was ${usd(t.targetUsd)}.`, href: `/asset/${asset.slug}`, facts: [['RIV', usd(riv!)], ['Target', usd(t.targetUsd)]] };
171 + await deliver(db, { userId: t.userId, alertId: null, channel: 'both', trigger, prefs: prefs.get(t.userId), kind: 'target_hit', now });
172 + await db.update(priceTargets).set({ hitAt: now, notifiedAt: now }).where(eq(priceTargets.id, t.id));
173 + n++;
174 + }
175 + return n;
176 +}
added workers/account/badges.ts +45 −0
@@ -0,0 +1,45 @@
1 +import { sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { userBadges } from '@rareindex/database';
4 +import { logger } from '@rareindex/shared';
5 +
6 +const log = logger.child({ job: 'account.badges' });
7 +
8 +/** Recompute data-driven badges for every active member (no fake badges: each has stored evidence). */
9 +export async function runBadges(db: Database): Promise<number> {
10 + const rows = (await db.execute(sql`
11 + with items as (
12 + select c.user_id, ci.id, a.category_slug, ci.certification_number, ci.acquired_at, ci.purchase_price_usd
13 + from collection_items ci join collections c on c.id = ci.collection_id join assets a on a.id = ci.asset_id
14 + ), watch as (
15 + select w.user_id, count(*)::int as n from watchlist_items wi join watchlists w on w.id = wi.watchlist_id group by w.user_id
16 + )
17 + select u.id, u.created_at, u.mfa_enabled,
18 + (select count(*)::int from items i where i.user_id = u.id) as item_count,
19 + (select count(distinct category_slug)::int from items i where i.user_id = u.id) as categories,
20 + (select count(*)::int from items i where i.user_id = u.id and i.certification_number is not null and i.certification_number <> '') as certified,
21 + (select count(*)::int from items i where i.user_id = u.id and (i.acquired_at is null or i.purchase_price_usd is null)) as undocumented,
22 + (select count(*)::int from collections c where c.user_id = u.id and c.is_public) as public_collections,
23 + coalesce((select n from watch where watch.user_id = u.id), 0) as watched
24 + from users u where u.deleted_at is null
25 + `)) as unknown as Array<{ id: string; created_at: Date; mfa_enabled: boolean; item_count: number; categories: number; certified: number; undocumented: number; public_collections: number; watched: number }>;
26 + let awarded = 0;
27 + for (const r of rows) {
28 + const badges: Array<[string, Record<string, unknown>]> = [];
29 + if (new Date(r.created_at) < new Date('2027-09-07')) badges.push(['early_member', { joined: new Date(r.created_at).toISOString().slice(0, 10) }]);
30 + if (r.categories >= 10) badges.push(['ten_categories', { categories: r.categories }]);
31 + if (r.item_count >= 100) badges.push(['hundred_items', { items: r.item_count }]);
32 + if (r.certified >= 10) badges.push(['graded_collector', { certified: r.certified }]);
33 + if (r.item_count >= 5 && r.undocumented === 0) badges.push(['documented', { items: r.item_count }]);
34 + if (r.public_collections >= 1) badges.push(['public_profile', { publicCollections: r.public_collections }]);
35 + if (r.watched >= 25) badges.push(['watcher', { watched: r.watched }]);
36 + if (r.mfa_enabled) badges.push(['two_factor', {}]);
37 + await db.delete(userBadges).where(sql`${userBadges.userId} = ${r.id}`);
38 + if (badges.length) {
39 + await db.insert(userBadges).values(badges.map(([badge, evidence]) => ({ userId: r.id, badge, evidence })));
40 + awarded += badges.length;
41 + }
42 + }
43 + log.info({ users: rows.length, awarded }, 'badges recomputed');
44 + return awarded;
45 +}
added workers/account/digest.ts +71 −0
@@ -0,0 +1,71 @@
1 +import { and, eq, gte, isNull, sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { users, notifications } from '@rareindex/database';
4 +import { logger, newId } from '@rareindex/shared';
5 +import { sendMail, digestEmail, type DigestSection } from '@rareindex/notify';
6 +
7 +const log = logger.child({ job: 'account.digest' });
8 +const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);
9 +const pct = (v: number | null) => (v === null ? '' : `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`);
10 +
11 +/**
12 + * Daily/weekly digest: portfolio value + change, biggest movers in collections & watchlist, deals,
13 + * unread notifications. Sent once per period per user (tracked with a 'digest' notification row).
14 + */
15 +export async function runDigests(db: Database, now = new Date()): Promise<number> {
16 + const weekday = now.getUTCDay();
17 + const members = await db.select({ id: users.id, email: users.email, name: users.name, prefs: users.preferences }).from(users).where(and(isNull(users.deletedAt), sql`${users.emailVerifiedAt} is not null`));
18 + let sent = 0;
19 + for (const m of members) {
20 + const p = (m.prefs ?? {}) as { digest?: string; digestWeekday?: number };
21 + const mode = p.digest ?? 'weekly';
22 + if (mode === 'off') continue;
23 + if (mode === 'weekly' && Number(p.digestWeekday ?? 1) !== weekday) continue;
24 + const periodDays = mode === 'daily' ? 1 : 7;
25 + const since = new Date(now.getTime() - periodDays * 86_400_000);
26 + const already = await db.select({ id: notifications.id }).from(notifications).where(and(eq(notifications.userId, m.id), eq(notifications.kind, 'digest'), gte(notifications.createdAt, new Date(now.getTime() - (periodDays * 24 - 2) * 3600_000)))).limit(1);
27 + if (already[0]) continue;
28 +
29 + const hist = (await db.execute(sql`
30 + select s.date::text as date, sum(s.value_usd)::float as v from collection_snapshots s join collections c on c.id = s.collection_id
31 + where c.user_id = ${m.id} and s.date >= ${since.toISOString().slice(0, 10)} group by s.date order by s.date
32 + `)) as unknown as Array<{ date: string; v: number }>;
33 + const first = hist[0]?.v ?? null;
34 + const last = hist[hist.length - 1]?.v ?? null;
35 + const portfolio = last !== null && last > 0 ? { valueUsd: usd(last), change: first && first > 0 ? pct(last / first - 1) : '—' } : null;
36 +
37 + const movers = (await db.execute(sql`
38 + select a.title, a.slug, st.riv_usd, st.change_7d, st.change_1d from (
39 + select ci.asset_id from collection_items ci join collections c on c.id = ci.collection_id where c.user_id = ${m.id}
40 + union select wi.target_id from watchlist_items wi join watchlists w on w.id = wi.watchlist_id where w.user_id = ${m.id} and wi.target_type = 'asset'
41 + ) x join assets a on a.id = x.asset_id join asset_stats st on st.asset_id = a.id
42 + where ${periodDays === 1 ? sql`st.change_1d` : sql`st.change_7d`} is not null
43 + order by abs(${periodDays === 1 ? sql`st.change_1d` : sql`st.change_7d`}) desc limit 6
44 + `)) as unknown as Array<{ title: string; slug: string; riv_usd: number | null; change_7d: number | null; change_1d: number | null }>;
45 + const deals = (await db.execute(sql`
46 + select a.title, a.slug, l.price_usd, l.discount_to_riv from listings l join assets a on a.id = l.asset_id join asset_stats s on s.asset_id = a.id
47 + where l.availability = 'available' and l.discount_to_riv <= -0.15 and s.riv_confidence >= 0.5 and s.riv_sample_size >= 5
48 + and a.category_slug in (
49 + select distinct a2.category_slug from collection_items ci join collections c on c.id = ci.collection_id join assets a2 on a2.id = ci.asset_id where c.user_id = ${m.id}
50 + union select wi.target_id from watchlist_items wi join watchlists w on w.id = wi.watchlist_id where w.user_id = ${m.id} and wi.target_type = 'category'
51 + )
52 + order by l.discount_to_riv asc limit 5
53 + `)) as unknown as Array<{ title: string; slug: string; price_usd: number; discount_to_riv: number }>;
54 + const unread = (await db.execute(sql`select count(*)::int as n from notifications where user_id = ${m.id} and read_at is null and kind <> 'digest'`)) as unknown as Array<{ n: number }>;
55 +
56 + const site = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');
57 + const sections: DigestSection[] = [
58 + { heading: periodDays === 1 ? 'Movers today' : 'Movers this week', rows: movers.map((r) => ({ label: r.title, value: r.riv_usd ? usd(Number(r.riv_usd)) : '—', delta: pct(periodDays === 1 ? r.change_1d : r.change_7d), href: `${site}/asset/${r.slug}` })) },
59 + { heading: 'Deal Radar', rows: deals.map((r) => ({ label: r.title, value: usd(Number(r.price_usd)), delta: pct(Number(r.discount_to_riv)), href: `${site}/asset/${r.slug}` })) },
60 + { heading: 'Inbox', rows: unread[0]?.n ? [{ label: 'Unread notifications', value: String(unread[0].n), href: `${site}/notifications` }] : [] },
61 + ];
62 + if (!portfolio && sections.every((s) => s.rows.length === 0)) continue; // nothing to say — no empty digests
63 + const mail = digestEmail({ name: m.name, period: mode === 'daily' ? 'daily' : 'weekly', sections, portfolio });
64 + const res = await sendMail({ to: m.email, ...mail, tags: [{ name: 'kind', value: 'digest' }] });
65 + await db.insert(notifications).values({ id: newId('event'), userId: m.id, kind: 'digest', title: mail.subject, body: portfolio ? `Collection value ${portfolio.valueUsd} (${portfolio.change}).` : 'Your digest is ready.', href: '/collections', emailedAt: res.ok ? now : null, readAt: now });
66 + if (res.ok) sent++;
67 + else log.warn({ userId: m.id, err: res.error }, 'digest e-mail failed');
68 + }
69 + log.info({ sent }, 'digests processed');
70 + return sent;
71 +}
added workers/account/evaluate.test.ts +43 −0
@@ -0,0 +1,43 @@
1 +import { describe, expect, it } from 'vitest';
2 +import { evaluateAssetAlert, evaluateCategoryAlert, evaluateIndexAlert, inCooldown, inQuietHours, targetHit, type AlertRow, type AssetState } from './evaluate.js';
3 +
4 +const alert = (over: Partial<AlertRow>): AlertRow => ({ id: 'a', userId: 'u', alertType: 'price_below', targetType: 'asset', targetId: 'rare_1', threshold: 1000, active: true, lastTriggeredAt: null, cooldownMinutes: 1440, name: null, channel: 'both', ...over });
5 +const state = (over: Partial<AssetState>): AssetState => ({ title: 'Charizard', slug: 'charizard', rivUsd: 900, rivConfidence: 0.8, rivSampleSize: 12, athUsd: 5000, latestSaleUsd: 950, latestSaleAt: new Date(), sales30d: 4, baselineSales30d: 2, newListings: [], newAuctionLots: [], endingLots: [], newRecordSale: null, populationChange: null, ...over });
6 +
7 +describe('alert evaluation', () => {
8 + it('price thresholds', () => {
9 + expect(evaluateAssetAlert(alert({}), state({}))?.title).toContain('below');
10 + expect(evaluateAssetAlert(alert({}), state({ rivUsd: 1200 }))).toBeNull();
11 + expect(evaluateAssetAlert(alert({ alertType: 'price_above', threshold: 800 }), state({}))?.title).toContain('above');
12 + expect(evaluateAssetAlert(alert({}), state({ rivUsd: null }))).toBeNull();
13 + });
14 + it('respects cooldown and inactive', () => {
15 + const now = new Date();
16 + expect(inCooldown(alert({ lastTriggeredAt: new Date(now.getTime() - 60_000) }), now)).toBe(true);
17 + expect(inCooldown(alert({ lastTriggeredAt: new Date(now.getTime() - 2 * 86_400_000) }), now)).toBe(false);
18 + expect(evaluateAssetAlert(alert({ active: false }), state({}))).toBeNull();
19 + });
20 + it('listings, records, volume, population', () => {
21 + expect(evaluateAssetAlert(alert({ alertType: 'new_listing' }), state({ newListings: [{ id: 'l', priceUsd: 700, sourceId: 'ebay', firstSeenAt: new Date() }] }))?.facts).toContainEqual(['Lowest ask', '$700.00']);
22 + expect(evaluateAssetAlert(alert({ alertType: 'record_sale' }), state({ newRecordSale: { priceUsd: 6000, saleDate: new Date('2026-01-01'), sourceId: 'goldin' } }))?.title).toContain('$6,000');
23 + expect(evaluateAssetAlert(alert({ alertType: 'unusual_volume', threshold: 50 }), state({ sales30d: 6, baselineSales30d: 2 }))?.title).toContain('+200.0%');
24 + expect(evaluateAssetAlert(alert({ alertType: 'unusual_volume', threshold: 50 }), state({ sales30d: 2, baselineSales30d: 2 }))).toBeNull();
25 + expect(evaluateAssetAlert(alert({ alertType: 'population_update' }), state({ populationChange: { grader: 'psa', from: 100, to: 120, date: '2026-09-01' } }))?.title).toContain('100 → 120');
26 + const soon = new Date(Date.now() + 3600_000);
27 + expect(evaluateAssetAlert(alert({ alertType: 'auction_ending' }), state({ endingLots: [{ id: 'x', title: 'Lot 1', endsAt: soon, auctionHouse: 'Heritage' }] }))?.title).toContain('ends');
28 + });
29 + it('category and index alerts', () => {
30 + expect(evaluateCategoryAlert(alert({ alertType: 'market_move', targetType: 'category', threshold: 3 }), { name: 'Pokémon', slug: 'pokemon', change1d: 0.045, newRecordSale: null, radarFindings: [], newAuctionLots: 0, endingLots: 0, sales30d: 0, baselineSales30d: null })?.title).toContain('+4.5%');
31 + expect(evaluateCategoryAlert(alert({ alertType: 'market_move', targetType: 'category', threshold: 5 }), { name: 'Pokémon', slug: 'pokemon', change1d: 0.045, newRecordSale: null, radarFindings: [], newAuctionLots: 0, endingLots: 0, sales30d: 0, baselineSales30d: null })).toBeNull();
32 + expect(evaluateIndexAlert(alert({ alertType: 'market_move', targetType: 'index', threshold: 2 }), { ticker: 'RARE', name: 'RareIndex', change1d: -0.03, value: 1042 })?.title).toContain('-3.0%');
33 + });
34 + it('quiet hours and targets', () => {
35 + expect(inQuietHours({ quietStart: 22, quietEnd: 7 }, new Date('2026-01-01T23:00:00Z'))).toBe(true);
36 + expect(inQuietHours({ quietStart: 22, quietEnd: 7 }, new Date('2026-01-01T12:00:00Z'))).toBe(false);
37 + expect(inQuietHours({ quietStart: 9, quietEnd: 17 }, new Date('2026-01-01T12:00:00Z'))).toBe(true);
38 + expect(inQuietHours({}, new Date())).toBe(false);
39 + expect(targetHit('below', 1000, 900)).toBe(true);
40 + expect(targetHit('above', 1000, 900)).toBe(false);
41 + expect(targetHit('above', 1000, null)).toBe(false);
42 + });
43 +});
added workers/account/evaluate.ts +171 −0
@@ -0,0 +1,171 @@
1 +/**
2 + * Pure alert evaluation (no I/O). The runner loads the state an alert needs, calls `evaluateAlert`,
3 + * and persists notifications/e-mails. Kept pure so the rules are unit-testable.
4 + */
5 +
6 +export interface AlertRow {
7 + id: string;
8 + userId: string;
9 + alertType: string;
10 + targetType: string; // asset | category | index
11 + targetId: string;
12 + threshold: number | null;
13 + active: boolean;
14 + lastTriggeredAt: Date | null;
15 + cooldownMinutes: number;
16 + name: string | null;
17 + channel: string;
18 +}
19 +
20 +export interface AssetState {
21 + title: string;
22 + slug: string;
23 + rivUsd: number | null;
24 + rivConfidence: number | null;
25 + rivSampleSize: number;
26 + athUsd: number | null;
27 + latestSaleUsd: number | null;
28 + latestSaleAt: Date | null;
29 + sales30d: number;
30 + /** average sales per 30 days over the prior 90 days */
31 + baselineSales30d: number | null;
32 + newListings: Array<{ id: string; priceUsd: number | null; sourceId: string; firstSeenAt: Date }>;
33 + newAuctionLots: Array<{ id: string; title: string; endsAt: Date | null; auctionHouse: string }>;
34 + endingLots: Array<{ id: string; title: string; endsAt: Date; auctionHouse: string }>;
35 + newRecordSale: { priceUsd: number; saleDate: Date; sourceId: string } | null;
36 + populationChange: { grader: string; from: number; to: number; date: string } | null;
37 +}
38 +
39 +export interface CategoryState {
40 + name: string;
41 + slug: string;
42 + change1d: number | null;
43 + newRecordSale: { assetTitle: string; assetSlug: string; priceUsd: number; saleDate: Date } | null;
44 + radarFindings: Array<{ assetTitle: string; assetSlug: string; kind: string; score: number }>;
45 + newAuctionLots: number;
46 + endingLots: number;
47 + sales30d: number;
48 + baselineSales30d: number | null;
49 +}
50 +
51 +export interface IndexState {
52 + ticker: string;
53 + name: string;
54 + change1d: number | null;
55 + value: number | null;
56 +}
57 +
58 +export interface Trigger {
59 + title: string;
60 + body: string;
61 + href: string;
62 + facts: Array<[string, string]>;
63 + kind: 'alert';
64 +}
65 +
66 +const usd = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: v >= 1000 ? 0 : 2 }).format(v);
67 +const pct = (v: number) => `${v > 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;
68 +
69 +export function inCooldown(alert: AlertRow, now: Date): boolean {
70 + if (!alert.lastTriggeredAt) return false;
71 + return now.getTime() - alert.lastTriggeredAt.getTime() < alert.cooldownMinutes * 60_000;
72 +}
73 +
74 +export function evaluateAssetAlert(alert: AlertRow, s: AssetState, now = new Date()): Trigger | null {
75 + if (!alert.active || inCooldown(alert, now)) return null;
76 + const href = `/asset/${s.slug}`;
77 + const title = alert.name ?? s.title;
78 + switch (alert.alertType) {
79 + case 'price_below':
80 + if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd <= alert.threshold) return { kind: 'alert', title: `${title}: RIV below ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };
81 + return null;
82 + case 'price_above':
83 + if (alert.threshold !== null && s.rivUsd !== null && s.rivUsd >= alert.threshold) return { kind: 'alert', title: `${title}: RIV above ${usd(alert.threshold)}`, body: `RareIndex Valuation is now ${usd(s.rivUsd)} (${s.rivSampleSize} sales).`, href, facts: [['RIV', usd(s.rivUsd)], ['Threshold', usd(alert.threshold)]] };
84 + return null;
85 + case 'new_listing': {
86 + if (!s.newListings.length) return null;
87 + const cheapest = s.newListings.filter((l) => l.priceUsd !== null).sort((a, b) => (a.priceUsd ?? 0) - (b.priceUsd ?? 0))[0];
88 + return { kind: 'alert', title: `${title}: ${s.newListings.length} new listing${s.newListings.length === 1 ? '' : 's'}`, body: cheapest?.priceUsd ? `Lowest new ask ${usd(cheapest.priceUsd)} on ${cheapest.sourceId}${s.rivUsd ? ` · RIV ${usd(s.rivUsd)}` : ''}.` : 'New listings observed.', href: `${href}?tab=listings`, facts: [['New listings', String(s.newListings.length)], ...(cheapest?.priceUsd ? [['Lowest ask', usd(cheapest.priceUsd)] as [string, string]] : []), ...(s.rivUsd ? [['RIV', usd(s.rivUsd)] as [string, string]] : [])] };
89 + }
90 + case 'new_auction': {
91 + if (!s.newAuctionLots.length) return null;
92 + const l = s.newAuctionLots[0]!;
93 + return { kind: 'alert', title: `${title}: new auction lot at ${l.auctionHouse}`, body: `${l.title}${l.endsAt ? ` · ends ${l.endsAt.toUTCString()}` : ''}`, href: `${href}?tab=listings`, facts: [['Lots', String(s.newAuctionLots.length)]] };
94 + }
95 + case 'auction_ending': {
96 + const soon = s.endingLots.filter((l) => l.endsAt.getTime() - now.getTime() <= 24 * 3600_000 && l.endsAt.getTime() > now.getTime());
97 + if (!soon.length) return null;
98 + const l = soon.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime())[0]!;
99 + return { kind: 'alert', title: `${title}: auction ends ${Math.max(1, Math.round((l.endsAt.getTime() - now.getTime()) / 3600_000))}h from now`, body: `${l.title} at ${l.auctionHouse}.`, href: `${href}?tab=listings`, facts: [['Ends', l.endsAt.toUTCString()]] };
100 + }
101 + case 'record_sale':
102 + if (!s.newRecordSale) return null;
103 + return { kind: 'alert', title: `${title}: new record sale ${usd(s.newRecordSale.priceUsd)}`, body: `Highest verified sale to date, on ${s.newRecordSale.sourceId} (${s.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `${href}?tab=sales`, facts: [['Record', usd(s.newRecordSale.priceUsd)], ...(s.athUsd ? [['Previous high', usd(s.athUsd)] as [string, string]] : [])] };
104 + case 'unusual_volume': {
105 + if (alert.threshold === null || s.baselineSales30d === null || s.baselineSales30d <= 0) return null;
106 + const ratio = s.sales30d / s.baselineSales30d - 1;
107 + if (ratio * 100 < alert.threshold || s.sales30d < 5) return null;
108 + return { kind: 'alert', title: `${title}: sales volume ${pct(ratio)} vs 90-day average`, body: `${s.sales30d} sales in 30 days against a baseline of ${s.baselineSales30d.toFixed(1)}.`, href: `${href}?tab=sales`, facts: [['Sales 30d', String(s.sales30d)], ['Baseline', s.baselineSales30d.toFixed(1)]] };
109 + }
110 + case 'population_update':
111 + if (!s.populationChange) return null;
112 + return { kind: 'alert', title: `${title}: ${s.populationChange.grader.toUpperCase()} population ${s.populationChange.from} → ${s.populationChange.to}`, body: `Population report dated ${s.populationChange.date}.`, href: `${href}?tab=population`, facts: [['Change', `${s.populationChange.to - s.populationChange.from > 0 ? '+' : ''}${s.populationChange.to - s.populationChange.from}`]] };
113 + default:
114 + return null;
115 + }
116 +}
117 +
118 +export function evaluateCategoryAlert(alert: AlertRow, c: CategoryState, now = new Date()): Trigger | null {
119 + if (!alert.active || inCooldown(alert, now)) return null;
120 + const href = `/markets/${c.slug}`;
121 + const title = alert.name ?? c.name;
122 + switch (alert.alertType) {
123 + case 'market_move':
124 + if (alert.threshold === null || c.change1d === null || Math.abs(c.change1d) * 100 < alert.threshold) return null;
125 + return { kind: 'alert', title: `${title} moved ${pct(c.change1d)} today`, body: `Daily move beyond your ${alert.threshold}% threshold.`, href, facts: [['1d', pct(c.change1d)]] };
126 + case 'record_sale':
127 + if (!c.newRecordSale) return null;
128 + return { kind: 'alert', title: `${title}: record sale ${usd(c.newRecordSale.priceUsd)}`, body: `${c.newRecordSale.assetTitle} (${c.newRecordSale.saleDate.toISOString().slice(0, 10)}).`, href: `/asset/${c.newRecordSale.assetSlug}`, facts: [['Price', usd(c.newRecordSale.priceUsd)]] };
129 + case 'rare_item': {
130 + if (!c.radarFindings.length) return null;
131 + const f = c.radarFindings.sort((a, b) => b.score - a.score)[0]!;
132 + return { kind: 'alert', title: `${title}: rare item on the radar`, body: `${f.assetTitle} — ${f.kind.replace(/_/g, ' ')}.`, href: `/asset/${f.assetSlug}`, facts: [['Findings', String(c.radarFindings.length)]] };
133 + }
134 + case 'new_auction':
135 + if (!c.newAuctionLots) return null;
136 + return { kind: 'alert', title: `${title}: ${c.newAuctionLots} new auction lot${c.newAuctionLots === 1 ? '' : 's'}`, body: 'New lots catalogued in the last cycle.', href: `/auctions?category=${c.slug}`, facts: [['Lots', String(c.newAuctionLots)]] };
137 + case 'auction_ending':
138 + if (!c.endingLots) return null;
139 + return { kind: 'alert', title: `${title}: ${c.endingLots} lot${c.endingLots === 1 ? '' : 's'} ending within 24h`, body: 'Check the auction calendar.', href: `/auctions/calendar?category=${c.slug}`, facts: [['Ending', String(c.endingLots)]] };
140 + case 'unusual_volume': {
141 + if (alert.threshold === null || c.baselineSales30d === null || c.baselineSales30d <= 0) return null;
142 + const ratio = c.sales30d / c.baselineSales30d - 1;
143 + if (ratio * 100 < alert.threshold) return null;
144 + return { kind: 'alert', title: `${title}: volume ${pct(ratio)} vs 90-day average`, body: `${c.sales30d} sales in 30 days.`, href, facts: [['Sales 30d', String(c.sales30d)]] };
145 + }
146 + default:
147 + return null;
148 + }
149 +}
150 +
151 +export function evaluateIndexAlert(alert: AlertRow, i: IndexState, now = new Date()): Trigger | null {
152 + if (!alert.active || inCooldown(alert, now)) return null;
153 + if (alert.alertType !== 'market_move' || alert.threshold === null || i.change1d === null) return null;
154 + if (Math.abs(i.change1d) * 100 < alert.threshold) return null;
155 + return { kind: 'alert', title: `${i.ticker} moved ${pct(i.change1d)} today`, body: `${i.name} at ${i.value?.toFixed(1) ?? '—'}.`, href: `/rareindex/${i.ticker}`, facts: [['1d', pct(i.change1d)]] };
156 +}
157 +
158 +/** Quiet hours check (UTC hours). Returns true when e-mail should be held. */
159 +export function inQuietHours(prefs: { quietStart?: number | null; quietEnd?: number | null }, now = new Date()): boolean {
160 + const s = prefs.quietStart;
161 + const e = prefs.quietEnd;
162 + if (s === null || s === undefined || e === null || e === undefined) return false;
163 + const h = now.getUTCHours();
164 + return s <= e ? h >= s && h < e : h >= s || h < e;
165 +}
166 +
167 +/** Price target hit check. */
168 +export function targetHit(direction: 'above' | 'below', targetUsd: number, rivUsd: number | null): boolean {
169 + if (rivUsd === null) return false;
170 + return direction === 'above' ? rivUsd >= targetUsd : rivUsd <= targetUsd;
171 +}
added workers/account/index.ts +67 −0
@@ -0,0 +1,67 @@
1 +import type { Database } from '@rareindex/database';
2 +import { logger } from '@rareindex/shared';
3 +import { runAlerts, runTargets } from './alerts.js';
4 +import { runPortfolioSnapshots } from './snapshots.js';
5 +import { runBadges } from './badges.js';
6 +import { runDigests } from './digest.js';
7 +import { runMaintenance } from './maintenance.js';
8 +import { runSavedSearches } from './saved-searches.js';
9 +
10 +export { runAlerts, runTargets, runPortfolioSnapshots, runBadges, runDigests, runMaintenance, runSavedSearches };
11 +
12 +const log = logger.child({ job: 'account' });
13 +
14 +export interface AccountJobsResult {
15 + alerts: { evaluated: number; triggered: number };
16 + targets: number;
17 + snapshots: number | null;
18 + badges: number | null;
19 + digests: number | null;
20 + savedSearches: number | null;
21 + purged: number | null;
22 +}
23 +
24 +let lastAlertRun: Date | null = null;
25 +let lastDailyKey: string | null = null;
26 +
27 +/**
28 + * Entry point for the pipeline scheduler (agent D): call every 5–15 minutes.
29 + *
30 + * import { runAccountJobs } from '../account/index.ts';
31 + * await runAccountJobs(db); // alerts + targets each call; daily jobs once per UTC day
32 + * await runAccountJobs(db, { daily: true }); // force the daily set (snapshots, badges, digests, saved searches, purge)
33 + *
34 + * Alerts use the time of the previous call as `since` (first call: last hour). All functions are
35 + * idempotent per day and safe to re-run.
36 + */
37 +export async function runAccountJobs(db: Database, opts: { daily?: boolean; now?: Date } = {}): Promise<AccountJobsResult> {
38 + const now = opts.now ?? new Date();
39 + const result: AccountJobsResult = { alerts: { evaluated: 0, triggered: 0 }, targets: 0, snapshots: null, badges: null, digests: null, savedSearches: null, purged: null };
40 + try {
41 + result.alerts = await runAlerts(db, { since: lastAlertRun ?? new Date(now.getTime() - 3600_000), now });
42 + result.targets = await runTargets(db, now);
43 + lastAlertRun = now;
44 + } catch (err) {
45 + log.error({ err: err instanceof Error ? err.message : String(err) }, 'alert jobs failed');
46 + }
47 + const dayKey = now.toISOString().slice(0, 10);
48 + const runDaily = opts.daily || lastDailyKey !== dayKey;
49 + if (runDaily) {
50 + const steps: Array<[keyof AccountJobsResult, () => Promise<number>]> = [
51 + ['snapshots', () => runPortfolioSnapshots(db, dayKey)],
52 + ['badges', () => runBadges(db)],
53 + ['savedSearches', () => runSavedSearches(db, now)],
54 + ['digests', () => runDigests(db, now)],
55 + ['purged', async () => (await runMaintenance(db, now)).purged],
56 + ];
57 + for (const [key, fn] of steps) {
58 + try {
59 + (result as unknown as Record<string, unknown>)[key] = await fn();
60 + } catch (err) {
61 + log.error({ err: err instanceof Error ? err.message : String(err), step: key }, 'daily account job failed');
62 + }
63 + }
64 + lastDailyKey = dayKey;
65 + }
66 + return result;
67 +}
added workers/account/maintenance.ts +35 −0
@@ -0,0 +1,35 @@
1 +import { and, eq, inArray, lt, sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { users, sessions, trustedDevices, authCodes, rateLimits, collections, collectionItems, watchlists, watchlistItems, alerts, alertEvents, notifications, savedSearches, priceTargets, recoveryCodes, loginEvents, apiKeys, userBadges, uploads, collectionSnapshots } from '@rareindex/database';
4 +import { logger } from '@rareindex/shared';
5 +
6 +const log = logger.child({ job: 'account.maintenance' });
7 +
8 +/** Purge accounts past their grace period, expire codes/sessions, trim rate-limit rows. */
9 +export async function runMaintenance(db: Database, now = new Date()): Promise<{ purged: number }> {
10 + const due = await db.select({ id: users.id }).from(users).where(and(sql`${users.deletedAt} is not null`, lt(users.purgeAfter, now)));
11 + for (const u of due) {
12 + const cols = await db.select({ id: collections.id }).from(collections).where(eq(collections.userId, u.id));
13 + const colIds = cols.map((c) => c.id);
14 + if (colIds.length) {
15 + await db.delete(collectionItems).where(inArray(collectionItems.collectionId, colIds));
16 + await db.delete(collectionSnapshots).where(inArray(collectionSnapshots.collectionId, colIds));
17 + await db.delete(collections).where(inArray(collections.id, colIds));
18 + }
19 + const wls = await db.select({ id: watchlists.id }).from(watchlists).where(eq(watchlists.userId, u.id));
20 + if (wls.length) {
21 + await db.delete(watchlistItems).where(inArray(watchlistItems.watchlistId, wls.map((w) => w.id)));
22 + await db.delete(watchlists).where(eq(watchlists.userId, u.id));
23 + }
24 + for (const t of [alertEvents, alerts, notifications, savedSearches, priceTargets, recoveryCodes, trustedDevices, sessions, loginEvents, apiKeys, userBadges, uploads]) {
25 + await db.delete(t).where(eq((t as unknown as { userId: typeof users.id }).userId, u.id));
26 + }
27 + await db.delete(authCodes).where(eq(authCodes.userId, u.id));
28 + await db.delete(users).where(eq(users.id, u.id));
29 + log.info({ userId: u.id }, 'account purged after grace period');
30 + }
31 + await db.delete(authCodes).where(lt(authCodes.expiresAt, new Date(now.getTime() - 7 * 86_400_000)));
32 + await db.delete(sessions).where(lt(sessions.expiresAt, new Date(now.getTime() - 30 * 86_400_000)));
33 + await db.delete(rateLimits).where(lt(rateLimits.resetAt, new Date(now.getTime() - 86_400_000)));
34 + return { purged: due.length };
35 +}
added workers/account/run.ts +19 −0
@@ -0,0 +1,19 @@
1 +/**
2 + * Standalone runner (dev/ops): `pnpm tsx workers/account/run.ts [--daily] [--once]`.
3 + * The production scheduler (workers/main.ts, agent D) should import runAccountJobs instead.
4 + */
5 +import { getDb, closeDb } from '@rareindex/database';
6 +import { runAccountJobs } from './index.js';
7 +
8 +const daily = process.argv.includes('--daily');
9 +const db = getDb();
10 +runAccountJobs(db, { daily })
11 + .then((r) => {
12 + console.log(JSON.stringify(r));
13 + return closeDb();
14 + })
15 + .catch(async (e) => {
16 + console.error(e);
17 + await closeDb();
18 + process.exit(1);
19 + });
added workers/account/saved-searches.ts +40 −0
@@ -0,0 +1,40 @@
1 +import { and, eq, sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { savedSearches, notifications } from '@rareindex/database';
4 +import { logger, newId } from '@rareindex/shared';
5 +
6 +const log = logger.child({ job: 'account.saved-searches' });
7 +
8 +/**
9 + * Re-run saved searches that have notifications enabled and report growth in matches.
10 + * Uses a conservative server-side approximation (title trigram + category param) so it works
11 + * independently of the web search module; counts are informative, not authoritative.
12 + */
13 +export async function runSavedSearches(db: Database, now = new Date()): Promise<number> {
14 + const rows = await db.select().from(savedSearches).where(eq(savedSearches.notify, true));
15 + let notified = 0;
16 + for (const s of rows) {
17 + const params = s.params as Record<string, string>;
18 + const q = (params.q ?? '').trim();
19 + const category = params.category ?? params.cat ?? null;
20 + if (!q && !category) continue;
21 + const res = (await db.execute(sql`
22 + select count(*)::int as n from assets a
23 + where (${q ? sql`(a.title ilike ${'%' + q + '%'} or a.search @@ plainto_tsquery('simple', ${q}))` : sql`true`})
24 + and (${category ? sql`(a.category_slug = ${category} or a.family_slug = ${category})` : sql`true`})
25 + `)) as unknown as Array<{ n: number }>;
26 + const n = res[0]?.n ?? 0;
27 + const prev = s.lastCount;
28 + await db.update(savedSearches).set({ lastRunAt: now, lastCount: n }).where(eq(savedSearches.id, s.id));
29 + if (prev !== null && n > prev) {
30 + await db.insert(notifications).values({ id: newId('event'), userId: s.userId, kind: 'system', title: `${n - prev} new match${n - prev === 1 ? '' : 'es'} for “${s.name}”`, body: `${n} assets now match your saved search.`, href: s.url, payload: { savedSearchId: s.id, previous: prev, current: n } });
31 + notified++;
32 + }
33 + }
34 + log.info({ searches: rows.length, notified }, 'saved searches re-run');
35 + return notified;
36 +}
37 +
38 +export async function markSavedSearchRun(db: Database, id: string, count: number): Promise<void> {
39 + await db.update(savedSearches).set({ lastRunAt: new Date(), lastCount: count }).where(and(eq(savedSearches.id, id)));
40 +}
added workers/account/snapshots.ts +55 −0
@@ -0,0 +1,55 @@
1 +import { eq, sql } from 'drizzle-orm';
2 +import type { Database } from '@rareindex/database';
3 +import { collections, collectionSnapshots } from '@rareindex/database';
4 +import { logger } from '@rareindex/shared';
5 +
6 +const log = logger.child({ job: 'account.snapshots' });
7 +
8 +/**
9 + * Daily portfolio snapshot per collection: Σ value (variant RIV → asset RIV → manual value) and
10 + * Σ cost basis in USD. Idempotent for a given date (upsert).
11 + */
12 +export async function runPortfolioSnapshots(db: Database, date = new Date().toISOString().slice(0, 10)): Promise<number> {
13 + const rows = (await db.execute(sql`
14 + select c.id as collection_id,
15 + coalesce(sum(coalesce(vs.riv_usd, s.riv_usd, ci.manual_value_usd, 0) * greatest(ci.quantity, 1)), 0)::float as value_usd,
16 + coalesce(sum(coalesce(ci.purchase_price_usd, 0) * greatest(ci.quantity, 1)), 0)::float as cost_basis_usd,
17 + count(ci.id)::int as items
18 + from collections c
19 + left join collection_items ci on ci.collection_id = c.id and ci.sold_at is null
20 + left join asset_stats s on s.asset_id = ci.asset_id
21 + left join variant_stats vs on vs.variant_id = ci.variant_id
22 + group by c.id
23 + `)) as unknown as Array<{ collection_id: string; value_usd: number; cost_basis_usd: number; items: number }>;
24 + let n = 0;
25 + for (const r of rows) {
26 + if (r.items === 0) continue;
27 + await db
28 + .insert(collectionSnapshots)
29 + .values({ collectionId: r.collection_id, date, valueUsd: Number(r.value_usd), costBasisUsd: Number(r.cost_basis_usd), items: r.items })
30 + .onConflictDoUpdate({ target: [collectionSnapshots.collectionId, collectionSnapshots.date], set: { valueUsd: Number(r.value_usd), costBasisUsd: Number(r.cost_basis_usd), items: r.items } });
31 + n++;
32 + }
33 + log.info({ collections: n, date }, 'portfolio snapshots written');
34 + return n;
35 +}
36 +
37 +export async function snapshotOne(db: Database, collectionId: string): Promise<void> {
38 + const date = new Date().toISOString().slice(0, 10);
39 + const rows = (await db.execute(sql`
40 + select coalesce(sum(coalesce(vs.riv_usd, s.riv_usd, ci.manual_value_usd, 0) * greatest(ci.quantity, 1)), 0)::float as value_usd,
41 + coalesce(sum(coalesce(ci.purchase_price_usd, 0) * greatest(ci.quantity, 1)), 0)::float as cost_basis_usd,
42 + count(ci.id)::int as items
43 + from collection_items ci
44 + left join asset_stats s on s.asset_id = ci.asset_id
45 + left join variant_stats vs on vs.variant_id = ci.variant_id
46 + where ci.collection_id = ${collectionId} and ci.sold_at is null
47 + `)) as unknown as Array<{ value_usd: number; cost_basis_usd: number; items: number }>;
48 + const r = rows[0];
49 + if (!r || r.items === 0) return;
50 + await db
51 + .insert(collectionSnapshots)
52 + .values({ collectionId, date, valueUsd: Number(r.value_usd), costBasisUsd: Number(r.cost_basis_usd), items: r.items })
53 + .onConflictDoUpdate({ target: [collectionSnapshots.collectionId, collectionSnapshots.date], set: { valueUsd: Number(r.value_usd), costBasisUsd: Number(r.cost_basis_usd), items: r.items } });
54 + await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
55 +}
added workers/account/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { defineConfig } from 'vitest/config';
2 +
3 +export default defineConfig({
4 + test: { include: ['**/*.test.ts'], root: __dirname },
5 +});
modified workers/package.json +21 −1
@@ -1 +1,21 @@
1 −{"name":"@rareindex/workers","version":"0.1.0","private":true,"type":"module","scripts":{"typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run --passWithNoTests"},"dependencies":{"@rareindex/shared":"workspace:*"},"devDependencies":{"@types/node":"^24.0.0","typescript":"^5.9.3","vitest":"^3.2.0"}}
1 +{
2 + "name": "@rareindex/workers",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "typecheck": "tsc -p tsconfig.json --noEmit",
8 + "test": "vitest run --passWithNoTests"
9 + },
10 + "dependencies": {
11 + "@rareindex/shared": "workspace:*",
12 + "@rareindex/database": "workspace:*",
13 + "@rareindex/notify": "workspace:*",
14 + "drizzle-orm": "^0.45.0"
15 + },
16 + "devDependencies": {
17 + "@types/node": "^24.0.0",
18 + "typescript": "^5.9.3",
19 + "vitest": "^3.2.0"
20 + }
21 +}
\ No newline at end of file
added workers/tsconfig.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "extends": "../tsconfig.base.json",
3 + "compilerOptions": {
4 + "rootDir": ".",
5 + "noEmit": true,
6 + "allowImportingTsExtensions": true
7 + },
8 + "include": ["./**/*.ts"],
9 + "exclude": ["node_modules"]
10 +}
11