204 lines
5.9 KiB
TypeScript
204 lines
5.9 KiB
TypeScript
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'
|
|
};
|
|
}
|