Member accounts: auth + MFA via Resend, collections, watchlist, alerts, deal radar, profiles (agent F1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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(/"/g, '"').replace(/&/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 & 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'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 "owner estimate" use a value entered by the collection owner. Items marked "unavailable" 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('<b>x</b>'); | |
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| 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 | ||