S10 (a1): public ledger profiles v1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,9 @@ app.use('/api/team', require('./routes/team'));
|
||||
// (settled snapshot grades vs real results). Public, cache-only.
|
||||
app.use('/api/accuracy', require('./routes/accuracy'));
|
||||
app.use('/api/ledger', require('./routes/ledger'));
|
||||
// A1 Session 10 — public ledger profiles: claim a handle, one explicit
|
||||
// publish toggle, and the ENTIRE settled record on a public page.
|
||||
app.use('/api/profiles', require('./routes/profiles'));
|
||||
const gameLinesRoutes = require('./routes/gameLines');
|
||||
app.use('/api/gamelines', gameLinesRoutes);
|
||||
const streaksRoutes = require('./routes/streaks');
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* /api/profiles — public ledger profiles v1 (A1 Session 10).
|
||||
*
|
||||
* Stage-3 seed: a user claims a handle and publishes their ENTIRE settled
|
||||
* ledger record — wins and misses, nothing curated. Strava for betting.
|
||||
*
|
||||
* GET /me (auth) — own public_profiles row (or null).
|
||||
* POST /me (auth) — upsert own { handle, published }. Handle must
|
||||
* match ^[a-z0-9_]{3,20}$; a handle owned by
|
||||
* another user → 409. Service-role write only
|
||||
* (the table has no client write policies).
|
||||
* GET /:handle (public) — the published record: the user-scoped 30d
|
||||
* aggregate (same n≥20 gate as the model record)
|
||||
* + settled rows, newest 50, same columns as
|
||||
* /api/ledger.
|
||||
*
|
||||
* PRIVACY: PRIVATE BY DEFAULT — `published` only flips via the explicit
|
||||
* toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle
|
||||
* return the byte-identical 404 body.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const ledgerService = require('../services/ledgerService');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
|
||||
// Same 404 body for unknown AND unpublished — never confirm a handle exists.
|
||||
const NOT_FOUND = { error: 'Profile not found' };
|
||||
// Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS).
|
||||
const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
|
||||
const ENTRY_LIMIT = 50;
|
||||
|
||||
function sbOrNull() {
|
||||
try {
|
||||
if (!ledgerService.__internals.isConfigured()) return null;
|
||||
return require('../utils/supabase').getSupabaseServiceClient();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Own row — the owner sees it published or not.
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.json({ profile: null });
|
||||
try {
|
||||
const { data, error } = await sb.from('public_profiles')
|
||||
.select('handle, published, created_at')
|
||||
.eq('user_id', req.user.id)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
return res.json({ profile: data || null });
|
||||
} catch (err) {
|
||||
console.error('[profiles/me]', err.message);
|
||||
return res.status(200).json({ profile: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Claim/update the handle + the ONE explicit publish toggle.
|
||||
router.post('/me', requireAuth, async (req, res) => {
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.status(503).json({ error: 'Profiles are unavailable right now' });
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const handle = String(body.handle || '').trim().toLowerCase();
|
||||
if (!HANDLE_RE.test(handle)) {
|
||||
return res.status(400).json({ error: 'Handle must be 3-20 characters: a-z, 0-9, underscore' });
|
||||
}
|
||||
// PRIVATE BY DEFAULT: published only flips when the body says true.
|
||||
const published = body.published === true;
|
||||
const { error } = await sb.from('public_profiles')
|
||||
.upsert({ user_id: req.user.id, handle, published }, { onConflict: 'user_id' });
|
||||
if (error) {
|
||||
// Unique violation on `handle` → someone else owns it.
|
||||
if (error.code === '23505' || /duplicate|unique/i.test(error.message || '')) {
|
||||
return res.status(409).json({ error: 'That handle is taken' });
|
||||
}
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return res.json({ ok: true, profile: { handle, published } });
|
||||
} catch (err) {
|
||||
console.error('[profiles/post]', err.message);
|
||||
return res.status(503).json({ error: 'Could not save profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// The public record. Cache lightly; the settle pass updates rows a few
|
||||
// times a day, not per-second.
|
||||
router.get('/:handle', async (req, res) => {
|
||||
const handle = String(req.params.handle || '').trim().toLowerCase();
|
||||
// Invalid shape can't exist (DB CHECK) → same 404, no query needed.
|
||||
if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND);
|
||||
const sb = sbOrNull();
|
||||
if (!sb) return res.status(404).json(NOT_FOUND);
|
||||
try {
|
||||
const { data: row, error } = await sb.from('public_profiles')
|
||||
.select('user_id, handle, published')
|
||||
.eq('handle', handle)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
// Unknown and unpublished are indistinguishable from outside.
|
||||
if (!row || row.published !== true) return res.status(404).json(NOT_FOUND);
|
||||
|
||||
// Same aggregate machinery as the model record — same window, same
|
||||
// n≥20 gate — scoped to this user's own rows.
|
||||
const aggregate = await ledgerService.getModelAggregate({ userId: row.user_id, sb });
|
||||
|
||||
// ALL settled reads, misses included — nothing curated.
|
||||
const { data: entries, error: entriesErr } = await sb.from('ledger_entries')
|
||||
.select(ROW_COLUMNS)
|
||||
.eq('user_id', row.user_id)
|
||||
.not('outcome', 'is', null)
|
||||
.order('graded_at', { ascending: false })
|
||||
.limit(ENTRY_LIMIT);
|
||||
if (entriesErr) throw new Error(entriesErr.message);
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=60');
|
||||
return res.json({
|
||||
handle: row.handle,
|
||||
aggregate,
|
||||
entries: entries || [],
|
||||
min_sample: ledgerService.MIN_AGG_SAMPLE,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[profiles/handle]', err.message);
|
||||
return res.status(404).json(NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -386,6 +386,11 @@ async function countRowsForDate(gameDate, opts = {}) {
|
||||
* 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
|
||||
* beat-the-close rate, pending count. Percentages are null below
|
||||
* MIN_AGG_SAMPLE — the UI must show "record building" instead.
|
||||
*
|
||||
* A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)`
|
||||
* scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same
|
||||
* n≥20 gate) over one user's own ledger, powering public profiles. The
|
||||
* public default is untouched.
|
||||
*/
|
||||
async function getModelAggregate(opts = {}) {
|
||||
const empty = {
|
||||
@@ -400,8 +405,9 @@ async function getModelAggregate(opts = {}) {
|
||||
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
let settledQ = sb.from('ledger_entries')
|
||||
.select('outcome, clv_result, player_key, grade')
|
||||
.is('user_id', null)
|
||||
.select('outcome, clv_result, player_key, grade');
|
||||
settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
|
||||
settledQ = settledQ
|
||||
.not('outcome', 'is', null)
|
||||
.gte('game_date', since)
|
||||
.limit(AGG_FETCH_LIMIT);
|
||||
@@ -412,9 +418,9 @@ async function getModelAggregate(opts = {}) {
|
||||
if (error) return { ...empty, error: error.message };
|
||||
|
||||
let pendingQ = sb.from('ledger_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.is('user_id', null)
|
||||
.is('outcome', null);
|
||||
.select('id', { count: 'exact', head: true });
|
||||
pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null);
|
||||
pendingQ = pendingQ.is('outcome', null);
|
||||
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
|
||||
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
|
||||
if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
|
||||
|
||||
Reference in New Issue
Block a user