import { redirect } from '@sveltejs/kit'; import { createPbClient, createSuperClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; import { ensureFamAccess } from '$lib/server/access'; import { seedDemoCompletions } from '$lib/server/migrate'; import { getPlatformFlags } from '$lib/server/platform'; import { scanChildSessions } from '$lib/server/session'; async function paydayCheck(famId: string, pbToken: string) { try { // Best-effort: never block render on the payday heartbeat. const s = createServices(createPbClient(pbToken)); await s.fam.payday(famId); } catch {} } function actorFrom(session: { famId: string; id: string; role: string; name?: string; color?: string; }): ChatActor { return { id: session.id, type: session.role === 'parent' ? 'admin' : 'member', name: session.name || '', color: session.color || '#6366f1' }; } async function resolveChatIdentity( session: { famId: string; id: string; role: string; name?: string; color?: string; }, pbToken: string ) { try { const s = createServices(createPbClient(pbToken)); const actor = actorFrom(session); return await s.chat.me(session.famId, actor); } catch { return null; } } export async function load(event) { // Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent). if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) { seedDemoCompletions().catch(() => {}); } const session = event.locals.user; const role = session?.role || 'child'; const isParent = role === 'parent'; const pbToken = event.locals.pbToken || ''; const deviceChildIds = scanChildSessions(event.cookies); // ── Shared-device picker mode ── // The device holds child sessions but none is active (per-profile logout). // Anything except the join flow lands on the standalone picker. const paramFam = event.params.fam; const isJoinPage = (event.url.pathname || '').split('/').includes('join'); if (!session && deviceChildIds.length > 0 && !isJoinPage) { if (!(event.url.pathname || '').endsWith('/switch')) { throw redirect(303, `/${encodeURIComponent(paramFam)}/switch`); } let pickerFamName = paramFam || ''; let pickerChildren: { id: string; name: string; color: string; username: string; }[] = []; try { const pb = await createSuperClient(); const fam = await pb .collection('fams') .getFirstListItem(`slug='${paramFam}'`) .catch(() => null); if (fam) { pickerFamName = fam.name || fam.slug; const users = await pb.collection('users').getFullList({ filter: `id in ('${deviceChildIds.join("','")}') && role='child'` }); pickerChildren = (users || []).map((u: any) => ({ id: u.id, name: u.name || u.username || '', color: u.color || '#6366f1', username: u.username || '' })); } } catch {} return { famSlug: paramFam || '', session: null, isParent, role, famId: '', chat: null, pbToken: '', fam: null, famAccess: { disabled: false, mode: 'none', reason: '' }, demoMode: (await getPlatformFlags()).demo, picker: true, pickerFamName, pickerChildren, deviceChildIds, lockMins: 0 }; } let famId = ''; let chat: { famId: string; actor: ChatActor; members?: { id: string; name: string; color: string; role: 'parent' | 'child' }[]; } | null = null; let fam: any = null; let famAccess = { disabled: false, mode: 'none' as 'none' | 'code' | 'sub' | 'canceled', reason: '' }; let lockMins = 0; if (session && pbToken) { famId = session.famId; await paydayCheck(famId, pbToken); chat = await resolveChatIdentity(session, pbToken); // fams is superadmin-only (non-realtime). Fetched server-side for both // roles; also recomputes + persists the derived `active` flag. const res = await ensureFamAccess(famId).catch(() => null); if (res) { fam = res.fam; famAccess = res.access; } // Shared-device idle lock setting (0 = off; missing row defaults to 10 // min). Superuser read — the settings rules are parent-oriented and // children must see it too. try { const pb = await createSuperClient(); const settings = await pb .collection('settings') .getFullList({ filter: `famId='${famId}'` }) .catch(() => []); const row = (settings as any[])?.[0]; lockMins = row && row.lockMins != null ? Number(row.lockMins) : 10; } catch {} // Canonical URL: the [fam] segment must be the family SLUG, never the PB // id. If someone lands on /{famId}/... (a stale shortcut, bookmark, or a // login that fell back to the id), rewrite the first path segment to the // slug so the id is replaced everywhere it'd otherwise persist. const canonicalSlug = fam?.slug; if (canonicalSlug && paramFam && paramFam !== canonicalSlug) { const rest = event.url.pathname.replace(`/${paramFam}`, '') || '/'; const qs = event.url.search; throw redirect(303, `/${encodeURIComponent(canonicalSlug)}${rest}${qs}`); } } const demoMode = (await getPlatformFlags()).demo; return { // Canonical fam slug — from the URL param ([fam] routes). Client code // reads page.data.famSlug; never copy it into local $state. famSlug: paramFam || '', session: session ? { famId: session.famId, userId: session.id, memberName: session.name, memberColor: session.color || '', memberPattern: session.pattern || '', memberThemeSize: session.themeSize || '', memberThemeOpacity: session.themeOpacity || '', username: session.username || '', role: session.role } : null, isParent, role, famId, chat, pbToken, fam, famAccess, demoMode, picker: false, pickerFamName: '', pickerChildren: [], deviceChildIds, lockMins, // Per-device shared-computer flag (cookie mirror of the localStorage // flag the TopNav toggle writes). Server can only hint — the client // re-reads localStorage on hydration. sharedDevice: event.cookies.get('fam_shared_device') === '1' }; }