445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
// 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<string, string>) => 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<string, string>) => 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,
|
|
},
|
|
)(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,
|
|
},
|
|
)(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,
|
|
},
|
|
)(ids),
|
|
},
|
|
];
|