Compare commits

..

6 Commits

Author SHA1 Message Date
JCEEE 7de3c8ca62 propose new chore update fix 2026-09-30 08:12:30 +01:00
JCEEE d14a154dcb fix auth again 2026-09-28 21:13:37 +01:00
JCEEE 89ab955efc add debugging 2026-09-27 11:04:37 +01:00
JCEEE edc8568cc4 update schema 2026-09-25 08:24:26 +01:00
JCEEE c070cc8053 1.11.7 2026-09-24 12:50:26 +01:00
JCEEE 8388868bee enable kids todos 2026-09-24 12:50:20 +01:00
12 changed files with 381 additions and 45 deletions
+11 -2
View File
@@ -89,9 +89,15 @@ export const handle: Handle = async ({ event, resolve }) => {
setActiveChild(event.cookies, activeId); setActiveChild(event.cookies, activeId);
return resolve(event); return resolve(event);
} }
} catch { console.error(
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
);
} catch (e) {
// Expired/revoked/malformed — leave the cookie; the picker switch // Expired/revoked/malformed — leave the cookie; the picker switch
// re-mints it server-side. Fall through to the single session. // re-mints it server-side. Fall through to the single session.
console.error(
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
} }
} }
} }
@@ -109,8 +115,11 @@ export const handle: Handle = async ({ event, resolve }) => {
if (freshToken !== token) { if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken); setSessionCookie(event.cookies, freshToken);
} }
} catch { } catch (e) {
// Expired, malformed, or revoked — drop it and treat as logged out. // Expired, malformed, or revoked — drop it and treat as logged out.
console.error(
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
} }
} }
+3
View File
@@ -23,6 +23,9 @@ export const memberApi = {
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) { async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt }); return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
}, },
async createTodo(name: string) {
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
},
async claimReward(famId: string, rewardId: string) { async claimReward(famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
}, },
+34 -13
View File
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
// Superuser PB client (memoized). Reserved for server-only privileged // Superuser PB client (memoized). Reserved for server-only privileged
// operations that must bypass collection rules: creating child users, minting // operations that must bypass collection rules: creating child users, minting
// OTP-login tokens, and verifying OTPs against the superuser-only otp. // OTP-login tokens, and verifying OTPs against the superuser-only otp.
//
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
// calls with 404 "resource wasn't found" — which the toggle path misreads as
// CHORE_GONE for every chore. So always re-auth when the stored token is no
// longer valid instead of reusing a dead client.
let superClient: PocketBase | null = null; let superClient: PocketBase | null = null;
export async function createSuperClient() { export async function createSuperClient() {
if (superClient) return superClient; if (superClient?.authStore.isValid) return superClient;
superClient = null;
const pb = new PocketBase(PB_ENDPOINT); const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD)); await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
@@ -40,29 +47,43 @@ export async function createSuperClient() {
return pb; return pb;
} }
// Run a superuser op, retrying once with a freshly-authenticated client when
// the first attempt hits an auth-shaped failure. A locally-valid token can
// still be dead server-side (restart/revoked secret rotation), and PB
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
try {
return await op(await createSuperClient());
} catch (e: any) {
const status = e?.status;
if (status === 401 || status === 403 || status === 404) {
superClient = null;
return await op(await createSuperClient());
}
throw e;
}
}
// Superuser CRUD facade, built on the memoized SDK superuser client. All // Superuser CRUD facade, built on the memoized SDK superuser client. All
// server PB access (authenticated user + superuser) lives in this one module. // server PB access (authenticated user + superuser) lives in this one module.
export const pbAdmin = { export const pbAdmin = {
async getList(collection: string, filter = '') { async getList(collection: string, filter = '') {
const pb = await createSuperClient(); return withSuperRetry((pb) => {
const options: { filter?: string } = {}; const options: { filter?: string } = {};
if (filter) options.filter = filter; if (filter) options.filter = filter;
return pb.collection(collection).getFullList(options); return pb.collection(collection).getFullList(options);
});
}, },
async getOne(collection: string, id: string) { async getOne(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).getOne(id));
return pb.collection(collection).getOne(id);
}, },
async create(collection: string, data: Record<string, unknown>) { async create(collection: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).create(data));
return pb.collection(collection).create(data);
}, },
async update(collection: string, id: string, data: Record<string, unknown>) { async update(collection: string, id: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).update(id, data));
return pb.collection(collection).update(id, data);
}, },
async remove(collection: string, id: string) { async remove(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).delete(id));
return pb.collection(collection).delete(id);
} }
}; };
@@ -67,10 +67,16 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
let chore: any; let chore: any;
try { try {
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId); chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch { } catch (e) {
console.error(
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list'); throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
} }
if (!chore || chore.famId !== famId) { if (!chore || chore.famId !== famId) {
console.error(
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list'); throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
} }
const isTodo = chore?.isTodo; const isTodo = chore?.isTodo;
+6 -1
View File
@@ -321,7 +321,12 @@ class FamStore {
// and from PB subscribe SSE for multi-user realtime. // and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => { const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]; // A create for an id we already hold (optimistic add + SSE echo) is
// an update, not a second row.
if (action === 'create')
return list.some((x) => x.id === record.id)
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
: [record, ...list];
if (action === 'update') if (action === 'update')
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)); return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
if (action === 'delete') return list.filter((x) => x.id !== record.id); if (action === 'delete') return list.filter((x) => x.id !== record.id);
+10 -8
View File
@@ -694,7 +694,7 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1; border-top: 4px solid #6366f1;
border-radius: 12px; border-radius: 12px;
padding: 0.75rem; padding: 0.9rem;
background: #fafbff; background: #fafbff;
} }
.col-member { .col-member {
@@ -709,6 +709,7 @@
gap: 0.4rem; gap: 0.4rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-bottom: 0.35rem;
} }
.dot { .dot {
display: inline-block; display: inline-block;
@@ -741,8 +742,8 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.7rem;
margin: 0.9rem 0 0.7rem; margin: 1.1rem 0 1.2rem;
} }
.donut-wrap { .donut-wrap {
position: relative; position: relative;
@@ -780,19 +781,20 @@
.member-stats { .member-stats {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.45rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.member-stats.compact .mstat { .member-stats.compact .mstat {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0.4rem; gap: 0.5rem;
font-size: 0.76rem; font-size: 0.8rem;
line-height: 1.5;
color: #374151; color: #374151;
background: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.65);
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 10px;
padding: 0.28rem 0.5rem; padding: 0.45rem 0.65rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
+185 -15
View File
@@ -266,6 +266,29 @@
let todayChild = $derived(todayInTz(famTz)); let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<Set<string>>(new Set()); let togglingIds = $state<Set<string>>(new Set());
// ─── Kid-created todos ───
let showTodoModal = $state(false);
let newTodoName = $state('');
let todoSaving = $state(false);
let todoError = $state('');
async function submitTodo() {
const name = newTodoName.trim();
if (!name || todoSaving) return;
todoSaving = true;
todoError = '';
try {
const res = await memberApi.createTodo(name);
// Instant UI — the SSE echo dedupes on id via applyRecord.
famStore.applyRecord('assigned_chores', res.record, 'create');
newTodoName = '';
showTodoModal = false;
} catch (e) {
todoError = e instanceof Error ? e.message : 'Could not add todo';
} finally {
todoSaving = false;
}
}
// ─── Payday countdown (child view) ─── // ─── Payday countdown (child view) ───
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
let eowFired = $state(false); let eowFired = $state(false);
@@ -475,9 +498,7 @@
const counts: number[] = []; const counts: number[] = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i); const d = addDays(weekStart, i);
counts.push( counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
);
} }
return counts; return counts;
}); });
@@ -975,10 +996,18 @@
if (optimistic) { if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete'); famStore.applyRecord('completions', optimistic, 'delete');
} }
const msg = e instanceof Error ? e.message : String(e);
if (/CHORE_GONE/.test(msg)) {
// Drop the dead card synchronously so a second tap can't re-enter
// before the resync round-trip finishes (the repeated 400s).
famStore.applyRecord('assigned_chores', chore, 'delete');
console.error(
`[diag] toggle CHORE_GONE(drop-card) member=${memberId} fam=${famId} chore=${chore.id}`
);
}
try { try {
await famStore.resync(); await famStore.resync();
} catch {} } catch {}
const msg = e instanceof Error ? e.message : String(e);
showToast( showToast(
/CHORE_GONE/.test(msg) /CHORE_GONE/.test(msg)
? 'This chore was changed — list refreshed.' ? 'This chore was changed — list refreshed.'
@@ -1208,7 +1237,12 @@
{/if} {/if}
</Card> </Card>
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}> <Card
cols={1}
title="Claims"
accent={hasClaimableRewards ? '#f97316' : undefined}
class={hasClaimableRewards ? 'claims-card' : ''}
>
{#if claimableRewards().length === 0} {#if claimableRewards().length === 0}
<div class="claims-empty"> <div class="claims-empty">
{@html checkCircleIcon}<span>No outstanding claims</span> {@html checkCircleIcon}<span>No outstanding claims</span>
@@ -1373,12 +1407,7 @@
{/if} {/if}
<!-- HERO --> <!-- HERO -->
<header class="hero"> <header class="hero">
<svg <svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
class="hero-bg"
viewBox="0 0 700 120"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline <polyline
points={heroSpark.line} points={heroSpark.line}
fill="none" fill="none"
@@ -1665,8 +1694,12 @@
{/if} {/if}
{#if inYesterday} {#if inYesterday}
<div class="yesterday-banner"> <div class="yesterday-banner">
<span>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span> <span
<button class="yesterday-back" onclick={() => (viewDate = 'today')}>Back to today →</button> >📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span
>
<button class="yesterday-back" onclick={() => (viewDate = 'today')}
>Back to today →</button
>
</div> </div>
{/if} {/if}
<div class="kanban"> <div class="kanban">
@@ -1676,7 +1709,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each dailyPending as chore} {#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1693,7 +1730,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each weeklyPending as chore} {#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1765,6 +1806,9 @@
{/if} {/if}
{/each} {/each}
{/if} {/if}
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
<span aria-hidden="true">+</span> Add todo
</button>
</div> </div>
<div class="column col-done"> <div class="column col-done">
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2> <h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
@@ -1799,6 +1843,45 @@
{/if} {/if}
<!-- WALLET / CLAIMS --> <!-- WALLET / CLAIMS -->
{#if showTodoModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-overlay"
onclick={() => !todoSaving && (showTodoModal = false)}
onkeydown={(e) => e.key === 'Escape' && !todoSaving && (showTodoModal = false)}
>
<div class="modal-card" onclick={(e) => e.stopPropagation()}>
<h3>🎯 New todo</h3>
<p class="modal-sub">Something you want to get done this week? Add it here.</p>
<input
class="modal-input"
type="text"
maxlength="80"
placeholder="e.g. Tidy my desk"
bind:value={newTodoName}
disabled={todoSaving}
autofocus
onkeydown={(e) => e.key === 'Enter' && submitTodo()}
/>
{#if todoError}
<p class="modal-error">{todoError}</p>
{/if}
<div class="modal-actions">
<button
class="modal-btn modal-cancel"
onclick={() => (showTodoModal = false)}
disabled={todoSaving}>Cancel</button
>
<button
class="modal-btn modal-add"
onclick={submitTodo}
disabled={!newTodoName.trim() || todoSaving}
>{todoSaving ? 'Adding…' : 'Add todo'}</button
>
</div>
</div>
</div>
{/if}
<div class="wallet"> <div class="wallet">
<div class="wallet-top"> <div class="wallet-top">
<span class="wallet-lbl">{@html walletIcon} Wallet</span> <span class="wallet-lbl">{@html walletIcon} Wallet</span>
@@ -3001,6 +3084,93 @@
padding: 1px 5px; padding: 1px 5px;
flex-shrink: 0; flex-shrink: 0;
} }
.add-todo-cta {
width: 100%;
margin-top: 0.5rem;
padding: 0.55rem;
border: 1.5px dashed #c7d2fe;
border-radius: 10px;
background: #eef2ff;
color: #4338ca;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.add-todo-cta:hover {
background: #e0e7ff;
border-color: #818cf8;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.modal-card {
background: #fff;
border-radius: 14px;
padding: 1.25rem;
width: 100%;
max-width: 380px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
.modal-card h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.modal-sub {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: #6b7280;
}
.modal-input {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #d1d5db;
border-radius: 10px;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
}
.modal-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px #e0e7ff;
}
.modal-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: #dc2626;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.modal-btn {
border: none;
border-radius: 10px;
padding: 0.55rem 1rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: default;
}
.modal-cancel {
background: #f3f4f6;
color: #374151;
}
.modal-add {
background: #4338ca;
color: #fff;
}
.todo-main { .todo-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -116,7 +116,8 @@ export const actions = {
const startDate = fd.get('startDate') as string; const startDate = fd.get('startDate') as string;
if (type) data.type = type; if (type) data.type = type;
if (type === 'emoji') { if (type === 'emoji') {
data.value = 0; // 1, not 0: PB treats 0 as blank on the required `value` field.
data.value = 1;
data.emoji = (fd.get('emoji') as string) || '🎉'; data.emoji = (fd.get('emoji') as string) || '🎉';
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0; } else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
data.customName = customName || undefined; data.customName = customName || undefined;
@@ -160,7 +161,7 @@ export const actions = {
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
const name = fd.get('name') as string; const name = fd.get('name') as string;
const type = fd.get('type') as string; const type = fd.get('type') as string;
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0); const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : ''; const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
const todoStart = fd.get('todoStart') as string; const todoStart = fd.get('todoStart') as string;
const todoCompleteBy = fd.get('todoCompleteBy') as string; const todoCompleteBy = fd.get('todoCompleteBy') as string;
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) { export async function POST(event: RequestEvent) {
const u = event.locals.user; const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); if (!u || !event.locals.pbToken) {
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
throw error(401, 'Unauthorized');
}
const pb = createPbClient(event.locals.pbToken); const pb = createPbClient(event.locals.pbToken);
const body = await event.request.json().catch(() => ({})); const body = await event.request.json().catch(() => ({}));
try { try {
const s = createServices(pb, { id: u.id, role: u.role }); const s = createServices(pb, { id: u.id, role: u.role });
return json(await s.completions.toggle(u.famId, body)); const out = await s.completions.toggle(u.famId, body);
console.log(
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
);
return json(out);
} catch (e) { } catch (e) {
console.error(
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
);
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 }); return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
} }
} }
@@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
// claims against server truth from both angles (superuser getOne — the toggle
// path — and the user's own list read — the kanban path). Authenticated,
// own-fam only, no tokens ever logged or returned.
export async function GET(event: RequestEvent) {
const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
let suChore: any = null;
let suError = '';
if (assignedChoreId) {
try {
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch (e) {
suError = e instanceof Error ? e.message : String(e);
}
}
let userSeesIt: boolean | null = null;
let userListError = '';
try {
const pb = createPbClient(event.locals.pbToken);
const list = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${u.famId}'` });
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
} catch (e) {
userListError = e instanceof Error ? e.message : String(e);
}
const out = {
session: { id: u.id, role: u.role, famId: u.famId },
asked: assignedChoreId,
superuser: suChore
? {
found: true,
famId: suChore.famId,
memberId: suChore.memberId,
isTodo: !!suChore.isTodo,
famMatch: suChore.famId === u.famId
}
: { found: false, error: suError },
userList: { seesIt: userSeesIt, error: userListError }
};
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
return json(out);
}
+57
View File
@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
// Kids can create todos for THEMSELVES only. Writes go through the superuser
// client because `assigned_chores.createRule` is parent-only — but every
// privileged field is forced server-side: memberId = session user, emoji type
// with zero value (so nobody can mint cash/points for themselves), weekly
// frequency, this-week start/completeBy (so it expires and purges naturally).
export async function POST(event: RequestEvent) {
const u = event.locals.user;
if (!u) {
console.error('[diag] todos 401 (no session)');
throw error(401, 'Unauthorized');
}
const body = await event.request.json().catch(() => ({}));
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
if (!name) throw error(400, 'Give your todo a name');
let payday = 1;
let tz = 'UTC';
try {
const fam: any = await pbAdmin.getOne('fams', u.famId);
if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday);
tz = resolveTz(fam.timezone || 'auto');
} catch {
tz = resolveTz('auto');
}
const today = todayInTz(tz);
const completeBy = addDaysStr(weekStart(payday, tz), 6);
try {
const record = await pbAdmin.create('assigned_chores', {
famId: u.famId,
memberId: u.id,
frequency: 'weekly',
type: 'emoji',
// Must be 1, not 0: PB treats 0 as blank on the required `value`
// number field (400 validation_required). Value is meaningless for
// emoji todos — never summed, never displayed, never rewarded.
value: 1,
customName: name,
isTodo: true,
emoji: '🎯',
startDate: today,
completeBy
});
console.log(`[diag] todo ok member=${u.id} fam=${u.famId} todo=${record.id} name=${name}`);
return json({ record });
} catch (e) {
console.error(
`[diag] todo 400 member=${u.id} role=${u.role} fam=${u.famId} name=${name} err=${e instanceof Error ? e.message : e}`
);
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
}
}
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.11.6" "version": "1.11.7"
} }