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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user