Session 49: Complete onboarding flow + name micro-fixes (2185 tests)

Name micro-fixes (close the normalization arc):
- collapseInitials merges "J C Escarra" -> "JC Escarra" (display + key); both
  playerName.js copies. Added mickey:michael nickname.

Onboarding flow (end-to-end, complete):
- Storage: Supabase user_metadata.preferences (no migration).
- API: src/routes/preferences.js GET/POST (requireAuth, admin getUserById/
  updateUserById, partial merge + sanitize) + Next /api/preferences proxy.
- Page: web onboarding/page.tsx — 3 steps (sports >=1 / books skip / bankroll
  presets+custom+skip) -> SIGNAL ACTIVE -> POST onboarding_complete:true -> 2s
  -> /dashboard. Redirects to login when unauthenticated.
- Redirect: dashboard fetches /api/preferences fresh; new+incomplete users
  (created_at >= cutoff) -> /onboarding; never while auth loading; existing
  users exempt.
- Personalization: Slate default tab = prefs.sports[0]; preferred books glow in
  the card lines grid (lib/books isPreferredBook, threaded dash->Slate->GameCard).
- Settings: PREFERENCES section loads + edits + saves sports/books/limit.

Backend 2156 -> 2185 tests (+29), 184 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-19 10:20:55 -04:00
parent 91b03c4044
commit 3b47b783dc
17 changed files with 687 additions and 17 deletions
+2
View File
@@ -123,6 +123,8 @@ app.use('/api/scan', scanRoutes);
app.use('/api/movements', movementsRoutes);
app.use('/api/alerts', alertsRoutes);
app.use('/api/bets', betsRoutes);
// Session 49 — per-user onboarding preferences (auth-gated, user_metadata).
app.use('/api/preferences', require('./routes/preferences'));
app.use('/api/stripe', stripeRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/props', propsRoutes);
+79
View File
@@ -0,0 +1,79 @@
'use strict';
/**
* /api/preferences (Session 49) — per-user onboarding preferences.
*
* Stored in Supabase auth `user_metadata.preferences` (NO migration / extra
* table). Auth-gated by the shared `requireAuth` (req.user = the app `users`
* row; req.user.id is the auth user id). Reads/writes via the service client's
* admin API. POST is a PARTIAL merge — only the keys present in the body change.
*
* Shape: { sports: string[], books: string[], weekly_limit: number|null,
* onboarding_complete: boolean }
*/
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
router.use(requireAuth);
const DEFAULTS = { sports: [], books: [], weekly_limit: null, onboarding_complete: false };
const VALID_SPORTS = new Set(['mlb', 'nba', 'wnba', 'soccer']);
/** Keep only valid keys/values; only present keys are returned (partial). */
function sanitizePrefs(p = {}) {
const out = {};
if (Array.isArray(p.sports)) {
out.sports = [...new Set(p.sports.map((s) => String(s).toLowerCase()).filter((s) => VALID_SPORTS.has(s)))];
}
if (Array.isArray(p.books)) {
out.books = [...new Set(p.books.map((b) => String(b).slice(0, 40)))].slice(0, 12);
}
if (Object.prototype.hasOwnProperty.call(p, 'weekly_limit')) {
const n = Number(p.weekly_limit);
out.weekly_limit = p.weekly_limit === null ? null : Number.isFinite(n) ? Math.max(0, Math.min(100000, n)) : null;
}
if (typeof p.onboarding_complete === 'boolean') out.onboarding_complete = p.onboarding_complete;
return out;
}
async function readPrefs(sb, userId) {
const { data, error } = await sb.auth.admin.getUserById(userId);
if (error) throw error;
const prefs = (data && data.user && data.user.user_metadata && data.user.user_metadata.preferences) || {};
const meta = (data && data.user && data.user.user_metadata) || {};
return { prefs: { ...DEFAULTS, ...prefs }, meta };
}
// GET /api/preferences
router.get('/', async (req, res) => {
try {
const sb = getSupabaseServiceClient();
const { prefs } = await readPrefs(sb, req.user.id);
return res.json(prefs);
} catch (err) {
console.error('[preferences/get]', err.message);
return res.status(503).json({ error: 'Could not load preferences' });
}
});
// POST /api/preferences — partial merge.
router.post('/', async (req, res) => {
try {
const sb = getSupabaseServiceClient();
const { prefs: existing, meta } = await readPrefs(sb, req.user.id);
const merged = { ...existing, ...sanitizePrefs(req.body || {}) };
const { error } = await sb.auth.admin.updateUserById(req.user.id, {
user_metadata: { ...meta, preferences: merged },
});
if (error) throw error;
return res.json({ ok: true, preferences: merged });
} catch (err) {
console.error('[preferences/post]', err.message);
return res.status(503).json({ error: 'Could not save preferences' });
}
});
module.exports = router;
+9 -3
View File
@@ -37,15 +37,21 @@ const NICKNAMES = {
charlie: 'charles', chuck: 'charles', rick: 'richard', dick: 'richard',
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
steve: 'steven', tim: 'timothy', frank: 'francis',
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
};
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra",
// "J C E Escarra" → "JCE Escarra" (PropLine sends space-separated initials).
function collapseInitials(s) {
return s.replace(/\b([A-Za-z])(?: ([A-Za-z]))+\b(?=\s|$)/g, (m) => m.replace(/ /g, ''));
}
function normalizeName(raw) {
const display = String(raw == null ? '' : raw)
const display = collapseInitials(String(raw == null ? '' : raw)
.replace(/\s*\([^)]*\)\s*/g, ' ') // strip parenthetical team tags "(STL)"
.replace(/\./g, '') // strip dots: "A.J." → "AJ", "Jr." → "Jr"
.replace(/\s+/g, ' ')
.trim();
.trim());
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const key = folded.split(' ').filter((t) => t && !SUFFIXES.has(t)).join(' ');
return { display, key };