migrate from hono

This commit is contained in:
JCEEE
2026-08-17 07:41:26 +01:00
parent e2b99c45b6
commit 5204e7bdbc
56 changed files with 1815 additions and 3357 deletions
-2
View File
@@ -10,8 +10,6 @@ const withDefault = (value: string) => ({
} as const);
export const variables = defineEnvVars({
// SSR → Hono proxy (loopback, proxy runs on the same host as SSR).
PROXY_URL: { public: true, schema: withDefault('http://127.0.0.1:3456') },
SERVER_IP: { public: true, schema: withDefault('192.168.1.225') },
// PB superuser creds (server-only).
PB_EMAIL: { public: false, schema: withDefault('debug@famchamp.dev') },
+4
View File
@@ -3,6 +3,10 @@ import { createPbClient } from '$lib/server/pocketbase';
import { SESSION_COOKIE, setSessionCookie, clearSessionCookie } from '$lib/server/session';
import type { SessionUser } from '$lib/server/types';
import { handleOf } from '@shared/slugify';
import { migrateOnBoot } from '$lib/server/migrate-boot';
// Run the PB schema migration once at server boot (idempotent).
void migrateOnBoot();
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
+2 -9
View File
@@ -1,5 +1,4 @@
// Client-only. All /api calls go same-origin (vite proxy in dev, nginx in
// prod). Server-side (SSR) calls use PROXY_URL from $app/env/public instead.
// Client-only. All /api calls go same-origin (SvelteKit in dev and prod).
const BASE_URL = '';
async function memberFetch<T = unknown>(
@@ -27,16 +26,10 @@ export const memberApi = {
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
},
async myChores(token: string, famId: string) {
return memberFetch('POST', '/api/members/my-chores', token, famId);
},
async claimReward(token: string, famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
},
async requestAllRewards(token: string, famId: string) {
return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId);
},
async payday(token: string, famId: string) {
return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId);
},
};
};
-246
View File
@@ -1,246 +0,0 @@
import type { RequestEvent } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
function sessionHeaders(event: RequestEvent): Record<string, string> {
const u = event.locals.user;
if (!u) return {};
return {
'x-session-famid': u.famId,
'x-session-userid': u.id,
'Content-Type': 'application/json'
};
}
async function request(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
) {
const res = await fetch(`${PROXY_URL}${path}`, {
method,
headers: headers || { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
let data: any = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!res.ok) {
const msg = data?.error || data?.message || `${method} ${path} failed (HTTP ${res.status})`;
throw new Error(msg);
}
return data;
}
export const hono = {
admin: {
async list(event: RequestEvent, resource: string, famId: string) {
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
},
async create(
event: RequestEvent,
resource: string,
famId: string,
data: Record<string, unknown>
) {
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
},
async update(
event: RequestEvent,
resource: string,
famId: string,
id: string,
data: Record<string, unknown>
) {
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
},
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
return request(
'DELETE',
`/api/admin/${famId}/${resource}/${id}`,
undefined,
sessionHeaders(event)
);
},
async renameFam(event: RequestEvent, famId: string, name: string) {
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
},
async updatePayday(
event: RequestEvent,
famId: string,
payday: number,
paydayTime?: string,
timezone?: string
) {
return request(
'PATCH',
`/api/admin/${famId}/fam`,
{
payday,
...(paydayTime !== undefined ? { paydayTime } : {}),
...(timezone !== undefined ? { timezone } : {})
},
sessionHeaders(event)
);
},
async fam(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
},
async verify(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
},
async weeklySummary(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
},
async completions(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event));
},
async rewards(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
},
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request(
'POST',
`/api/admin/${famId}/rewards/${rewardId}/claim`,
undefined,
sessionHeaders(event)
);
},
async issueAllRewards(event: RequestEvent, famId: string, memberId: string) {
return request(
'POST',
`/api/admin/${famId}/rewards/issue-all`,
{ memberId },
sessionHeaders(event)
);
},
async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
},
async bonusConfigProgress(event: RequestEvent, famId: string) {
return request(
'GET',
`/api/admin/${famId}/bonus-configs/progress`,
undefined,
sessionHeaders(event)
);
},
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/evaluate`,
{ configId },
sessionHeaders(event)
);
},
async triggerBonusConfig(
event: RequestEvent,
famId: string,
configId: string,
memberId?: string
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/trigger`,
{ memberId },
sessionHeaders(event)
);
},
async assignBonusConfig(
event: RequestEvent,
famId: string,
configId: string,
data: Record<string, unknown>
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/assign`,
data,
sessionHeaders(event)
);
},
async completeBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/complete`,
undefined,
sessionHeaders(event)
);
},
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/destroy`,
undefined,
sessionHeaders(event)
);
},
async bonusConfigTallies(event: RequestEvent, famId: string) {
return request(
'GET',
`/api/admin/${famId}/bonus-configs/tallies`,
undefined,
sessionHeaders(event)
);
},
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
return request(
'POST',
`/api/admin/${famId}/completions/${completionId}/revoke`,
undefined,
sessionHeaders(event)
);
},
async memberChores(event: RequestEvent, famId: string, memberId: string) {
return request(
'GET',
`/api/admin/${famId}/members/${memberId}/chores`,
undefined,
sessionHeaders(event)
);
},
async updateMember(
event: RequestEvent,
famId: string,
memberId: string,
data: Record<string, unknown>
) {
return request(
'PATCH',
`/api/admin/${famId}/members/${memberId}`,
data,
sessionHeaders(event)
);
},
async getProfile(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
},
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
},
async updateFam(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event));
},
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
return request(method, path, body, sessionHeaders(event));
},
async settings(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/settings`, undefined, sessionHeaders(event));
},
async updateSettings(event: RequestEvent, famId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/settings`, data, sessionHeaders(event));
},
async eowPreview(event: RequestEvent, famId: string) {
return request(
'GET',
`/api/admin/${famId}/debug/eow-preview`,
undefined,
sessionHeaders(event)
);
}
}
};
+16
View File
@@ -0,0 +1,16 @@
import { migrate } from './migrate';
// Runs the PocketBase schema migration once per server process, at boot.
// Idempotent (migrate() diffs against existing collections), so re-running on
// dev HMR or restarts is a cheap no-op. Errors are logged, not thrown, so a
// migration hiccup never takes the server down.
let done: Promise<void> | null = null;
export function migrateOnBoot(): Promise<void> {
if (!done) {
done = migrate().catch((e) => {
console.error('[migrate] failed:', e);
});
}
return done;
}
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -36,9 +36,8 @@ export async function createSuperClient() {
return pb;
}
// Superuser CRUD facade, built on the memoized SDK superuser client. Replaces
// the old raw-fetch `pb-admin.ts`/`ensureToken` path so all server PB access
// (authenticated user + superuser) lives in this one module.
// Superuser CRUD facade, built on the memoized SDK superuser client. All
// server PB access (authenticated user + superuser) lives in this one module.
export const pbAdmin = {
async getList(collection: string, filter = '') {
const pb = await createSuperClient();
+28
View File
@@ -0,0 +1,28 @@
import { error } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase';
import type { RequestEvent } from '@sveltejs/kit';
// Resolve the acting user's PB client from a request: prefer the Authorization
// Bearer token (sent by the browser member API), else the httpOnly session
// cookie. Identity comes from the verified session. Used by the in-app /api/*
// routes that replaced the Hono member endpoints.
export function actingClient(event: RequestEvent) {
const token =
event.request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ||
event.locals.pbToken ||
'';
const u = event.locals.user;
if (!u || !token) throw error(401, 'Unauthorized');
return {
pb: createPbClient(token),
famId: u.famId,
userId: u.id,
role: u.role,
name: u.name || '',
color: u.color || '#6366f1'
};
}
export function err(e: unknown) {
return error(500, e instanceof Error ? e.message : 'Internal error');
}
+362
View File
@@ -0,0 +1,362 @@
import {
todayInTz,
resolveTz,
periodStart,
periodEnd,
nextPaydayAfter,
weekStart
} from '@shared/timezone';
import { famMeta } from './fam';
function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto');
}
function claimableStamp(cfg: any, payday: number, tz: string) {
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
return { claimable: 'immediate', settleDate: '' };
}
const now = todayInTz(resolveServerTz(tz));
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
const end = periodEnd(cfg.period, start);
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) };
}
export async function evaluateFam(pb: any, famId: string) {
let configs: any[] = [];
try {
configs = await pb
.collection('bonus_configs')
.getFullList({ filter: `famId = '${famId}' && status = 'active' && type != 'manual'` });
} catch {
return;
}
if (!configs.length) return;
const [allMembers, allAssigned, allCompletions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]);
let allRewards: any[] = [];
try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {}
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
for (const cfg of configs) {
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : '';
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : '';
const periodCompletions = cfg.period
? allCompletions.filter(
(c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd
)
: allCompletions;
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
let createdReward = false;
const rewardData = (memberId: string) => {
const label =
cfg.rewardType === 'cash'
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
: `${cfg.name} – ${cfg.rewardValue}`;
const now = new Date().toISOString();
return {
famId,
memberId,
bonusConfigId: cfg.id,
label,
value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null,
date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval)
};
};
if (cfg.target === 'individual') {
const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers;
for (const m of targetMembers) {
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
let current = 0;
if (cfg.type === 'threshold') {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = memberCompletions.length;
}
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
if (memberReward && !achieved) {
if (memberReward.status !== 'claimed') {
try {
await pb.collection('rewards').delete(memberReward.id);
} catch {}
}
continue;
}
if (memberReward) continue;
if (achieved) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'collaborative') {
const allMemberIds = allMembers.map((m: any) => m.id);
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
let total = 0;
if (cfg.type === 'threshold') {
total = teamCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
total = teamCompletions.length;
}
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== 'claimed') {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
}
continue;
}
if (achieved && existingRewards.length === 0) {
for (const m of allMembers) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'competitive') {
const scored = allMembers.map((m: any) => {
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
let current = 0;
if (cfg.type === 'threshold') {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = memberCompletions.length;
}
return { memberId: m.id, name: m.name, current };
});
const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue));
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
if (!stillValid && existing.status !== 'claimed') {
try {
await pb.collection('rewards').delete(existing.id);
} catch {}
}
}
if (winner && existingRewards.length === 0) {
await pb.collection('rewards').create(rewardData(winner.memberId));
createdReward = true;
}
}
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
try {
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
} catch {}
}
}
}
export async function evaluateAll(pb: any, famId: string) {
await evaluateFam(pb, famId);
return { evaluated: true };
}
export async function progress(pb: any, famId: string) {
const { payday, tz } = await famMeta(pb, famId);
let configsData: any[] = [];
try {
configsData = await pb
.collection('bonus_configs')
.getFullList({ filter: `famId = '${famId}' && status = 'active'` });
} catch {}
const [members, assigned, completions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]);
const assignedList = assigned;
const completionsList = completions;
let allRewards: any[] = [];
try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {}
const result: any[] = [];
for (const cfg of configsData) {
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
const periodCompletions = cfg.period
? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd)
: completionsList;
const progressRows: any[] = [];
if (cfg.target === 'collaborative') {
const teamCompletions = periodCompletions.filter((c: any) =>
members.some((m: any) => m.id === c.memberId)
);
let teamCurrent = 0;
if (cfg.type === 'threshold') {
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
teamCurrent = teamCompletions.length;
}
const teamReward = cfgRewards[0];
progressRows.push({
memberId: '__team__',
memberName: 'Team Total',
memberColor: '#8b5cf6',
current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0,
reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null,
state: teamReward ? teamReward.status : 'pending',
achieved: teamReward ? true : false
});
}
if (cfg.target !== 'collaborative') {
const progressMembers =
cfg.target === 'individual' && cfg.memberId
? members.filter((m: any) => m.id === cfg.memberId)
: members;
for (const m of progressMembers) {
const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
const memberReward = cfgRewards.find((r: any) => r.memberId === m.id);
let current = 0;
if (cfg.type === 'threshold') {
current = memberCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = memberCompletions.length;
} else if (cfg.type === 'manual') {
current = 0;
}
progressRows.push({
memberId: m.id,
memberName: m.name,
memberColor: m.color,
current,
criteriaValue: cfg.criteriaValue || 0,
reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null,
state: memberReward ? memberReward.status : 'pending',
achieved: memberReward ? true : false
});
}
}
result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd });
}
return result;
}
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
const configs = await pb
.collection('bonus_configs')
.getFullList({ filter: `famId = '${famId}' && id = '${configId}' && status = 'active'` });
const cfg = configs?.[0];
if (!cfg) throw new Error('Bonus config not found');
if (cfg.type !== 'manual') throw new Error('Only manual-type configs can be triggered');
const existingRewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && bonusConfigId = '${cfg.id}'`
});
const members = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}' && role = 'child'` });
const targetMembers: any[] = [];
if (cfg.target === 'competitive' || cfg.target === 'collaborative') {
for (const m of members) targetMembers.push(m);
} else if (cfg.target === 'individual') {
const targetId = cfg.memberId || memberId;
if (!targetId) throw new Error('memberId required for individual trigger');
const member = members.find((m: any) => m.id === targetId);
if (!member) throw new Error('Member not found');
targetMembers.push(member);
}
for (const m of targetMembers) {
const memberRewards = existingRewards.filter((r: any) => r.memberId === m.id);
if (cfg.occurrence === 'once' && memberRewards.some((r: any) => r.status === 'unclaimed')) {
throw new Error(`Already issued and pending for ${m.name}`);
}
if (cfg.occurrence === 'recurring' && cfg.period) {
const { payday, tz } = await famMeta(pb, famId);
const pStart = periodStart(cfg.period, payday, tz);
const pEnd = periodEnd(cfg.period, pStart);
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
if (periodRewards.length > 0) {
throw new Error(
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
);
}
}
}
const created: any[] = [];
for (const m of targetMembers) {
const label =
cfg.rewardType === 'cash'
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
: `${cfg.name} – ${cfg.rewardValue}`;
const now = new Date().toISOString();
const record = await pb.collection('rewards').create({
famId,
memberId: m.id,
bonusConfigId: cfg.id,
label,
value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null,
date: now.slice(0, 10),
claimable: 'immediate',
settleDate: ''
});
created.push(record);
}
if (cfg.occurrence === 'once') {
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
}
return { triggered: true, created: created.length, records: created };
}
+55
View File
@@ -0,0 +1,55 @@
export type ChatActor = {
id: string;
type: 'admin' | 'member';
name: string;
color: string;
};
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
return { famId, actor };
}
export async function send(
pb: any,
famId: string,
actor: ChatActor,
body: { content?: string; clientId?: string }
) {
const content = (body.content || '').trim();
if (!content) throw new Error('content required');
return pb.collection('messages').create({
famId,
authorType: actor.type,
authorId: actor.id,
authorName: actor.name,
authorColor: actor.color,
content,
createdAt: new Date().toISOString(),
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
});
}
export async function typing(
pb: any,
famId: string,
actor: ChatActor,
body: { typing?: boolean }
) {
const existing = await pb.collection('chat_typing').getFullList({
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
});
const row = {
famId,
actorId: actor.id,
actorType: actor.type,
authorName: actor.name,
authorColor: actor.color,
typing: !!body.typing
};
if (existing?.length) {
await pb.collection('chat_typing').update(existing[0].id, row);
} else {
await pb.collection('chat_typing').create(row);
}
return { ok: true };
}
@@ -0,0 +1,31 @@
import { famMeta } from './fam';
// Aggregated kanban payload for a single member (child session).
export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]);
const { payday, paydayTime, tz } = await famMeta(pb, famId);
let settings: any = {};
try {
const s = await pb
.collection('settings')
.getFullList({ filter: `famId = '${famId}'` });
settings = s?.[0] || {};
} catch {}
return {
templates,
assigned,
completions,
rewards,
bonusConfigs,
payday,
paydayTime,
timezone: tz,
simulateEow: !!settings.simulateEow
};
}
@@ -0,0 +1,78 @@
import { periodWindow } from '@shared/timezone';
import { famMeta } from './fam';
import { evaluateFam } from './bonuses';
export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]);
const { payday, paydayTime, tz } = await famMeta(pb, famId);
let settings: any = {};
try {
const s = await pb
.collection('settings')
.getFullList({ filter: `famId = '${famId}'` });
settings = s?.[0] || {};
} catch {}
return {
templates,
assigned,
completions,
rewards,
bonusConfigs,
payday,
paydayTime,
timezone: tz,
simulateEow: !!settings.simulateEow
};
}
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) {
const { assignedChoreId, date } = body;
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
const choreList = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
const chore = choreList?.[0];
const isTodo = chore?.isTodo;
let filter: string;
if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else {
const { payday, tz } = await famMeta(pb, famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
}
const existing = await pb
.collection('completions')
.getFullList({ filter });
if (existing?.length > 0) {
await pb.collection('completions').delete(existing[0].id);
evaluateFam(pb, famId).catch(() => {});
return { completed: false };
}
const record = await pb.collection('completions').create({
famId,
memberId,
assignedChoreId,
date,
completedAt: new Date().toISOString()
});
evaluateFam(pb, famId).catch(() => {});
return { completed: true, record };
}
export async function revoke(pb: any, famId: string, completionId: string) {
const completions = await pb
.collection('completions')
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
if (!completions?.length) throw new Error('Completion not found');
await pb.collection('completions').delete(completionId);
evaluateFam(pb, famId).catch(() => {});
return { revoked: true };
}
+61
View File
@@ -0,0 +1,61 @@
// Generic per-resource CRUD, mirroring the old proxy's /api/admin/:famId/<resource>.
// Kept generic because many pages (chore templates, bonus templates, members,
// assigned-chores, seasons) only need plain list/create/update/delete.
const RESOURCES: Record<
string,
{ col: string; listFilter: (famId: string) => string; bonus?: 'config' | 'template' }
> = {
'chore-templates': { col: 'chore_templates', listFilter: (f) => `famId = '${f}'` },
members: { col: 'users', listFilter: (f) => `famId = '${f}' && role = 'child'` },
'assigned-chores': { col: 'assigned_chores', listFilter: (f) => `famId = '${f}'` },
'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `famId = '${f}'`, bonus: 'template' },
'bonus-configs': { col: 'bonus_configs', listFilter: (f) => `famId = '${f}'`, bonus: 'config' },
completions: { col: 'completions', listFilter: (f) => `famId = '${f}'` },
rewards: { col: 'rewards', listFilter: (f) => `famId = '${f}'` },
seasons: { col: 'seasons', listFilter: (f) => `famId = '${f}'` }
};
function res(resource: string) {
const r = RESOURCES[resource];
if (!r) throw new Error(`Unknown resource: ${resource}`);
return r;
}
function bonusBody(c: { bonus?: 'config' | 'template' }, data: Record<string, unknown>) {
const body: Record<string, unknown> = { ...data };
if (body.occurrence === 'once') body.period = '';
if (c.bonus === 'config') body.status = 'active';
return body;
}
export async function list(pb: any, resource: string, famId: string) {
const c = res(resource);
return pb.collection(c.col).getFullList({ filter: c.listFilter(famId) });
}
export async function create(
pb: any,
resource: string,
famId: string,
data: Record<string, unknown>
) {
const c = res(resource);
return pb.collection(c.col).create({ famId, ...bonusBody(c, data) });
}
export async function update(
pb: any,
resource: string,
famId: string,
id: string,
data: Record<string, unknown>
) {
const c = res(resource);
return pb.collection(c.col).update(id, bonusBody(c, data));
}
export async function remove(pb: any, resource: string, famId: string, id: string) {
const c = res(resource);
return pb.collection(c.col).delete(id);
}
+60
View File
@@ -0,0 +1,60 @@
// Dev/test helper (settings debugMode) — random completions for a range of days.
export async function generateData(pb: any, famId: string, days = 7) {
const [members, templates] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` })
]);
if (!members.length) return { error: 'No members found' };
if (!templates.length) return { error: 'No templates found' };
let completionsCreated = 0;
const today = new Date();
for (let d = 0; d < days; d++) {
const date = new Date(today);
date.setDate(date.getDate() - d);
const dateStr = date.toISOString().slice(0, 10);
for (const m of members) {
const completionRate = 0.5 + Math.random() * 0.5;
for (const t of templates) {
if (Math.random() > completionRate) continue;
let assigned = await pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && memberId = '${m.id}' && templateId = '${t.id}'`
});
let assignedId;
if (assigned?.length > 0) {
assignedId = assigned[0].id;
} else {
const record = await pb.collection('assigned_chores').create({
famId,
memberId: m.id,
templateId: t.id,
frequency: t.defaultFrequency || 'daily',
type: t.defaultType || 'points',
value: t.defaultValue || 10
});
assignedId = record.id;
}
const existing = await pb.collection('completions').getFullList({
filter: `famId = '${famId}' && memberId = '${m.id}' && assignedChoreId = '${assignedId}' && date = '${dateStr}'`
});
if (existing?.length > 0) continue;
await pb.collection('completions').create({
famId,
memberId: m.id,
assignedChoreId: assignedId,
date: dateStr
});
completionsCreated++;
}
}
}
return { completionsCreated, days };
}
+452
View File
@@ -0,0 +1,452 @@
import {
weekStart,
addDaysStr,
resolveTz,
periodStart,
periodEnd,
wallClockToUtc
} from '@shared/timezone';
import { slugify } from '@shared/slugify';
import { evaluateFam } from './bonuses';
function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto');
}
export async function famMeta(pb: any, famId: string) {
const fam = await pb.collection('fams').getOne(famId);
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveServerTz(fam.timezone)
};
}
export async function getFam(pb: any, famId: string) {
const fam = await pb.collection('fams').getOne(famId);
return {
name: fam.name,
slug: fam.slug,
payday: fam.payday,
paydayTime: fam.paydayTime || '18:00',
timezone: fam.timezone || 'auto'
};
}
export async function patchFam(pb: any, famId: string, body: Record<string, unknown>) {
if (body.name !== undefined) {
const name = body.name as string;
if (!name) throw new Error('name required');
const slug = slugify(name);
const record = await pb.collection('fams').update(famId, { name, slug });
return { name: record.name, slug: record.slug, payday: record.payday };
}
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
const patch: Record<string, string | number> = {};
if (body.payday !== undefined) {
const payday = Number(body.payday);
if (payday < 0 || payday > 6 || !Number.isInteger(payday))
throw new Error('payday must be 0-6');
patch.payday = payday;
}
if (body.paydayTime !== undefined) {
const paydayTime = String(body.paydayTime);
if (!/^\d{2}:\d{2}$/.test(paydayTime)) throw new Error('paydayTime must be HH:MM');
patch.paydayTime = paydayTime;
}
if (body.timezone !== undefined) {
const timezone = String(body.timezone);
if (timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
throw new Error("timezone must be an IANA name or 'auto'");
patch.timezone = timezone;
}
const record = await pb.collection('fams').update(famId, patch);
return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone };
}
throw new Error('no valid fields');
}
export async function getProfile(pb: any, famId: string, userId: string) {
const rec = await pb.collection('users').getOne(userId);
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
}
export async function updateProfile(
pb: any,
famId: string,
userId: string,
data: Record<string, unknown>
) {
const patch: Record<string, unknown> = {};
if (data.name) patch.name = data.name;
if (data.color) patch.color = data.color;
if (data.email !== undefined) patch.email = data.email;
const rec = await pb.collection('users').update(userId, patch);
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' };
}
export async function weeklySummary(pb: any, famId: string) {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
const [members, assigned, completions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` })
]);
let rewardPointsList: any[] = [];
try {
rewardPointsList = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
});
} catch {}
const assignedList = assigned;
const completionsList = completions;
const daysInWeek: string[] = [];
{
const d = new Date(ws + 'T00:00:00Z');
for (let i = 0; i < 7; i++) {
daysInWeek.push(d.toISOString().slice(0, 10));
d.setDate(d.getDate() + 1);
}
}
const summaries = await Promise.all(
members.map(async (m: any) => {
const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id);
const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id);
const weekPoints = memberCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
const dayPoints: Record<string, number> = {};
const dayCompletions: Record<string, number> = {};
for (const day of daysInWeek) {
dayPoints[day] = 0;
dayCompletions[day] = 0;
}
for (const c of memberCompletions) {
const day = (c.date || '').slice(0, 10);
if (dayPoints[day] !== undefined) {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
dayPoints[day] += chore?.type === 'points' ? Number(chore.value) : 0;
dayCompletions[day]++;
}
}
const bonusPoints = rewardPointsList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const weekMoney = memberCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0);
let bonusMoney = 0;
try {
const cashRewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
});
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
} catch {}
return {
memberId: m.id,
memberName: m.name,
memberColor: m.color,
pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney + bonusMoney,
choresCompleted: memberCompletions.length,
totalChores: memberAssignments.length,
dayPoints,
dayCompletions
};
})
);
return { weekStart: ws, daysInWeek, summaries };
}
export async function eowPreview(pb: any, famId: string) {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
const we = periodEnd('weekly', ws);
const [members, assigned, completions, configs, rewards] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` }),
pb
.collection('bonus_configs')
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
.catch(() => []),
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
]);
let rewardPointsList: any[] = [];
let rewardCashList: any[] = [];
try {
[rewardPointsList, rewardCashList] = await Promise.all([
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
}),
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
})
]);
} catch {}
const assignedList = assigned;
const completionsList = completions;
const summaries = members.map((m: any) => {
const mc = completionsList.filter((c: any) => c.memberId === m.id);
const weekPoints = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
}, 0);
const weekMoney = mc.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'money' ? Number(ch.value) : 0);
}, 0);
const bonusPoints = rewardPointsList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const bonusMoney = rewardCashList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
return {
memberId: m.id,
memberName: m.name || m.username || m.id.slice(0, 6),
memberColor: m.color,
pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney + bonusMoney,
choresCompleted: mc.length,
bonusEarned: bonusPoints
};
});
const predictedRewards: any[] = [];
const existingRewards = rewards;
for (const cfg of configs) {
if (cfg.type === 'manual') continue;
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
const periodCompletions = cfg.period
? completionsList.filter(
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
)
: completionsList;
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
const tryEval = (sourceComps: any[]) => {
if (cfg.type === 'threshold')
return sourceComps.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
}, 0);
if (cfg.type === 'count') return sourceComps.length;
return 0;
};
if (cfg.target === 'individual') {
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
for (const m of targets) {
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: m.name || m.id.slice(0, 6),
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
});
}
} else if (cfg.target === 'collaborative') {
if (cfgRewards.length) continue;
const allIds = members.map((m: any) => m.id);
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
const current = tryEval(teamComps);
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
memberName: 'Everyone',
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
});
} else if (cfg.target === 'competitive') {
if (cfgRewards.length) continue;
const scored = members.map((m: any) => ({
memberId: m.id,
name: m.name,
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
}));
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
if (winner)
predictedRewards.push({
config: cfg.name,
memberName: winner.name,
type: cfg.rewardType,
value: Number(cfg.rewardValue) || 0,
detail: `winner ${winner.current} pts`
});
}
}
const nextWeekStart = addDaysStr(ws, 7);
return {
simulateEow: true,
weekStart: ws,
weekEnd: we,
nextWeekStart,
summaries,
predictedRewards,
completionsThisWeek: completionsList.length
};
}
export async function releaseWeek(pb: any, famId: string) {
const { payday, paydayTime, tz } = await famMeta(pb, famId);
const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` });
const fam = fams?.[0];
if (!fam) throw new Error('Fam not found');
const wsToday = weekStart(payday, tz);
const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz));
if (Date.now() < target.getTime()) {
return {
settled: false,
notYet: true,
weekStart: wsToday,
target: target.toISOString()
};
}
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
const members = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}' && role = 'child'` });
let cashRewards: any[] = [];
try {
cashRewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`
});
} catch {}
const breakdown: any[] = [];
const now = new Date().toISOString();
const today = now.slice(0, 10);
for (const m of members) {
const unpaid = cashRewards.filter((r: any) => r.memberId === m.id && r.status !== 'claimed');
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
if (total > 0) {
for (const r of unpaid) {
if (r.status !== 'requested') {
await pb.collection('rewards').update(r.id, {
status: 'requested',
claimedAt: null,
date: (r.date || '').slice(0, 10) || today
});
}
}
breakdown.push({
memberId: m.id,
name: m.name,
total,
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) }))
});
}
}
await pb.collection('fams').update(famId, { lastIssued: wsToday });
return { settled: true, weekStart: wsToday, breakdown };
}
export async function completeWeek(pb: any, famId: string) {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
await evaluateFam(pb, famId);
const [members, assigned, completions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}' && date >= '${ws}'` })
]);
let rewardPointsList: any[] = [];
let rewardCashList: any[] = [];
try {
[rewardPointsList, rewardCashList] = await Promise.all([
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
}),
pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`
})
]);
} catch {}
const assignedList = assigned;
const historyRecords: any[] = [];
for (const m of members) {
const memberCompletions = completions.filter((c: any) => c.memberId === m.id);
const weekPoints = memberCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
const weekMoney = memberCompletions.reduce((sum: number, c: any) => {
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0);
const bonusPoints = rewardPointsList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const bonusMoney = rewardCashList
.filter((r: any) => r.memberId === m.id)
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const existing = await pb
.collection('weekly_history')
.getFullList({ filter: `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'` });
const recordData = {
famId,
memberId: m.id,
weekStart: ws,
pointsEarned: weekPoints + bonusPoints,
moneyEarned: weekMoney + bonusMoney,
choresCompleted: memberCompletions.length,
bonusEarned: bonusPoints
};
if (existing.length > 0) {
await pb.collection('weekly_history').update(existing[0].id, recordData);
} else {
const record = await pb.collection('weekly_history').create(recordData);
historyRecords.push(record);
}
}
return {
weekStart: ws,
historyRecords,
memberCount: members.length
};
}
+101
View File
@@ -0,0 +1,101 @@
import * as famSvc from './fam';
import * as choresSvc from './chores';
import * as completionsSvc from './completions';
import * as rewardsSvc from './rewards';
import * as bonusesSvc from './bonuses';
import * as chatSvc from './chat';
import * as settingsSvc from './settings';
import * as crudSvc from './crud';
import * as debugSvc from './debug';
export * from './chat';
export { famMeta } from './fam';
export { assertPaydayUnlocked } from './rewards';
// Per-feature service binder. `pb` is the acting user's own PocketBase client
// (child or parent token), so PB collection rules enforce famId + role scoping;
// this layer is purely in-process logic grouped by app area for readability.
// `user` supplies the acting user's id/role so member-scoped ops default to it.
export function createServices(
pb: any,
user?: { id?: string; role?: string; name?: string; color?: string }
) {
const uid = user?.id || '';
return {
fam: {
meta: (famId: string) => famSvc.famMeta(pb, famId),
get: (famId: string) => famSvc.getFam(pb, famId),
update: (famId: string, body: Record<string, unknown>) => famSvc.patchFam(pb, famId, body),
rename: (famId: string, name: string) => famSvc.patchFam(pb, famId, { name }),
updatePayday: (famId: string, payday: number, paydayTime?: string, timezone?: string) =>
famSvc.patchFam(pb, famId, {
payday,
...(paydayTime !== undefined ? { paydayTime } : {}),
...(timezone !== undefined ? { timezone } : {})
}),
weeklySummary: (famId: string) => famSvc.weeklySummary(pb, famId),
eowPreview: (famId: string) => famSvc.eowPreview(pb, famId),
payday: (famId: string) => famSvc.releaseWeek(pb, famId),
completeWeek: (famId: string) => famSvc.completeWeek(pb, famId),
getProfile: (famId: string) => famSvc.getProfile(pb, famId, uid),
updateProfile: (famId: string, data: Record<string, unknown>) =>
famSvc.updateProfile(pb, famId, uid, data)
},
crud: {
list: (resource: string, famId: string) => crudSvc.list(pb, resource, famId),
create: (resource: string, famId: string, data: Record<string, unknown>) =>
crudSvc.create(pb, resource, famId, data),
update: (resource: string, famId: string, id: string, data: Record<string, unknown>) =>
crudSvc.update(pb, resource, famId, id, data),
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id)
},
chores: {
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid)
},
completions: {
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>
completionsSvc.toggle(pb, famId, uid, body),
revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId)
},
rewards: {
claim: (famId: string, id: string) => rewardsSvc.claim(pb, famId, id),
approve: (famId: string, id: string) => rewardsSvc.approve(pb, famId, id),
requestAll: (famId: string) => rewardsSvc.requestAll(pb, famId, uid),
issueAll: (famId: string, memberId: string) => rewardsSvc.issueAll(pb, famId, memberId)
},
bonuses: {
progress: (famId: string) => bonusesSvc.progress(pb, famId),
evaluate: (famId: string) => bonusesSvc.evaluateAll(pb, famId),
trigger: (famId: string, configId: string, memberId?: string) =>
bonusesSvc.trigger(pb, famId, configId, memberId),
assign: (famId: string, configId: string, data: Record<string, unknown>) => {
const updates: Record<string, unknown> = { status: 'active' };
if (data.target) updates.target = data.target;
if (data.memberId !== undefined) updates.memberId = data.memberId || null;
return pb.collection('bonus_configs').update(configId, updates);
},
complete: (famId: string, configId: string) =>
pb.collection('bonus_configs').update(configId, { status: 'completed' }),
destroy: (famId: string, configId: string) =>
pb.collection('bonus_configs').delete(configId)
},
settings: {
get: (famId: string) => settingsSvc.getSettings(pb, famId),
update: (famId: string, data: Record<string, unknown>) =>
settingsSvc.updateSettings(pb, famId, data)
},
chat: {
me: (famId: string, actor: chatSvc.ChatActor) => chatSvc.chatMe(pb, famId, actor),
send: (famId: string, actor: chatSvc.ChatActor, body: { content?: string; clientId?: string }) =>
chatSvc.send(pb, famId, actor, body),
typing: (famId: string, actor: chatSvc.ChatActor, body: { typing?: boolean }) =>
chatSvc.typing(pb, famId, actor, body)
},
debug: {
generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days)
}
};
}
export type Services = ReturnType<typeof createServices>;
@@ -0,0 +1,68 @@
import { todayInTz, resolveTz } from '@shared/timezone';
import { famMeta } from './fam';
function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto');
}
export function assertPaydayUnlocked(reward: any, tz?: string) {
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz));
if (today < reward.settleDate) {
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`);
}
}
// Member claim → status 'requested' (pending parent approval).
export async function claim(pb: any, famId: string, id: string) {
const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId);
const found = await pb
.collection('rewards')
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
const reward = found?.[0];
if (!reward) throw new Error('Reward not found');
assertPaydayUnlocked(reward, tz);
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
}
// Admin approval → status 'claimed'.
export async function approve(pb: any, famId: string, id: string) {
return pb.collection('rewards').update(id, {
status: 'claimed',
claimedAt: new Date().toISOString()
});
}
export async function requestAll(pb: any, famId: string, memberId: string) {
const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId);
const rewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
});
let count = 0;
for (const r of rewards) {
try {
assertPaydayUnlocked(r, tz);
} catch {
continue;
}
await pb.collection('rewards').update(r.id, { status: 'requested', requestedAt: now });
count++;
}
return { count };
}
export async function issueAll(pb: any, famId: string, memberId: string) {
if (!memberId) throw new Error('memberId required');
const now = new Date().toISOString();
const rewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${memberId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')`
});
let count = 0;
for (const r of rewards) {
await pb.collection('rewards').update(r.id, { status: 'claimed', claimedAt: now });
count++;
}
return { count };
}
@@ -0,0 +1,27 @@
import { todayInTz, resolveTz } from '@shared/timezone';
import { famMeta } from './fam';
function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto');
}
export async function getSettings(pb: any, famId: string) {
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
const s = list?.[0] || {};
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
}
export async function updateSettings(pb: any, famId: string, data: Record<string, unknown>) {
const list = await pb.collection('settings').getFullList({ filter: `famId = '${famId}'` });
const existing = list?.[0];
const patch: Record<string, unknown> = {};
if (data.simulateEow !== undefined) patch.simulateEow = !!data.simulateEow;
if (data.webhookUrl !== undefined) patch.webhookUrl = String(data.webhookUrl);
let s;
if (existing) {
s = Object.keys(patch).length ? await pb.collection('settings').update(existing.id, patch) : existing;
} else {
s = await pb.collection('settings').create({ famId, ...patch });
}
return { simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || '' };
}
+11
View File
@@ -0,0 +1,11 @@
import { pbUser } from '$lib/server/pocketbase';
import { createServices, type Services } from '$lib/server/services';
import type { RequestEvent } from '@sveltejs/kit';
// Build the per-feature service binder for a server request, running as the
// authenticated user's own PB client (session cookie). Shorthand for the
// common `createServices(pbUser(event), event.locals.user)` call used in loads
// and form actions.
export function servicesFor(event: RequestEvent): Services {
return createServices(pbUser(event), event.locals.user || undefined);
}
+1 -1
View File
@@ -180,7 +180,7 @@ class ChatStore {
// ── Writes via SvelteKit server (forwards session/device auth) ──
private async serverChat(payload: Record<string, unknown>) {
const res = await fetch('/chat', {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
+35 -44
View File
@@ -1,42 +1,40 @@
import { PROXY_URL } from '$app/env/public';
import { pbAdmin } from '$lib/server/pocketbase';
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services';
const HONO_URL = PROXY_URL;
async function paydayCheck(famId: string, headers: Record<string, string>) {
async function paydayCheck(famId: string, pbToken: string) {
try {
const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
}
});
// Best-effort: never block render on the payday heartbeat.
await res.json().catch(() => null);
const s = createServices(createPbClient(pbToken));
await s.fam.payday(famId);
} catch {}
}
async function resolveChatIdentity(
api: 'admin' | 'member',
opts: {
session?: { famId: string; id: string };
pbToken?: string;
}
) {
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 headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (api === 'admin' && opts.session) {
headers['x-session-famid'] = opts.session.famId;
headers['x-session-userid'] = opts.session.id;
} else if (api === 'member' && opts.pbToken) {
headers['Authorization'] = `Bearer ${opts.pbToken}`;
} else {
return null;
}
const res = await fetch(`${HONO_URL}/api/chat/me`, { headers });
if (!res.ok) return null;
return await res.json();
const s = createServices(createPbClient(pbToken));
const actor = actorFrom(session);
return await s.chat.me(session.famId, actor);
} catch {
return null;
}
@@ -49,19 +47,12 @@ export async function load(event) {
const pbToken = event.cookies.get('pb_token') || '';
let famId = '';
let chat: { famId: string; actor: any } | null = null;
let chat: { famId: string; actor: ChatActor } | null = null;
if (session && isParent) {
if (session && pbToken) {
famId = session.famId;
await paydayCheck(famId, {
'x-session-famid': session.famId,
'x-session-userid': session.id
});
chat = await resolveChatIdentity('admin', { session });
} else if (role === 'child' && pbToken && session) {
famId = session.famId;
await paydayCheck(famId, { Authorization: `Bearer ${pbToken}` });
chat = await resolveChatIdentity('member', { pbToken });
await paydayCheck(famId, pbToken);
chat = await resolveChatIdentity(session, pbToken);
}
return {
@@ -85,4 +76,4 @@ export async function load(event) {
? await pbAdmin.getOne('fams', famId).catch(() => null)
: null
};
}
}
+8 -6
View File
@@ -1,20 +1,22 @@
import { hono } from '$lib/server/hono';
import { pbUser } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services';
export async function load(event) {
const session = event.locals.user;
if (!session) return {};
const famId = session.famId;
const s = createServices(pbUser(event), session);
try {
const [members, templates, assigned, summary] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
s.crud.list('members', famId),
s.crud.list('chore-templates', famId),
s.crud.list('assigned-chores', famId),
s.fam.weeklySummary(famId),
]);
return { members, templates, assigned, summary };
} catch {
return {};
}
}
}
@@ -1,8 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
import { pbUser, createPbClient } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services';
export async function load(event) {
const session = event.locals.user;
@@ -17,6 +15,7 @@ export async function load(event) {
if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`);
}
const s = createServices(pbUser(event), session);
const [
members,
templates,
@@ -28,15 +27,15 @@ export async function load(event) {
completions,
settings
] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
hono.admin.fam(event, famId),
hono.admin.rewards(event, famId),
hono.admin.bonusConfigs(event, famId),
hono.admin.completions(event, famId),
hono.admin.settings(event, famId).catch(() => ({}))
s.crud.list('members', famId),
s.crud.list('chore-templates', famId),
s.crud.list('assigned-chores', famId),
s.fam.weeklySummary(famId),
s.fam.get(famId),
s.crud.list('rewards', famId),
s.crud.list('bonus-configs', famId),
s.crud.list('completions', famId),
s.settings.get(famId).catch(() => ({ simulateEow: false, webhookUrl: '' }))
]);
return {
role: 'parent',
@@ -88,12 +87,8 @@ export async function load(event) {
if (!pbToken || !famId) return empty;
try {
const choresRes = await fetch(`${HONO_URL}/api/members/my-chores`, {
method: 'POST',
headers: { Authorization: `Bearer ${pbToken}` }
});
if (!choresRes.ok) return empty;
const chores = await choresRes.json();
const s = createServices(createPbClient(pbToken), session ?? undefined);
const chores = await s.chores.myChores(famId);
return {
role: 'child',
token: pbToken,
@@ -107,7 +102,7 @@ export async function load(event) {
completions: chores.completions || [],
rewards: chores.rewards || [],
bonusConfigs: chores.bonusConfigs || [],
tallies: chores.tallies || {},
tallies: {} as Record<string, unknown>,
payday: chores.payday,
paydayTime: chores.paydayTime || '18:00',
timezone: chores.timezone || 'auto',
@@ -125,7 +120,8 @@ export const actions = {
const fd = await event.request.formData();
const on = fd.get('on') === 'true';
try {
const result = await hono.admin.updateSettings(event, famId, { simulateEow: on });
const s = createServices(pbUser(event), event.locals.user);
const result = await s.settings.update(famId, { simulateEow: on });
return { simulateEow: result.simulateEow };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to update settings' };
@@ -136,8 +132,9 @@ export const actions = {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
try {
const preview = await hono.admin.eowPreview(event, famId);
await hono.admin.updateSettings(event, famId, { simulateEow: true });
const s = createServices(pbUser(event), event.locals.user);
const preview = await s.fam.eowPreview(famId);
await s.settings.update(famId, { simulateEow: true });
return { preview, simulateEow: true };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
@@ -150,7 +147,8 @@ export const actions = {
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
const s = createServices(pbUser(event), event.locals.user);
const record = await s.rewards.approve(famId, rewardId);
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
@@ -163,7 +161,8 @@ export const actions = {
const fd = await event.request.formData();
const memberId = fd.get('memberId') as string;
try {
const result = await hono.admin.issueAllRewards(event, famId, memberId);
const s = createServices(pbUser(event), event.locals.user);
const result = await s.rewards.issueAll(famId, memberId);
return { count: result.count };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to issue rewards' };
@@ -176,7 +175,8 @@ export const actions = {
const fd = await event.request.formData();
const completionId = fd.get('id') as string;
try {
await hono.admin.revokeCompletion(event, famId, completionId);
const s = createServices(pbUser(event), event.locals.user);
await s.completions.revoke(famId, completionId);
return { revoked: true, id: completionId };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
@@ -191,15 +191,11 @@ export const actions = {
const memberId = fd.get('memberId') as string;
if (!configId) return { error: 'Config ID required' };
try {
const result = await hono.admin.triggerBonusConfig(
event,
famId,
configId,
memberId || undefined
);
const s = createServices(pbUser(event), event.locals.user);
const result = await s.bonuses.trigger(famId, configId, memberId || undefined);
return { records: result.records || [] };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
}
}
};
};
@@ -1,15 +1,16 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { servicesFor } from '$lib/server/servicesFor';
export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const s = servicesFor(event);
const [configs, templates, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'bonus-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.bonusConfigProgress(event, famId),
hono.admin.rewards(event, famId)
s.crud.list('bonus-configs', famId),
s.crud.list('bonus-templates', famId),
s.crud.list('members', famId),
s.bonuses.progress(famId),
s.crud.list('rewards', famId)
]);
return { configs, templates, members, progress, rewards };
}
@@ -34,7 +35,8 @@ export const actions = {
if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = '';
try {
const record = await hono.admin.create(event, 'bonus-templates', famId, data);
const s = servicesFor(event);
const record = await s.crud.create('bonus-templates', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
@@ -67,7 +69,8 @@ export const actions = {
const description = fd.get('description');
if (description) data.description = description;
try {
const record = await hono.admin.update(event, 'bonus-templates', famId, id, data);
const s = servicesFor(event);
const record = await s.crud.update('bonus-templates', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
@@ -80,7 +83,8 @@ export const actions = {
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-templates', famId, id);
const s = servicesFor(event);
await s.crud.remove('bonus-templates', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
@@ -112,7 +116,8 @@ export const actions = {
const memberId = fd.get('memberId');
if (memberId) data.memberId = memberId;
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
const s = servicesFor(event);
const record = await s.crud.create('bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
@@ -147,7 +152,8 @@ export const actions = {
const memberId = fd.get('memberId');
if (memberId !== null) data.memberId = memberId || null;
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
const s = servicesFor(event);
const record = await s.crud.update('bonus-configs', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
@@ -160,7 +166,8 @@ export const actions = {
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-configs', famId, id);
const s = servicesFor(event);
await s.crud.remove('bonus-configs', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
@@ -188,7 +195,8 @@ export const actions = {
};
if (data.occurrence === 'once') data.period = '';
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
const s = servicesFor(event);
const record = await s.crud.create('bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, {
@@ -205,7 +213,8 @@ export const actions = {
const target = fd.get('target') as string;
const memberId = fd.get('memberId') as string;
try {
const record = await hono.admin.assignBonusConfig(event, famId, id, {
const s = servicesFor(event);
const record = await s.bonuses.assign(famId, id, {
target,
memberId: memberId || null
});
@@ -221,7 +230,8 @@ export const actions = {
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.completeBonusConfig(event, famId, id);
const s = servicesFor(event);
const record = await s.bonuses.complete(famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
@@ -234,7 +244,8 @@ export const actions = {
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.destroyBonusConfig(event, famId, id);
const s = servicesFor(event);
await s.bonuses.destroy(famId, id);
return { destroyed: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
@@ -249,7 +260,8 @@ export const actions = {
const currentStatus = fd.get('currentStatus') as string;
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, {
const s = servicesFor(event);
const record = await s.crud.update('bonus-configs', famId, id, {
status: newStatus
});
return { record };
@@ -262,9 +274,10 @@ export const actions = {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
try {
await hono.admin.evaluateBonusConfig(event, famId);
const s = servicesFor(event);
await s.bonuses.evaluate(famId);
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
}
}
};
};
@@ -1,12 +1,13 @@
import { hono } from '$lib/server/hono';
import { json } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor';
export async function GET(event) {
const famId = event.params.fam;
try {
const progress = await hono.admin.bonusConfigProgress(event, famId);
const s = servicesFor(event);
const progress = await s.bonuses.progress(famId);
return json(progress);
} catch {
return json([]);
}
}
}
@@ -1,15 +1,16 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { servicesFor } from '$lib/server/servicesFor';
export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId;
const s = servicesFor(event);
const [rewards, members, assigned, templates, completions] = await Promise.all([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.completions(event, famId)
s.crud.list('rewards', famId),
s.crud.list('members', famId),
s.crud.list('assigned-chores', famId),
s.crud.list('chore-templates', famId),
s.crud.list('completions', famId)
]);
return { rewards, members, assigned, templates, completions };
}
@@ -21,10 +22,11 @@ export const actions = {
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
const s = servicesFor(event);
const record = await s.rewards.approve(famId, rewardId);
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
}
};
};
@@ -1,22 +1,25 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { PROXY_URL } from '$app/env/public';
const HONO_URL = PROXY_URL;
import { servicesFor } from '$lib/server/servicesFor';
export async function load(event) {
const session = event.locals.user;
const famSlug = event.params.fam;
const username = event.params.username;
// Parent (session auth) — profile lives on the users record
if (session) {
const famId = session.famId;
if (session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
}
if (!session) {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
const famId = session.famId;
if (session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}/preferences`);
}
const s = servicesFor(event);
if (session.role === 'parent') {
try {
const me = await hono.admin.getProfile(event, famId);
const me = await s.fam.getProfile(famId);
return {
verified: true, token: '', memberId: me.id, famId,
memberName: me.name, memberColor: me.color, email: me.email || '',
@@ -27,73 +30,49 @@ export async function load(event) {
}
}
// Child (device token) — profile lives in members
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
if (!deviceToken) {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
return {
verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId,
memberName: data.name, memberColor: data.color, email: data.email || '', session: false,
};
} catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
// Child — profile lives on the users record, read from the session.
return {
verified: true, token: event.cookies.get('pb_token') || '', memberId: session.id, famId,
memberName: session.name || '', memberColor: session.color || '', email: '',
session: true,
};
}
export const actions = {
update: async (event) => {
const session = event.locals.user;
if (!session) return { error: 'Not authenticated' };
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
const email = fd.get('email') as string;
if (session) {
const famId = session.famId;
const s = servicesFor(event);
const famId = session.famId;
if (session.role === 'parent') {
try {
const data: Record<string, string> = {};
if (name) data.name = name;
if (color) data.color = color;
data.email = email || '';
const me = await hono.admin.updateProfile(event, famId, data);
const me = await s.fam.updateProfile(famId, data);
return { success: true, name: me.name, color: me.color, email: me.email };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
}
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam;
if (!deviceToken) return { error: 'Not authenticated' };
// Child — update own users record via their PB token.
try {
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const verify = await verifyRes.json();
if (!verifyRes.ok) return { error: 'Verification failed' };
const res = await fetch(`${HONO_URL}/api/members/me`, {
method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
const data = await res.json();
if (!res.ok) return { error: data.error || 'Update failed' };
return { success: true, name: data.name, color: data.color };
const data: Record<string, string> = {};
if (name) data.name = name;
if (color) data.color = color;
const me = await s.fam.updateProfile(famId, data);
return { success: true, name: me.name, color: me.color, email: '' };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
},
};
};
@@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbUser } from '$lib/server/pocketbase';
import { pbAdmin } from '$lib/server/pocketbase';
import { hono } from '$lib/server/hono';
import { servicesFor } from '$lib/server/servicesFor';
import { issueAccess, createChild } from '$lib/server/member-otp';
import { slugify } from '@shared/slugify';
@@ -124,11 +124,12 @@ export const actions = {
return { deletedChoreIds: deletedIds };
},
// Compute endpoints — still proxied to Hono.
// Compute endpoints — in-process.
completeWeek: async (event: RequestEvent) => {
const famId = famIdOf(event);
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/complete-week`);
const s = servicesFor(event);
const result = await s.fam.completeWeek(famId);
return { success: true, result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to complete week' };
@@ -140,7 +141,8 @@ export const actions = {
const fd = await event.request.formData();
const days = parseInt((fd.get('days') as string) || '7', 10);
try {
const result = await hono.admin.request(event, 'POST', `/api/admin/${famId}/debug/generate-data`, { days });
const s = servicesFor(event);
const result = await s.debug.generateData(famId, days);
return { success: true, result };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to generate data' };
@@ -0,0 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
if (famId !== event.params.famId) {
return json({ error: 'famId mismatch' }, { status: 403 });
}
const body = await event.request.json().catch(() => ({}));
try {
const s = createServices(pb, { id: userId, role });
const record = await s.crud.create('assigned-chores', famId, body);
return json(record);
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'create failed' }, { status: 400 });
}
}
@@ -0,0 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function DELETE(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
if (famId !== event.params.famId) {
return json({ error: 'famId mismatch' }, { status: 403 });
}
const id = event.params.id!;
try {
const s = createServices(pb, { id: userId, role });
await s.crud.remove('assigned-chores', famId, id);
return json({ ok: true });
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'delete failed' }, { status: 400 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices, type ChatActor } from '$lib/server/services';
import type { RequestEvent } from '@sveltejs/kit';
type Body = {
action: 'send' | 'typing';
famId?: string;
content?: string;
clientId?: string;
typing?: boolean;
};
export async function POST(event: RequestEvent) {
const body = (await event.request.json().catch(() => null)) as Body | null;
if (!body || !body.action) return json({ error: 'missing action' }, { status: 400 });
const { pb, famId: sessionFamId, userId, role, name, color } = actingClient(event);
const actor: ChatActor = {
id: userId,
type: role === 'parent' ? 'admin' : 'member',
name,
color
};
const famId = body.famId || sessionFamId || '';
if (!famId) return json({ error: 'famId required' }, { status: 400 });
try {
const s = createServices(pb, { id: userId, role });
const data =
body.action === 'typing'
? await s.chat.typing(famId, actor, { typing: Boolean(body.typing) })
: await s.chat.send(famId, actor, {
content: body.content || '',
clientId: body.clientId || ''
});
return json(data);
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'chat request failed' }, { status: 400 });
}
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
const body = await event.request.json().catch(() => ({}));
try {
const s = createServices(pb, { id: userId, role });
return json(await s.completions.toggle(famId, body));
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
}
}
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
try {
const s = createServices(pb, { id: userId, role });
return json(await s.fam.payday(famId));
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'payday failed' }, { status: 400 });
}
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function PATCH(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
const body = await event.request.json().catch(() => ({}));
try {
const s = createServices(pb, { id: userId, role });
return json(await s.fam.updateProfile(famId, body));
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'update failed' }, { status: 400 });
}
}
@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { actingClient } from '$lib/server/routeAuth';
import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) {
const { pb, famId, userId, role } = actingClient(event);
const id = event.params.id!;
try {
const s = createServices(pb, { id: userId, role });
return json(await s.rewards.claim(famId, id));
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
}
}
-58
View File
@@ -1,58 +0,0 @@
import { json } from '@sveltejs/kit';
import { PROXY_URL } from '$app/env/public';
import type { RequestEvent } from '@sveltejs/kit';
const HONO_URL = PROXY_URL;
type Body = {
action: 'send' | 'typing';
famId?: string;
content?: string;
clientId?: string;
typing?: boolean;
};
export async function POST(event: RequestEvent) {
const body = (await event.request.json().catch(() => null)) as Body | null;
if (!body || !body.action) return json({ error: 'missing action' }, 400);
const session = event.locals.user;
const pbToken = event.cookies.get('pb_token') || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
let famId = body.famId || '';
if (session?.role === 'parent' && session?.famId && session?.id) {
// Admin (parent) — trust the verified session server-side.
headers['x-session-famid'] = session.famId;
headers['x-session-userid'] = session.id;
famId = session.famId;
} else if (session?.role === 'child' && pbToken && session?.famId) {
// Member (child) — forward the pb_token; the proxy re-validates.
headers['Authorization'] = `Bearer ${pbToken}`;
famId = session.famId;
} else {
return json({ error: 'Unauthorized' }, 401);
}
if (!famId) return json({ error: 'famId required' }, 400);
const path = body.action === 'typing' ? `/api/chat/${famId}/typing` : `/api/chat/${famId}/messages`;
const payload =
body.action === 'typing'
? { typing: Boolean(body.typing) }
: { content: body.content || '', clientId: body.clientId || '' };
try {
const res = await fetch(`${HONO_URL}${path}`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return json({ error: data.error || 'chat request failed' }, res.status);
return json(data);
} catch {
return json({ error: 'chat request failed' }, 502);
}
}
+2 -9
View File
@@ -29,15 +29,8 @@ export default defineConfig(() => {
allow: ['.', './node_modules', '../node_modules']
},
// Dev only: allow access via any host/LAN IP without hardcoding it.
allowedHosts: true,
port: 2080,
proxy: {
'/api': {
// The vite dev server and Hono proxy run on the same host.
target: `http://127.0.0.1:3456`,
changeOrigin: true
}
}
allowedHosts: true as true,
port: 2080
}
};
});