diff --git a/AGENTS.md b/AGENTS.md index 786c875..43298f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ - `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId - `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl +> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. + ## Routes ``` diff --git a/MEMORY.md b/MEMORY.md index fca4786..1f96f32 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,13 @@ # FamChore v2 — Development Memory +## 2026-08-17 — Schema collapse to single source of truth (Option 1) + +- **Decision**: abandoned incremental migration history. `SCHEMA_PLAN` (`shared/pb/schema.ts`) is now the true, current schema; `migrate.ts` collapsed from 2096 → 183 lines to a **fresh-only bootstrap** (`ensureSchema()` skips if `fams` exists — no incremental steps). The app isn't live and data is disposable, so there's nothing to preserve; a schema change = update `SCHEMA_PLAN` + wipe PB + reboot. +- **Folded the net effect of ~40 hand-written steps into `SCHEMA_PLAN`** (verified against the live PB): `fams.{payday,lastIssued,paydayTime,timezone}` (dropped stale `seasons`), `settings.simulateEow`, `messages.clientId`, `assigned_chores.{isTodo,startDate,completeBy}`. `bonus_configs.memberId`, `weekly_history/rewards/assigned_chores/completions.memberId` → `users`. +- **Out of `SCHEMA_PLAN`** (handled in `migrate.ts`): the native `users` auth collection (custom `famId`/`role`/`username`/`color` fields + `username` unique index + password-auth identity + famId-scoped rules, via `ensureUsers`) and the superuser-only `otp` collection (null rules — `col()` can't express `null`, via `ensureOtp`). +- **Gotcha**: `col()` coerces `rules.listRule ?? ""` → can't emit `null` rules, so `otp` stays out of the plan. `ensureSchema` needs the live PB to confirm the true schema (SCHEMA_PLAN was stale before this). +- Verify path: wipe dev PB + restart frontend (needs user OK) so `migrateOnBoot` rebuilds from `SCHEMA_PLAN`. + ## 2026-08-17 — Hono proxy removed: everything runs in SvelteKit services - **Decision**: deleted the `proxy/` Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in `frontend/src/lib/server/services/`, grouped **by app area** (not by role): `fam.ts`, `chores.ts`, `completions.ts`, `rewards.ts`, `bonuses.ts`, `chat.ts`, `settings.ts`, `crud.ts`, `debug.ts`, plus a generic per-resource `crud.ts`. `createServices(pb, user)` returns a per-feature binder; `servicesFor(event)` is the shorthand for loads/form actions. **No role guard** — PB collection rules on the acting user's token are the security boundary (the `admin`/`member` split no longer exists as separate files). diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 1020f7e..6b25dd9 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -24,9 +24,7 @@ async function getCollection(name: string): Promise { const t = await auth(); const res = await fetch( `${PB_ENDPOINT}/api/collections?filter=name='${name}'`, - { - headers: { Authorization: `Bearer ${t}` }, - }, + { headers: { Authorization: `Bearer ${t}` } }, ); const data = await res.json(); return data?.items?.[0] || null; @@ -36,15 +34,11 @@ async function createCollection(col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, body: JSON.stringify(col), }); const data = await res.json(); - if (!res.ok) - throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); + if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); console.log(` ✓ Created collection: ${col.name}`); return data?.id || null; } @@ -53,31 +47,124 @@ async function updateCollection(id: string, col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, body: JSON.stringify(col), }); const data = await res.json(); - if (!res.ok) - throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); + if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); console.log(` ✓ Updated collection: ${col.name || id}`); } -// Bootstrap the full base schema on a fresh PocketBase (docker first boot). -// Idempotent — skips collections that already exist. Schema is shared with -// seed.ts via shared/pb/schema.ts. +// Apply the custom fields + rules the app relies on to PB's native `users` +// auth collection (created automatically on first serve). Children live here as +// role='child'; username is a password-auth identity so the server can +// authWithPassword(derivedPassword) at OTP join time. +async function ensureUsers(ids: Record): Promise { + const usersCol = await getCollection("users"); + if (!usersCol) throw new Error("users collection not found"); + const famsId = ids.fams || (await getCollection("fams"))?.id; + if (!famsId) throw new Error("fams collection not found"); + + const has = (n: string) => usersCol.fields.some((f: any) => f.name === n); + let changed = false; + + const emailField = usersCol.fields.find((f: any) => f.name === "email"); + if (emailField && emailField.required) { + emailField.required = false; + changed = true; + } + if (!has("famId")) { + usersCol.fields.push({ + name: "famId", type: "relation", required: false, + collectionId: famsId, maxSelect: 1, cascadeDelete: false, + }); + changed = true; + } + if (!has("role")) { + usersCol.fields.push({ name: "role", type: "select", required: false, values: ["parent", "child"], maxSelect: 1 }); + changed = true; + } + if (!has("username")) { + usersCol.fields.push({ name: "username", type: "text", required: true }); + changed = true; + } + if (!has("color")) { + usersCol.fields.push({ name: "color", type: "text", required: false }); + changed = true; + } + + let indexes = usersCol.indexes || []; + if (!indexes.some((i: string) => /username/i.test(i))) { + indexes = [...indexes, "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''"]; + changed = true; + } + + const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; + const identityFields = Array.isArray(pwAuth.identityFields) ? pwAuth.identityFields : ["email"]; + if (!identityFields.includes("username")) { + identityFields.push("username"); + changed = true; + } + + const listRule = "famId = @request.auth.famId"; + const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; + if (usersCol.listRule !== listRule || usersCol.viewRule !== listRule || + usersCol.updateRule !== parentWrite || usersCol.deleteRule !== parentWrite) { + changed = true; + } + + if (changed) { + await updateCollection(usersCol.id, { + name: "users", + type: "auth", + listRule, + viewRule: listRule, + createRule: usersCol.createRule || "", + updateRule: parentWrite, + deleteRule: parentWrite, + fields: usersCol.fields, + indexes, + passwordAuth: { enabled: true, identityFields }, + }); + } +} + +// Superuser-only OTP store for the child join gate. Holds the rotating code and +// its issue timestamp (20-min window). Not public — read/written via the +// superuser client only. +async function ensureOtp(ids: Record): Promise { + if (await getCollection("otp")) return; + const famsId = ids.fams || (await getCollection("fams"))?.id; + const usersId = ids.users || (await getCollection("users"))?.id; + if (!famsId || !usersId) throw new Error("fams/users collection not found"); + await createCollection({ + name: "otp", + type: "base", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, + { name: "userId", type: "relation", required: true, collectionId: usersId, maxSelect: 1, cascadeDelete: false }, + { name: "otp", type: "text", required: false }, + { name: "updatedAt", type: "text", required: false }, + ], + }); +} + +// Bootstrap the full schema on a fresh/wiped PocketBase. Idempotent — skips if +// `fams` already exists (data is disposable; there is no incremental migration +// history). async function ensureSchema(): Promise { if (await getCollection("fams")) { - console.log("[migrate] Base schema already present — skipping bootstrap."); + console.log("[migrate] Schema already present — skipping bootstrap."); return; } - console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); + console.log("[migrate] Bootstrapping schema on fresh PocketBase..."); const ids: Record = {}; - // `users` is PocketBase's native auth collection (created on first boot), - // not part of SCHEMA_PLAN. Pre-register its id so relations can point at it. const nativeUsers = await getCollection("users"); if (nativeUsers) ids.users = nativeUsers.id; for (const entry of SCHEMA_PLAN) { @@ -85,2012 +172,13 @@ async function ensureSchema(): Promise { if (createdId) ids[entry.name] = createdId; } - // fams is sensitive → superadmin-only (proxy/server reads). Not public. - const famsId = ids.fams; - if (famsId) await updateCollection(famsId, { viewRule: null, listRule: null }); - console.log("[migrate] Base schema bootstrapped."); + await ensureUsers(ids); + await ensureOtp(ids); + console.log("[migrate] Schema bootstrapped."); } export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); - await ensureSchema(); - - // ── 0. Lock fams to superadmin-only (sensitive; read via proxy/server) ── - { - const famsCol = await getCollection("fams"); - if (famsCol) { - const rules = { viewRule: null, listRule: null }; - if (famsCol.viewRule !== null || famsCol.listRule !== null) { - console.log("[migrate] Locking fams collection to superadmin-only..."); - await updateCollection(famsCol.id, rules); - } else { - console.log(" ↳ fams already superadmin-only"); - } - } - } - - // ── 1. Create bonus_configs if missing ── - const existing = await getCollection("bonus_configs"); - if (!existing) { - // Need to get fams collection ID first - const famsCol = await getCollection("fams"); - if (!famsCol) throw new Error("fams collection not found"); - const famsId = famsCol.id; - - console.log("[migrate] Creating bonus_configs collection..."); - await createCollection({ - name: "bonus_configs", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsId, - maxSelect: 1, - cascadeDelete: false, - }, - { name: "name", type: "text", required: true }, - { name: "description", type: "text", required: false }, - { - name: "target", - type: "select", - required: true, - values: ["individual", "competitive", "collaborative"], - maxSelect: 1, - }, - { - name: "type", - type: "select", - required: true, - values: ["threshold", "count", "manual"], - maxSelect: 1, - }, - { - name: "occurrence", - type: "select", - required: true, - values: ["recurring", "once"], - maxSelect: 1, - }, - { - name: "rewardType", - type: "select", - required: true, - values: ["points", "cash", "prize"], - maxSelect: 1, - }, - { name: "rewardValue", type: "text", required: true }, - { name: "criteriaValue", type: "number", required: false }, - { - name: "period", - type: "select", - required: false, - values: ["schedule", "daily", "weekly", "monthly"], - maxSelect: 1, - }, - { - name: "status", - type: "select", - required: true, - values: ["active", "archived"], - maxSelect: 1, - }, - ], - }); - } else { - console.log(` ↳ bonus_configs already exists`); - - const targetField = existing.fields.find((f: any) => f.name === "target"); - const periodField = existing.fields.find((f: any) => f.name === "period"); - const needTargetUpdate = - targetField && !targetField.values.includes("collaborative"); - const needPeriodUpdate = - periodField && !periodField.values.includes("daily"); - - if (needTargetUpdate || needPeriodUpdate) { - console.log("[migrate] Updating bonus_configs fields..."); - if (needTargetUpdate) - targetField.values = ["individual", "competitive", "collaborative"]; - if (needPeriodUpdate) - periodField.values = ["schedule", "daily", "weekly", "monthly"]; - await updateCollection(existing.id, { - name: "bonus_configs", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: existing.fields, - }); - } - } - - // ── 2. Update rewards collection ── - const rewardsCol = await getCollection("rewards"); - if (rewardsCol) { - const fieldNames = rewardsCol.fields.map((f: any) => f.name); - const needsUpdate = - !fieldNames.includes("date") || - fieldNames.includes("autoClaimed") || - fieldNames.includes("weekStart") || - fieldNames.includes("month"); - - if (needsUpdate) { - console.log("[migrate] Updating rewards collection schema..."); - - const bonusConfigsCol = await getCollection("bonus_configs"); - - const keepFields = rewardsCol.fields.filter((f: any) => - [ - "famId", - "memberId", - "label", - "value", - "claimed", - "claimedAt", - "rewardType", - "bonusConfigId", - "created", - "updated", - "id", - ].includes(f.name), - ); - - const newFields = [ - ...keepFields, - ...(bonusConfigsCol && !fieldNames.includes("bonusConfigId") - ? [ - { - name: "bonusConfigId", - type: "relation", - required: false, - collectionId: bonusConfigsCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - ] - : []), - ...(fieldNames.includes("rewardType") - ? [] - : [ - { - name: "rewardType", - type: "select", - required: true, - values: ["cash", "prize", "points"], - maxSelect: 1, - }, - ]), - ...(fieldNames.includes("claimedAt") - ? [] - : [{ name: "claimedAt", type: "date", required: false }]), - { name: "date", type: "text", required: false }, - ]; - - await updateCollection(rewardsCol.id, { - name: "rewards", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: newFields, - }); - - // Backfill existing rewards - const today = new Date().toISOString().slice(0, 10); - const backfillRes = await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records?perPage=200`, - { headers: { Authorization: `Bearer ${await auth()}` } }, - ); - const backfillData = await backfillRes.json(); - if (backfillData?.items) { - for (const r of backfillData.items) { - const t2 = await auth(); - const patches: Record = {}; - if (!r.date) { - patches.date = r.claimedAt?.slice(0, 10) || today; - } - if (r.rewardType === "points" && !r.claimed) { - patches.claimed = true; - patches.claimedAt = new Date().toISOString(); - } - if (Object.keys(patches).length > 0) { - await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t2}`, - }, - body: JSON.stringify(patches), - }, - ); - } - } - console.log(` ✓ Backfilled ${backfillData.items.length} rewards`); - } - } else { - console.log(` ↳ rewards schema is current`); - } - } else { - console.log(` ↳ rewards collection not found (will be created by seed)`); - } - - // Ensure rewards.claimable + rewards.settleDate exist (payday-gated bonuses) - const rewardsCol2 = await getCollection("rewards"); - if (rewardsCol2) { - const fieldNames = rewardsCol2.fields.map((f: any) => f.name); - const missing: any[] = []; - if (!fieldNames.includes("claimable")) { - missing.push({ - name: "claimable", - type: "select", - required: true, - values: ["immediate", "payday"], - maxSelect: 1, - }); - } - if (!fieldNames.includes("settleDate")) { - missing.push({ name: "settleDate", type: "text", required: false }); - } - if (missing.length) { - console.log( - "[migrate] Adding rewards.claimable/settleDate (payday gating)...", - ); - rewardsCol2.fields.push(...missing); - await updateCollection(rewardsCol2.id, { - name: "rewards", - type: "base", - listRule: rewardsCol2.listRule, - viewRule: rewardsCol2.viewRule, - createRule: rewardsCol2.createRule, - updateRule: rewardsCol2.updateRule, - deleteRule: rewardsCol2.deleteRule, - fields: rewardsCol2.fields, - }); - console.log(" ✓ rewards.claimable/settleDate added"); - } else { - console.log(` ↳ rewards.claimable/settleDate already exist`); - } - } - - // ── 3. Update settings collection (drop old fields) ── - const settingsCol = await getCollection("settings"); - if (settingsCol) { - const fieldNames = settingsCol.fields.map((f: any) => f.name); - if ( - fieldNames.includes("pointsThreshold") || - fieldNames.includes("weeklyBonus") - ) { - console.log( - "[migrate] Updating settings collection (dropping old fields)...", - ); - const keepFields = settingsCol.fields.filter((f: any) => - ["famId", "webhookUrl", "created", "updated", "id"].includes(f.name), - ); - await updateCollection(settingsCol.id, { - name: "settings", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: keepFields, - }); - } else { - console.log(` ↳ settings schema is current`); - } - - // Ensure simulateEow debug flag exists (read-only EOW preview toggle) - const simField = settingsCol.fields.some( - (f: any) => f.name === "simulateEow", - ); - if (!simField) { - console.log("[migrate] Adding settings.simulateEow (debug toggle)..."); - settingsCol.fields.push({ name: "simulateEow", type: "bool" }); - await updateCollection(settingsCol.id, { - name: "settings", - type: "base", - listRule: settingsCol.listRule, - viewRule: settingsCol.viewRule, - createRule: settingsCol.createRule, - updateRule: settingsCol.updateRule, - deleteRule: settingsCol.deleteRule, - fields: settingsCol.fields, - }); - } else { - console.log(` ↳ settings.simulateEow already exists`); - } - } - - // ── 5. Add payday field to fams if missing ── - const famsCol = await getCollection("fams"); - if (famsCol) { - const hasPayday = famsCol.fields.some((f: any) => f.name === "payday"); - if (!hasPayday) { - console.log("[migrate] Adding payday field to fams..."); - const paydayField = { - name: "payday", - type: "number", - required: false, - min: 0, - max: 6, - }; - famsCol.fields.push(paydayField); - await updateCollection(famsCol.id, { - name: "fams", - type: "base", - listRule: famsCol.listRule, - viewRule: famsCol.viewRule, - createRule: famsCol.createRule, - updateRule: famsCol.updateRule, - deleteRule: famsCol.deleteRule, - fields: famsCol.fields, - }); - } else { - console.log(` ↳ fams.payday already exists`); - } - } - - // ── 5b. Add lastIssued (payday heartbeat) field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasLastIssued = fc.fields.some((f: any) => f.name === "lastIssued"); - if (!hasLastIssued) { - console.log("[migrate] Adding lastIssued field to fams..."); - fc.fields.push({ name: "lastIssued", type: "text" }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.lastIssued already exists`); - } - } - } - - // ── 5c. Add paydayTime (HH:MM) field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasPaydayTime = fc.fields.some((f: any) => f.name === "paydayTime"); - if (!hasPaydayTime) { - console.log("[migrate] Adding paydayTime field to fams..."); - fc.fields.push({ name: "paydayTime", type: "text" }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.paydayTime already exists`); - } - } - } - - // ── 5d. Add timezone (IANA name or "auto") field to fams if missing ── - { - const fc = famsCol || (await getCollection("fams")); - if (fc) { - const hasTz = fc.fields.some((f: any) => f.name === "timezone"); - if (!hasTz) { - console.log("[migrate] Adding timezone field to fams..."); - fc.fields.push({ - name: "timezone", - type: "text", - max: 64, - pattern: "", - autogeneratePattern: "", - primaryKey: false, - system: false, - required: false, - unique: false, - hidden: false, - presentable: false, - noDecimal: false, - }); - await updateCollection(fc.id, { - name: "fams", - type: "base", - listRule: fc.listRule, - viewRule: fc.viewRule, - createRule: fc.createRule, - updateRule: fc.updateRule, - deleteRule: fc.deleteRule, - fields: fc.fields, - }); - } else { - console.log(` ↳ fams.timezone already exists`); - } - } - } - - // ── 5e. Backfill fams.timezone = "auto" for any existing fams ── - try { - const t = await auth(); - const res = await fetch( - `${PB_ENDPOINT}/api/collections/fams/records?perPage=200&filter=${encodeURIComponent( - `timezone = "" || timezone = null`, - )}`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const data = await res.json(); - const fams = data?.items || []; - for (const f of fams) { - await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${f.id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ timezone: "auto" }), - }); - } - if (fams.length) { - console.log( - ` ✓ Backfilled timezone="auto" for ${fams.length} fam${fams.length > 1 ? "s" : ""}`, - ); - } - } catch (err) { - console.log( - " ↳ timezone backfill skipped:", - err instanceof Error ? err.message : err, - ); - } - - // ── 6. Backfill completions.date — strip timestamps to YYYY-MM-DD ── - // PB v0.25 doesn't allow changing field type (date→text), so we keep it as `date` - // and just normalise existing records. The frontend reads with .slice(0, 10) either way. - try { - const completionsCol = await getCollection("completions"); - if (completionsCol) { - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - if (data?.items?.length) { - let backfilled = 0; - for (const rec of data.items) { - const plain = rec.date?.slice(0, 10); - if (plain && plain !== rec.date) { - await fetch( - `${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ date: plain }), - }, - ); - backfilled++; - } - } - if (backfilled > 0) - console.log(` ✓ Backfilled ${backfilled} completion dates`); - else console.log(` ↳ completions.date already normalised`); - } - } - } catch (e) { - console.log( - ` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`, - ); - } - - // ── 7. Add memberId field to bonus_configs ── - const bonusConfigsCol = await getCollection("bonus_configs"); - if (bonusConfigsCol) { - const hasMemberId = bonusConfigsCol.fields.some( - (f: any) => f.name === "memberId", - ); - if (!hasMemberId) { - const membersCol = await getCollection("members"); - if (membersCol) { - console.log("[migrate] Adding memberId field to bonus_configs..."); - bonusConfigsCol.fields.push({ - name: "memberId", - type: "relation", - required: false, - collectionId: membersCol.id, - maxSelect: 1, - cascadeDelete: false, - }); - await updateCollection(bonusConfigsCol.id, { - name: "bonus_configs", - type: "base", - listRule: bonusConfigsCol.listRule, - viewRule: bonusConfigsCol.viewRule, - createRule: bonusConfigsCol.createRule, - updateRule: bonusConfigsCol.updateRule, - deleteRule: bonusConfigsCol.deleteRule, - fields: bonusConfigsCol.fields, - }); - } - } else { - console.log(` ↳ bonus_configs.memberId already exists`); - } - } - - // ── 8. (Removed) bonus_configs.phase field was dropped — replaced by status: active|completed. - // Templates now live in a dedicated `bonus_templates` collection (see step 23+). - - // ── 9. Add role + userId fields to members ── - const membersCol = await getCollection("members"); - if (membersCol) { - const hasRole = membersCol.fields.some((f: any) => f.name === "role"); - const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); - if (!hasRole || !hasUserId) { - console.log("[migrate] Adding role/userId fields to members..."); - if (!hasRole) - membersCol.fields.push({ - name: "role", - type: "select", - required: false, - values: ["parent", "child"], - maxSelect: 1, - }); - if (!hasUserId) - membersCol.fields.push({ - name: "userId", - type: "text", - required: false, - }); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.role/userId already exists`); - } - } - - // ── 10. Add email field to members ── - if (membersCol) { - const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); - if (!hasEmail) { - console.log("[migrate] Adding email field to members..."); - membersCol.fields.push({ name: "email", type: "text", required: false }); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.email already exists`); - } - } - - // ── 11. Backfill userId on existing member records ── - if (membersCol) { - try { - const t = await auth(); - const allMembers = await fetch( - `${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const membersData = await allMembers.json(); - const needBackfill = (membersData?.items || []).filter( - (m: any) => !m.userId, - ); - if (needBackfill.length > 0) { - console.log( - `[migrate] Backfilling userId for ${needBackfill.length} members...`, - ); - const adminsRes = await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const adminsData = await adminsRes.json(); - const adminsByFam = new Map(); - for (const a of adminsData?.items || []) { - const list = adminsByFam.get(a.famId) || []; - list.push(a); - adminsByFam.set(a.famId, list); - } - let count = 0; - for (const m of needBackfill) { - const admins = adminsByFam.get(m.famId) || []; - if (admins.length === 1) { - await fetch( - `${PB_ENDPOINT}/api/collections/members/records/${m.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ userId: admins[0].userId }), - }, - ); - count++; - } - } - if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`); - if (count < needBackfill.length) - console.log( - ` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`, - ); - } else { - console.log(` ↳ members.userId already backfilled`); - } - } catch (e) { - console.log( - ` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`, - ); - } - } - - // ── 12. Drop userId field from members ── - if (membersCol) { - const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); - if (hasUserId) { - console.log("[migrate] Dropping userId field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "userId", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.userId already removed`); - } - } - - // ── 13. Add name + color fields to fam_admins ── - const adminsCol = await getCollection("fam_admins"); - if (adminsCol) { - const hasName = adminsCol.fields.some((f: any) => f.name === "name"); - const hasColor = adminsCol.fields.some((f: any) => f.name === "color"); - if (!hasName || !hasColor) { - console.log("[migrate] Adding name/color to fam_admins..."); - if (!hasName) - adminsCol.fields.push({ name: "name", type: "text", required: false }); - if (!hasColor) - adminsCol.fields.push({ name: "color", type: "text", required: false }); - await updateCollection(adminsCol.id, { - name: "fam_admins", - type: "base", - listRule: adminsCol.listRule || "", - viewRule: adminsCol.viewRule || "", - createRule: adminsCol.createRule, - updateRule: adminsCol.updateRule, - deleteRule: adminsCol.deleteRule, - fields: adminsCol.fields, - }); - // Backfill name from email prefix - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - for (const a of data?.items || []) { - const patch: Record = {}; - if (!a.name) patch.name = (a.email || "admin").split("@")[0]; - if (!a.color) patch.color = "#6366f1"; - if (Object.keys(patch).length) { - await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify(patch), - }, - ); - } - } - console.log( - ` ✓ Backfilled name/color for ${(data?.items || []).length} admins`, - ); - } else { - console.log(` ↳ fam_admins.name/color already exists`); - } - } - - // ── 14. Drop role field from members ── - if (membersCol) { - const hasRole = membersCol.fields.some((f: any) => f.name === "role"); - if (hasRole) { - console.log("[migrate] Dropping role field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "role", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.role already removed`); - } - } - - // ── 15. Drop email field from members ── - if (membersCol) { - const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); - if (hasEmail) { - console.log("[migrate] Dropping email field from members..."); - membersCol.fields = membersCol.fields.filter( - (f: any) => f.name !== "email", - ); - await updateCollection(membersCol.id, { - name: "members", - type: "base", - listRule: membersCol.listRule, - viewRule: membersCol.viewRule, - createRule: membersCol.createRule, - updateRule: membersCol.updateRule, - deleteRule: membersCol.deleteRule, - fields: membersCol.fields, - }); - } else { - console.log(` ↳ members.email already removed`); - } - } - - // ── 16. Add seasons json field to fams ── - { - const c = await getCollection("fams"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasons"); - if (!hasField) { - console.log("[migrate] Adding seasons to fams..."); - c.fields.push({ name: "seasons", type: "json" }); - await updateCollection(c.id, { - name: "fams", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ fams.seasons already exists`); - } - } - } - - // ── 17. Add seasonIds json field to assigned_chores ── - { - const c = await getCollection("assigned_chores"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasonIds"); - if (!hasField) { - console.log("[migrate] Adding seasonIds to assigned_chores..."); - c.fields.push({ name: "seasonIds", type: "json" }); - await updateCollection(c.id, { - name: "assigned_chores", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ assigned_chores.seasonIds already exists`); - } - } - } - - // ── 18. Create seasons collection if missing ── - { - const existing = await getCollection("seasons"); - if (!existing) { - console.log("[migrate] Creating seasons collection..."); - const t = await auth(); - const famsCol = await getCollection("fams"); - const res = await fetch(`${PB_ENDPOINT}/api/collections`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ - name: "seasons", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - maxSelect: 1, - collectionId: famsCol?.id || "", - }, - { name: "name", type: "text", required: true }, - { name: "color", type: "text" }, - { name: "active", type: "bool" }, - { name: "autoDisable", type: "date" }, - { name: "autoStart", type: "date" }, - ], - }), - }); - if (res.ok) console.log(" ✓ Created seasons collection"); - else - console.log( - ` ↳ seasons collection creation skipped or already exists`, - ); - } else { - console.log(` ↳ seasons collection already exists`); - } - } - - // ── 19. Add active bool to existing seasons collection ── - { - const c = await getCollection("seasons"); - if (c) { - const hasActive = c.fields.some((f: any) => f.name === "active"); - if (!hasActive) { - console.log("[migrate] Adding active bool to seasons..."); - c.fields.push({ name: "active", type: "bool" }); - await updateCollection(c.id, { - name: "seasons", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ seasons.active already exists`); - } - } - } - - // ── 20. Backfill active=true from fams.seasons array ── - { - const t = await auth(); - const fams = await getCollection("fams"); - if (fams) { - const hasSeasonsField = fams.fields.some( - (f: any) => f.name === "seasons", - ); - if (hasSeasonsField) { - console.log( - "[migrate] Backfilling season active flags from fams.seasons...", - ); - const allFams = await fetch( - `${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const famData = await allFams.json(); - for (const fam of famData.items || []) { - const activeIds = fam.seasons || []; - if (activeIds.length > 0) { - for (const sid of activeIds) { - await fetch( - `${PB_ENDPOINT}/api/collections/seasons/records/${sid}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ active: true }), - }, - ); - } - } - } - console.log(` ↳ backfilled ${famData.items?.length || 0} fams`); - } else { - console.log(` ↳ fams.seasons already removed`); - } - } - } - - // ── 21. Remove seasons field from fams ── - { - const c = await getCollection("fams"); - if (c) { - const hasField = c.fields.some((f: any) => f.name === "seasons"); - if (hasField) { - console.log("[migrate] Removing seasons field from fams..."); - c.fields = c.fields.filter((f: any) => f.name !== "seasons"); - await updateCollection(c.id, { - name: "fams", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else { - console.log(` ↳ fams.seasons already removed`); - } - } - } - - // ── 22. Convert rewards.claimed boolean to status select field ── - { - const c = await getCollection("rewards"); - if (c) { - const hasClaimedField = c.fields.some((f: any) => f.name === "claimed"); - const hasStatusField = c.fields.some((f: any) => f.name === "status"); - if (hasClaimedField && !hasStatusField) { - console.log("[migrate] Converting rewards.claimed to status field..."); - - // Fetch all rewards to backfill status - const t = await auth(); - let page = 1; - let total = 0; - while (true) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await res.json(); - for (const r of data.items || []) { - const status = r.claimed ? "claimed" : "unclaimed"; - await fetch( - `${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ status }), - }, - ); - total++; - } - if (!data.items || data.items.length < 100) break; - page++; - } - console.log(` ↳ backfilled ${total} rewards with status`); - - // Remove claimed field and add status + requestedAt fields - c.fields = c.fields.filter((f: any) => f.name !== "claimed"); - if (!c.fields.some((f: any) => f.name === "status")) { - c.fields.push({ - name: "status", - type: "select", - required: true, - values: ["unclaimed", "requested", "claimed"], - maxSelect: 1, - }); - } - if (!c.fields.some((f: any) => f.name === "requestedAt")) { - c.fields.push({ name: "requestedAt", type: "date", required: false }); - } - await updateCollection(c.id, { - name: "rewards", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - } else if (!hasClaimedField) { - console.log(` ↳ rewards.claimed already converted to status`); - } - } - } - - // ── 23. Create bonus_templates collection if missing ── - { - const existing = await getCollection("bonus_templates"); - if (!existing) { - const famsCol = await getCollection("fams"); - if (!famsCol) throw new Error("fams collection not found"); - console.log("[migrate] Creating bonus_templates collection..."); - await createCollection({ - name: "bonus_templates", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - { name: "name", type: "text", required: true }, - { name: "description", type: "text", required: false }, - { - name: "target", - type: "select", - required: true, - values: ["individual", "competitive", "collaborative"], - maxSelect: 1, - }, - { - name: "type", - type: "select", - required: true, - values: ["threshold", "count", "manual"], - maxSelect: 1, - }, - { - name: "occurrence", - type: "select", - required: true, - values: ["recurring", "once"], - maxSelect: 1, - }, - { - name: "rewardType", - type: "select", - required: true, - values: ["points", "cash", "prize"], - maxSelect: 1, - }, - { name: "rewardValue", type: "text", required: true }, - { name: "criteriaValue", type: "number", required: false }, - { - name: "period", - type: "select", - required: false, - values: ["schedule", "daily", "weekly", "monthly"], - maxSelect: 1, - }, - ], - }); - } else { - console.log(` ↳ bonus_templates already exists`); - } - } - - // ── 24. Migrate phase='template' bonus_configs → bonus_templates, then delete originals ── - { - const configsCol = await getCollection("bonus_configs"); - const templatesCol = await getCollection("bonus_templates"); - if (configsCol && templatesCol) { - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - const templateRecords = (data?.items || []).filter( - (r: any) => r.phase === "template", - ); - if (templateRecords.length > 0) { - console.log( - `[migrate] Moving ${templateRecords.length} templates from bonus_configs → bonus_templates...`, - ); - for (const rec of templateRecords) { - const { - id, - phase, - status, - completedAt, - memberId, - created, - updated, - collectionId, - expand, - ...fields - } = rec; - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_templates/records`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify(fields), - }, - ); - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records/${id}`, - { - method: "DELETE", - headers: { Authorization: `Bearer ${t}` }, - }, - ); - } - console.log(` ✓ Moved ${templateRecords.length} bonus templates`); - } else { - console.log(` ↳ no phase=template bonus_configs to migrate`); - } - } - } - - // ── 25. Drop phase/completedAt from bonus_configs; status → active|completed ── - { - const c = await getCollection("bonus_configs"); - if (c) { - const hasPhase = c.fields.some((f: any) => f.name === "phase"); - const hasCompletedAt = c.fields.some( - (f: any) => f.name === "completedAt", - ); - const statusField = c.fields.find((f: any) => f.name === "status"); - const needsStatusUpdate = - statusField && !statusField.values.includes("completed"); - if (hasPhase || hasCompletedAt || needsStatusUpdate) { - console.log( - "[migrate] Restructuring bonus_configs (drop phase/completedAt, status → active|completed)...", - ); - if (statusField && needsStatusUpdate) - statusField.values = ["active", "completed"]; - c.fields = c.fields.filter( - (f: any) => f.name !== "phase" && f.name !== "completedAt", - ); - await updateCollection(c.id, { - name: "bonus_configs", - type: "base", - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - fields: c.fields, - }); - const t = await auth(); - const all = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`, - { - headers: { Authorization: `Bearer ${t}` }, - }, - ); - const data = await all.json(); - let updated = 0; - for (const rec of data?.items || []) { - const completed = - rec.phase === "completed" || rec.status === "archived"; - const newStatus = completed ? "completed" : "active"; - if (rec.status !== newStatus) { - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ status: newStatus }), - }, - ); - updated++; - } - } - console.log( - ` ✓ Updated ${updated} bonus_configs to status=active|completed`, - ); - } else { - console.log(` ↳ bonus_configs already restructured`); - } - } - } - - // ── 7. Add todo fields to assigned_chores if missing ── - const assignedCol = await getCollection("assigned_chores"); - if (assignedCol) { - let needsUpdate = false; - - // 7a. Make templateId non-required (todos don't use templates) - const tplField = assignedCol.fields.find( - (f: any) => f.name === "templateId", - ); - if (tplField && tplField.required) { - console.log( - "[migrate] Making assigned_chores.templateId non-required...", - ); - tplField.required = false; - needsUpdate = true; - } - - // 7b. Add isTodo, startDate, completeBy fields - const hasIsTodo = assignedCol.fields.some((f: any) => f.name === "isTodo"); - if (!hasIsTodo) { - console.log("[migrate] Adding todo fields to assigned_chores..."); - assignedCol.fields.push( - { name: "isTodo", type: "bool" }, - { name: "startDate", type: "text" }, - { name: "completeBy", type: "text" }, - ); - needsUpdate = true; - } else { - console.log(` ↳ assigned_chores.isTodo already exists`); - } - - if (needsUpdate) { - await updateCollection(assignedCol.id, { - name: "assigned_chores", - type: "base", - listRule: assignedCol.listRule, - viewRule: assignedCol.viewRule, - createRule: assignedCol.createRule, - updateRule: assignedCol.updateRule, - deleteRule: assignedCol.deleteRule, - fields: assignedCol.fields, - }); - } - } else { - console.log( - ` ↳ assigned_chores collection not found (will be created by seed)`, - ); - } - - // ── 8. Add completedAt timestamp to completions ── - const complCol = await getCollection("completions"); - if (complCol) { - const hasCompletedAt = complCol.fields.some( - (f: any) => f.name === "completedAt", - ); - if (!hasCompletedAt) { - console.log("[migrate] Adding completions.completedAt..."); - complCol.fields.push({ - name: "completedAt", - type: "date", - required: false, - hidden: false, - }); - await updateCollection(complCol.id, { - name: "completions", - type: "base", - listRule: complCol.listRule, - viewRule: complCol.viewRule, - createRule: complCol.createRule, - updateRule: complCol.updateRule, - deleteRule: complCol.deleteRule, - fields: complCol.fields, - }); - console.log(" ✓ completions.completedAt added"); - } else { - console.log(` ↳ completions.completedAt already exists`); - } - } else { - console.log( - ` ↳ completions collection not found (will be created by seed)`, - ); - } - - // ── 9. Create chat collections (messages + chat_typing) ── - const famsColChat = await getCollection("fams"); - if (famsColChat) { - // 9a. messages - const msgsCol = await getCollection("messages"); - if (!msgsCol) { - console.log("[migrate] Creating messages collection..."); - await createCollection({ - name: "messages", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsColChat.id, - maxSelect: 1, - cascadeDelete: true, - }, - { name: "authorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, - { name: "authorId", type: "text", required: true }, - { name: "authorName", type: "text", required: true }, - { name: "authorColor", type: "text", required: false }, - { name: "content", type: "text", required: true }, - { name: "createdAt", type: "date", required: false }, - { name: "clientId", type: "text", required: false, max: 64 }, - ], - }); - } else { - console.log(` ↳ messages already exists`); - // PB 0.39 doesn't auto-add createdAt to API-created collections, and - // older stores may predate it. Chat filters/sorts on createdAt, so - // ensure the field exists even if the collection was created without it. - const msgsFields = msgsCol.fields?.map((f: any) => f.name) || []; - let msgsChanged = false; - if (!msgsFields.includes("createdAt")) { - console.log(" ↳ adding missing createdAt field to messages..."); - msgsCol.fields.push({ - name: "createdAt", - type: "date", - required: false, - min: "", - max: "", - }); - msgsChanged = true; - } - if (!msgsFields.includes("clientId")) { - console.log(" ↳ adding missing clientId field to messages..."); - msgsCol.fields.push({ name: "clientId", type: "text", required: false, max: 64 }); - msgsChanged = true; - } - await updateCollection("messages", { - listRule: "", - viewRule: "", - fields: msgsCol.fields, - }); - } - - // 9b. chat_typing (transient presence rows, one per actor) - const typingCol = await getCollection("chat_typing"); - if (!typingCol) { - console.log("[migrate] Creating chat_typing collection..."); - await createCollection({ - name: "chat_typing", - type: "base", - listRule: "", - viewRule: "", - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsColChat.id, - maxSelect: 1, - cascadeDelete: true, - }, - { name: "actorId", type: "text", required: true }, - { name: "actorType", type: "select", required: true, values: ["admin", "member"], maxSelect: 1 }, - { name: "authorName", type: "text", required: true }, - { name: "authorColor", type: "text", required: false }, - { name: "typing", type: "bool", required: false }, - ], - }); - } else { - console.log(` ↳ chat_typing already exists`); - await updateCollection("chat_typing", { listRule: "", viewRule: "" }); - } - } else { - console.log(` ↳ fams collection not found — chat collections deferred`); - } - - // ── 26. Add famId + role to the users auth collection ── - // Admin identity now lives on the auth record so PB rules can scope via - // @request.auth.famId. role defaults to "parent" (only admins log in for now; - // children become a separate auth collection in the membership phase). - { - const usersCol = await getCollection("users"); - if (usersCol) { - const famsCol2 = await getCollection("fams"); - const hasFamId = usersCol.fields.some((f: any) => f.name === "famId"); - const hasRole = usersCol.fields.some((f: any) => f.name === "role"); - if (!hasFamId || !hasRole) { - console.log("[migrate] Adding famId/role to users collection..."); - if (!hasFamId && famsCol2) { - usersCol.fields.push({ - name: "famId", - type: "relation", - required: false, - collectionId: famsCol2.id, - maxSelect: 1, - cascadeDelete: false, - }); - } - if (!hasRole) { - usersCol.fields.push({ - name: "role", - type: "select", - required: false, - values: ["parent", "child"], - maxSelect: 1, - }); - } - await updateCollection(usersCol.id, { - name: "users", - type: "auth", - listRule: usersCol.listRule, - viewRule: usersCol.viewRule, - createRule: usersCol.createRule, - updateRule: usersCol.updateRule, - deleteRule: usersCol.deleteRule, - fields: usersCol.fields, - }); - console.log(" ✓ users.famId/role added"); - } else { - console.log(" ↳ users.famId/role already present"); - } - } - } - - // ── 27. Backfill users.famId from fam_admins ── - { - const usersCol = await getCollection("users"); - if (usersCol) { - const hasFamId = usersCol.fields.some((f: any) => f.name === "famId"); - if (hasFamId) { - try { - const t = await auth(); - const adminsRes = await fetch( - `${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const adminsData = await adminsRes.json(); - let count = 0; - for (const a of adminsData?.items || []) { - const u = await fetch( - `${PB_ENDPOINT}/api/collections/users/records/${a.userId}`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - if (!u.ok) continue; - const user = await u.json(); - if (!user.famId) { - await fetch( - `${PB_ENDPOINT}/api/collections/users/records/${user.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ famId: a.famId, role: "parent" }), - }, - ); - count++; - } - } - if (count > 0) - console.log(` ✓ Backfilled users.famId/role for ${count} users`); - else console.log(" ↳ users.famId already backfilled"); - } catch (e) { - console.log( - " ↳ users.famId backfill skipped:", - e instanceof Error ? e.message : e, - ); - } - } - } - } - - // ── 28. Lock family-scoped WRITE rules to the caller's famId ── - // Replaces the old "superuser-only writes via Hono" model. SvelteKit writes - // as the authenticated user, so PB itself enforces famId scoping — no more - // internet CRUD (anonymous `@request.auth` is null → rule fails). Reads stay - // public until children become authenticated (membership phase). - // - // Admin-only collections additionally require role='parent'; child-accessible - // collections (completions toggle, reward claim, chat) are famId-scoped only. - { - const PARENT_WRITE = - "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; - const PARENT_SCOPED = "famId = @request.auth.famId && @request.auth.role = 'parent'"; - const FAM_WRITE = "@request.body.famId = @request.auth.famId"; - const FAM_SCOPED = "famId = @request.auth.famId"; - const ADMIN_ONLY = [ - "chore_templates", - "assigned_chores", - "bonus_templates", - "bonus_configs", - "settings", - "weekly_history", - "monthly_bonuses", - "seasons", - ]; - const CHILD_ACCESSIBLE = ["completions", "rewards", "messages", "chat_typing"]; - for (const name of [...ADMIN_ONLY, ...CHILD_ACCESSIBLE]) { - const c = await getCollection(name); - if (!c) continue; - const isParent = ADMIN_ONLY.includes(name); - const write = isParent ? PARENT_WRITE : FAM_WRITE; - const scoped = isParent ? PARENT_SCOPED : FAM_SCOPED; - if (c.createRule === write && c.updateRule === scoped && c.deleteRule === scoped) continue; - await updateCollection(c.id, { - name, - type: c.type, - listRule: c.listRule, - viewRule: c.viewRule, - createRule: write, - updateRule: scoped, - deleteRule: scoped, - fields: c.fields, - }); - console.log(` ↳ Locked ${name} write rules (${isParent ? "parent" : "fam"} scoping)`); - } - } - - // ── 28b. Allow each user to READ + UPDATE their own fam record ── - // fams is the root collection: its record id IS the famId, and it has no - // famId field pointing to itself. So the scoping rule compares the record id - // to the caller's famId. Reads are enabled so both parents and children can - // load their fam via their own token; create/delete stay superuser-only. - { - const c = await getCollection("fams"); - if (c) { - const wantList = c.listRule !== "id = @request.auth.famId"; - const wantView = c.viewRule !== "id = @request.auth.famId"; - const wantUpdate = c.updateRule !== "id = @request.auth.famId"; - if (wantList || wantView || wantUpdate) { - await updateCollection(c.id, { - name: "fams", - type: c.type, - listRule: "id = @request.auth.famId", - viewRule: "id = @request.auth.famId", - createRule: c.createRule, - updateRule: "id = @request.auth.famId", - deleteRule: c.deleteRule, - fields: c.fields, - }); - console.log(" ↳ fams read+update scoped to own record (id = @request.auth.famId)"); - } - } - } - - // ── 29. Add username identity to the users auth collection ── - // Members now live in `users` alongside admins. They never type a password; - // OTP is the gate. username is registered as a password-auth IDENTITY so the - // server can authWithPassword(username, derivedPassword) at join time — the - // password is derived from (MEMBER_SECRET + famSlug + username), never - // user-supplied. email is made optional (admins log in via email; members use - // username only and have no email). In PB v0.23+ an identity field must have - // a single-column UNIQUE index AND be listed in passwordAuth.identityFields. - { - const usersCol = await getCollection("users"); - if (usersCol) { - const famsCol2 = await getCollection("fams"); - const hasUsername = usersCol.fields.some((f: any) => f.name === "username"); - - // email: required → optional (members have no email) - const emailField = usersCol.fields.find((f: any) => f.name === "email"); - if (emailField && emailField.required) { - emailField.required = false; - } - - if (!hasUsername && famsCol2) { - usersCol.fields.push({ name: "username", type: "text", required: true }); - } - - // ensure a UNIQUE index on username exists (identity requirement) - let indexes = usersCol.indexes || []; - const hasUsernameIdx = indexes.some((i: string) => /username/i.test(i)); - if (!hasUsernameIdx) { - indexes = [ - ...indexes, - "CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''", - ]; - } - - // register username as a password-auth identity field - const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] }; - const identityFields = Array.isArray(pwAuth.identityFields) - ? pwAuth.identityFields - : ["email"]; - if (!identityFields.includes("username")) identityFields.push("username"); - - await updateCollection(usersCol.id, { - name: "users", - type: "auth", - listRule: usersCol.listRule, - viewRule: usersCol.viewRule, - createRule: usersCol.createRule, - updateRule: usersCol.updateRule, - deleteRule: usersCol.deleteRule, - fields: usersCol.fields, - indexes, - passwordAuth: { enabled: true, identityFields }, - }); - console.log(" ✓ users: email optional, username auth identity"); - } - } - - // ── 30. otp: OTP store (superuser-only) ── - // Holds the rotating one-time code and the OTP-issue timestamp used for the - // 20-minute join window. Sensitive (OTPs) → not public; read/written via - // createSuperClient in SvelteKit. `created`/`updated` are PB built-ins; the - // manual `updatedAt` is written ONLY on OTP (re)issue so the window stays - // accurate. userId links to the users auth record so each child's config is - // uniquely addressable. (Display colour lives on users.color, not here.) - { - if (!(await getCollection("otp"))) { - const famsCol = await getCollection("fams"); - const usersCol = await getCollection("users"); - if (!famsCol || !usersCol) throw new Error("fams/users collection not found"); - console.log("[migrate] Creating otp collection..."); - await createCollection({ - name: "otp", - type: "base", - listRule: null, - viewRule: null, - createRule: null, - updateRule: null, - deleteRule: null, - fields: [ - { - name: "famId", - type: "relation", - required: true, - collectionId: famsCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - { - name: "userId", - type: "relation", - required: true, - collectionId: usersCol.id, - maxSelect: 1, - cascadeDelete: false, - }, - { name: "otp", type: "text", required: false }, - { name: "updatedAt", type: "text", required: false }, - ], - }); - } else { - console.log(" ↳ otp already exists"); - } - } - - // ── 31. Add name + color to users (child display fields) ── - // Children are now `users` records; admin views (weekly summary, ledger, - // bonus progress, kanban) render name/color from the users record directly - // instead of a separate members row. - { - const usersCol = await getCollection("users"); - if (usersCol) { - const needName = !usersCol.fields.some((f: any) => f.name === "name"); - const needColor = !usersCol.fields.some((f: any) => f.name === "color"); - if (needName || needColor) { - console.log("[migrate] Adding name/color to users collection..."); - if (needName) - usersCol.fields.push({ name: "name", type: "text", required: false }); - if (needColor) - usersCol.fields.push({ name: "color", type: "text", required: false }); - await updateCollection(usersCol.id, { - name: "users", - type: "auth", - listRule: usersCol.listRule, - viewRule: usersCol.viewRule, - createRule: usersCol.createRule, - updateRule: usersCol.updateRule, - deleteRule: usersCol.deleteRule, - fields: usersCol.fields, - }); - console.log(" ✓ users.name/color added"); - // Backfill display fields for existing child users (created before the - // name/color fields existed). - try { - const t = await auth(); - const childRes = await fetch( - `${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent( - "role = 'child'", - )}`, - { headers: { Authorization: `Bearer ${t}` } }, - ); - const childData = await childRes.json(); - for (const u of childData?.items || []) { - if (!u.name || !u.color) { - await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${t}`, - }, - body: JSON.stringify({ - name: u.name || u.username || "", - color: u.color || "#6366f1", - }), - }); - } - } - if ((childData?.items || []).length) - console.log(" ↳ Backfilled child users name/color"); - } catch (e) { - console.log( - " ↳ child name/color backfill skipped:", - e instanceof Error ? e.message : e, - ); - } - } else { - console.log(" ↳ users.name/color already present"); - } - } - } - - // ── 31b. Backfill parent role on users ── - // Legacy parent users were created before users.role existed (or before it - // was set), leaving role empty. The app falls back `role || 'parent'`, but we - // normalize it here so records are self-consistent. - { - const t = await auth(); - const headers = { - Authorization: `Bearer ${t}`, - "Content-Type": "application/json", - }; - try { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent( - "role = ''", - )}`, - { headers }, - ); - const data = await res.json(); - for (const u of data?.items || []) { - await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, { - method: "PATCH", - headers, - body: JSON.stringify({ role: "parent" }), - }); - } - if ((data?.items || []).length) - console.log(` ↳ Backfilled ${data.items.length} users -> role=parent`); - } catch (e) { - console.log( - " ↳ parent role backfill skipped:", - e instanceof Error ? e.message : e, - ); - } - } - - // ── 32. Repoint memberId relations from members -> users ── - // Every child-scoped collection's memberId field now points at the users - // auth collection (children live there as role='child'). Must run before - // the members collection is deleted (a collection referenced by a relation - // field cannot be removed). PB (v0.39) forbids changing a relation field's - // target collection in place, so we drop the field and re-add it targeting - // users in two separate collection updates. - { - const usersCol = await getCollection("users"); - const membersCol = await getCollection("members"); - if (usersCol && membersCol) { - for (const name of [ - "assigned_chores", - "completions", - "rewards", - "weekly_history", - "bonus_configs", - ]) { - const c = await getCollection(name); - if (!c) continue; - const f = c.fields.find((x: any) => x.name === "memberId"); - if (f && f.collectionId === membersCol.id) { - const base = { - name, - type: c.type, - listRule: c.listRule, - viewRule: c.viewRule, - createRule: c.createRule, - updateRule: c.updateRule, - deleteRule: c.deleteRule, - }; - const without = c.fields.filter((x: any) => x.name !== "memberId"); - // 1) drop memberId - await updateCollection(c.id, { ...base, fields: without }); - // 2) re-add memberId pointing at users - const withUsers = without.concat({ - name: "memberId", - type: "relation", - required: false, - collectionId: usersCol.id, - cascadeDelete: false, - minSelect: 0, - maxSelect: 1, - }); - await updateCollection(c.id, { ...base, fields: withUsers }); - console.log(` ↳ Repointed ${name}.memberId -> users`); - } else { - console.log(` ↳ ${name}.memberId already -> users`); - } - } - } else if (usersCol) { - console.log(" ↳ members already gone; memberId rels unchanged"); - } - } - - // ── 32b. Scope users read/write rules for the child model ── - // Children live in `users`. Family reads (admin famStore, kanban, loads) run - // as the authenticated user via pbUser/pb.authStore, so the collection needs - // list/view rules keyed to the caller's famId. Creating children stays - // superuser-only (issueAccess/createChild use createSuperClient); parents may - // update/delete their own fam's child users via pbUser. - { - const usersCol = await getCollection("users"); - if (usersCol) { - const listRule = "famId = @request.auth.famId"; - const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'"; - if ( - usersCol.listRule !== listRule || - usersCol.viewRule !== listRule || - usersCol.updateRule !== parentWrite || - usersCol.deleteRule !== parentWrite - ) { - console.log("[migrate] Scoping users read/write rules..."); - await updateCollection(usersCol.id, { - name: "users", - type: "auth", - listRule, - viewRule: listRule, - createRule: usersCol.createRule, - updateRule: parentWrite, - deleteRule: parentWrite, - fields: usersCol.fields, - }); - console.log(" ↳ users rules: list/view = famId scope, parent write"); - } else { - console.log(" ↳ users rules already scoped"); - } - } - } - - // ── 33. Delete legacy members collection + stale child-scoped data ── - // Old member rows and the data keyed to them are not preserved (accounts - // won't be reused). Wipe child-scoped rows whose memberId pointed at the old - // members rows, clear bonus_configs member targets, then drop the collection. - { - const membersCol = await getCollection("members"); - if (membersCol) { - console.log("[migrate] Removing legacy members collection + stale data..."); - const t = await auth(); - const headers = { Authorization: `Bearer ${t}` }; - const wipeCollection = async (name: string) => { - let page = 0; - for (;;) { - const res = await fetch( - `${PB_ENDPOINT}/api/collections/${name}/records?perPage=100&page=${page + 1}`, - { headers }, - ); - const data = await res.json(); - const items: any[] = data?.items || []; - if (!items.length) break; - for (const r of items) { - await fetch( - `${PB_ENDPOINT}/api/collections/${name}/records/${r.id}`, - { method: "DELETE", headers }, - ); - } - if (items.length < 100) break; - page++; - } - }; - for (const name of [ - "assigned_chores", - "completions", - "rewards", - "weekly_history", - ]) { - await wipeCollection(name); - } - console.log(" ↳ Wiped stale child-scoped data"); - const cfgRes = await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=200`, - { headers }, - ); - const cfgData = await cfgRes.json(); - for (const cfg of cfgData?.items || []) { - if (cfg.memberId) { - await fetch( - `${PB_ENDPOINT}/api/collections/bonus_configs/records/${cfg.id}`, - { - method: "PATCH", - headers: { ...headers, "Content-Type": "application/json" }, - body: JSON.stringify({ memberId: null }), - }, - ); - } - } - console.log(" ↳ Cleared bonus_configs.memberId targets"); - await fetch(`${PB_ENDPOINT}/api/collections/${membersCol.id}`, { - method: "DELETE", - headers, - }); - console.log(" ✓ members collection deleted"); - } else { - console.log(" ↳ members already removed"); - } - } - - // ── 34. Drop legacy fam_admins collection + unused fams.inviteCode ── - // Parent identity now lives entirely on the `users` record (famId, role, - // name, color, email); the join flow is OTP-based, so inviteCode is unused. - { - const adminsCol = await getCollection("fam_admins"); - if (adminsCol) { - const t = await auth(); - const headers = { Authorization: `Bearer ${t}` }; - await fetch(`${PB_ENDPOINT}/api/collections/${adminsCol.id}`, { - method: "DELETE", - headers, - }); - console.log(" ✓ fam_admins collection deleted"); - } else { - console.log(" ↳ fam_admins already removed"); - } - const famsCol = await getCollection("fams"); - if (famsCol && famsCol.fields.some((f: any) => f.name === "inviteCode")) { - famsCol.fields = famsCol.fields.filter( - (f: any) => f.name !== "inviteCode", - ); - await updateCollection(famsCol.id, { - name: "fams", - type: "base", - listRule: famsCol.listRule || "", - viewRule: famsCol.viewRule || "", - createRule: famsCol.createRule, - updateRule: famsCol.updateRule, - deleteRule: famsCol.deleteRule, - fields: famsCol.fields, - }); - console.log(" ✓ fams.inviteCode field removed"); - } - } - console.log("[migrate] Done"); -} +} \ No newline at end of file diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 9881297..15a8775 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -1,9 +1,14 @@ // Single source of truth for the PocketBase schema + field builders. -// Consumed by BOTH proxy/src/migrate.ts (idempotent bootstrap) and -// proxy/scripts/seed.ts (fresh-store seed) so the schema isn't duplicated. +// Consumed by frontend/src/lib/server/migrate.ts (idempotent bootstrap) so the +// schema isn't duplicated. // // Relations reference collections by name; the `ids` map maps collection // name -> runtime id (filled as each collection is created). +// +// NOTE: the native `users` auth collection and the superuser-only `otp` +// collection are NOT in SCHEMA_PLAN — they're applied separately in +// migrate.ts (users is PB's built-in auth model; `otp` needs null rules, +// which the `col()` builder can't express). Everything else lives here. export interface FieldDef { name: string; @@ -128,7 +133,10 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ uniqueText("slug"), text("stripeCustomerId"), jsonField("featureFlags"), - jsonField("seasons"), + number("payday"), + text("lastIssued"), + text("paydayTime"), + text("timezone"), ], { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM }, )(ids), @@ -156,7 +164,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ { name: "settings", build: (ids) => - col("settings", [rel("famId", ids.fams, true), text("webhookUrl")], { + col("settings", [rel("famId", ids.fams, true), text("webhookUrl"), bool("simulateEow")], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, @@ -246,6 +254,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ // Explicit createdAt: PB 0.39 does NOT auto-add createdAt to // API-created collections (0.25 did). Chat filters/sorts on it. date("createdAt"), + text("clientId"), ], { createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, @@ -302,6 +311,9 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ number("value", true), text("customName"), jsonField("seasonIds"), + bool("isTodo"), + text("startDate"), + text("completeBy"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED,