// Single source of truth for the PocketBase schema + field builders. // 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` + // `accesscodes` collections are NOT in SCHEMA_PLAN — they're applied separately // in migrate.ts (users is PB's built-in auth model; `otp`/`accesscodes` need // null rules, which the `col()` builder can't express). The public-read // `platform` settings collection is also applied there (needs null write // rules). Everything else lives here. export interface FieldDef { name: string; type: string; required?: boolean; unique?: boolean; max?: number; min?: number; values?: string[]; maxSelect?: number; collectionId?: string; cascadeDelete?: boolean; } export interface CollectionDef { name: string; type: string; fields: FieldDef[]; listRule?: string | null; viewRule?: string | null; createRule?: string | null; updateRule?: string | null; deleteRule?: string | null; } // ── Field builders ── export function text(name: string, required = false): FieldDef { return { name, type: "text", required }; } export function uniqueText(name: string): FieldDef { return { name, type: "text", required: true, unique: true }; } export function number(name: string, required = false): FieldDef { return { name, type: "number", required }; } export function bool(name: string): FieldDef { return { name, type: "bool" }; } export function date(name: string): FieldDef { return { name, type: "date" }; } export function jsonField(name: string): FieldDef { return { name, type: "json" }; } export function select( name: string, values: string[], required = false, ): FieldDef { return { name, type: "select", required, values, maxSelect: 1 }; } export function rel( name: string, collectionId: string, required = false, ): FieldDef { return { name, type: "relation", required, collectionId, maxSelect: 1, cascadeDelete: false, }; } // ── Per-user access rules ── // The app writes directly to PB as the authenticated user (their own token), // so PB enforces famId scoping instead of running everything as superuser. // Only genuinely privileged app-level actions (signup, OTP join, member // creation, superuser dashboard, CRON/webhook) use the superuser client. // // Admin-only collections require role='parent'; child-accessible collections // (completions toggle, reward claim, chat) are scoped by famId alone. export const RULE_PARENT_WRITE = "@request.body.famId = @request.auth.famId && @request.auth.role = 'parent'"; export const RULE_PARENT_SCOPED = "famId = @request.auth.famId && @request.auth.role = 'parent'"; export const RULE_FAM_WRITE = "@request.body.famId = @request.auth.famId"; export const RULE_FAM_SCOPED = "famId = @request.auth.famId"; // Anyone in the family can read (list/view/subscribe) — members + parents. export const RULE_FAM_READ = "famId = @request.auth.famId"; // fams has no self-referencing famId field; its record id IS the famId. export const RULE_OWN_FAM = "id = @request.auth.famId"; // ── Collection builder ── export function col( name: string, fields: FieldDef[], rules: { listRule?: string | null; viewRule?: string | null; createRule?: string | null; updateRule?: string | null; deleteRule?: string | null; } = {}, ): (ids: Record) => CollectionDef { return (ids) => ({ name, type: "base", listRule: rules.listRule ?? "", viewRule: rules.viewRule ?? "", createRule: rules.createRule ?? null, updateRule: rules.updateRule ?? null, deleteRule: rules.deleteRule ?? null, fields, }); } export type CollectionPlanEntry = { name: string; build: (ids: Record) => CollectionDef; }; // Ordered so every relation's target already exists (and its id is in `ids`) // when a collection is built. `fams` is superadmin-only (listRule/viewRule null). export const SCHEMA_PLAN: CollectionPlanEntry[] = [ { name: "fams", build: (ids) => col( "fams", [ text("name", true), uniqueText("slug"), text("stripeCustomerId"), bool("active"), number("payday"), text("lastIssued"), text("paydayTime"), text("timezone"), // Access gating. paymentMode: how this fam has access — a code, a // Stripe subscription, canceled, or none (no access). `active` is the // derived "usable right now" flag recomputed by the access check on // every layout load (and by the Stripe webhook for subs). `accessCodeId` // + `accessCodeEnteredAt` back the 'code' mode (the duration clock // starts at entry; global expiry is the code createdAt + expiry months). select("paymentMode", ["none", "code", "sub", "canceled"]), text("accessCodeId"), date("accessCodeEnteredAt"), ], { listRule: RULE_OWN_FAM, viewRule: RULE_OWN_FAM, updateRule: RULE_OWN_FAM, }, )(ids), }, { name: "bonus_templates", build: (ids) => col( "bonus_templates", [ rel("famId", ids.fams, false), text("name", true), text("description"), select( "target", ["individual", "competitive", "collaborative"], true, ), select("type", ["threshold", "count", "manual"], true), select("thresholdType", ["points", "percent"], false), select("occurrence", ["recurring", "once"], true), select("rewardType", ["points", "cash", "prize"], true), text("rewardValue", false), number("criteriaValue"), select("period", ["schedule", "daily", "weekly", "monthly"]), bool("isPocketMoney"), bool("global"), text("icon"), text("color"), ], { listRule: "global = true || famId = @request.auth.famId", viewRule: "global = true || famId = @request.auth.famId", createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, }, )(ids), }, { name: "settings", build: (ids) => col( "settings", [rel("famId", ids.fams, true), text("webhookUrl"), number("lockMins")], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, }, )(ids), }, { name: "chore_templates", build: (ids) => col( "chore_templates", [ rel("famId", ids.fams, false), text("name", true), text("description"), select("defaultFrequency", ["daily", "weekly"], true), select("defaultType", ["points", "money"], true), number("defaultValue", true), bool("global"), text("icon"), text("color"), ], { listRule: "global = true || famId = @request.auth.famId", viewRule: "global = true || famId = @request.auth.famId", createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, }, )(ids), }, { name: "bonus_configs", build: (ids) => col("bonus_configs", [ rel("famId", ids.fams, true), text("name", true), text("description"), select("target", ["individual", "competitive", "collaborative"], true), select("type", ["threshold", "count", "manual"], true), select("thresholdType", ["points", "percent"], false), select("occurrence", ["recurring", "once"], true), select("rewardType", ["points", "cash", "prize"], true), text("rewardValue", false), number("criteriaValue"), rel("memberId", ids.users), select("period", ["schedule", "daily", "weekly", "monthly"]), select("status", ["active", "completed"], true), text("completedBy"), date("completedAt"), text("targetChoreId"), jsonField("targetChoreIds"), bool("isPocketMoney"), // Standalone/one-off reward progress window. `period` stays empty for // these (they're measured once, not per-cycle). `completeBy` bounds how // far progress counts: `unlimited` (cumulative since startDate), // `week` (end of current week), or `custom` (a specific completeByDate). // `startDate` is when tracking begins — without it "unlimited" would // count points earned before the reward existed. select("completeBy", ["unlimited", "week", "custom"]), text("startDate"), text("completeByDate"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, })(ids), }, { name: "weekly_history", build: (ids) => col( "weekly_history", [ rel("famId", ids.fams, true), rel("memberId", ids.users, true), date("weekStart"), number("pointsEarned"), number("moneyEarned"), number("choresCompleted"), number("bonusEarned"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, }, )(ids), }, { name: "seasons", build: (ids) => col( "seasons", [ rel("famId", ids.fams, true), text("name", true), text("color"), bool("active"), date("autoDisable"), date("autoStart"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, }, )(ids), }, { name: "messages", build: (ids) => col( "messages", [ rel("famId", ids.fams, true), select("authorType", ["admin", "member"], true), text("authorId", true), text("authorName", true), text("authorColor"), text("content", true), // 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, deleteRule: RULE_FAM_SCOPED, listRule: RULE_FAM_READ, viewRule: RULE_FAM_READ, }, )(ids), }, { name: "chat_typing", build: (ids) => col( "chat_typing", [ rel("famId", ids.fams, true), text("actorId", true), select("actorType", ["admin", "member"], true), text("authorName", true), text("authorColor"), bool("typing"), ], { createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED, listRule: RULE_FAM_READ, viewRule: RULE_FAM_READ, }, )(ids), }, { name: "rewards", build: (ids) => col( "rewards", [ rel("famId", ids.fams, true), rel("memberId", ids.users, true), rel("bonusConfigId", ids.bonus_configs), text("label", true), number("value", true), select("rewardType", ["cash", "prize", "points"], true), select("status", ["unclaimed", "requested", "claimed"], true), select("claimable", ["immediate", "payday"], false), text("settleDate"), date("claimedAt"), date("requestedAt"), text("date"), ], { createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED, listRule: RULE_FAM_READ, viewRule: RULE_FAM_READ, }, )(ids), }, { name: "assigned_chores", build: (ids) => col( "assigned_chores", [ rel("famId", ids.fams, true), rel("memberId", ids.users, false), // null = shared with all members bool("shared"), // true = initially shared (tracked for revoke) rel("templateId", ids.chore_templates), select("frequency", ["daily", "weekly"], true), select("type", ["points", "money", "emoji"], true), number("value", false), text("customName"), text("description"), text("icon"), text("color"), text("emoji"), jsonField("seasonIds"), bool("isTodo"), text("startDate"), text("completeBy"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED, listRule: RULE_FAM_READ, viewRule: RULE_FAM_READ, }, )(ids), }, { name: "completions", build: (ids) => col( "completions", [ rel("famId", ids.fams, true), rel("memberId", ids.users, true), rel("assignedChoreId", ids.assigned_chores, true), date("date"), date("completedAt"), text("rewardId"), ], { createRule: RULE_FAM_WRITE, updateRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED, listRule: RULE_FAM_READ, viewRule: RULE_FAM_READ, }, )(ids), }, ];