migrate auth v2 code
This commit is contained in:
@@ -14,3 +14,8 @@ export const PB_ENDPOINT =
|
||||
(SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`);
|
||||
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
|
||||
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
|
||||
// Shared secret used to DERIVE a child's users password as
|
||||
// `MEMBER_SECRET + famSlug + username` (same formula as the frontend
|
||||
// member-otp.ts). Never typed by anyone; OTP is the access gate.
|
||||
export const MEMBER_SECRET =
|
||||
process.env.MEMBER_SECRET || "famchamp-member-secret";
|
||||
|
||||
+190
-299
@@ -1,9 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { pb } from "./pb.ts";
|
||||
import { migrate } from "./migrate.ts";
|
||||
import { PROXY_PORT } from "@shared/config.ts";
|
||||
import { PB_ENDPOINT, MEMBER_SECRET } from "./env.ts";
|
||||
import {
|
||||
weekStart as tzWeekStart,
|
||||
addDaysStr,
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
resolveTz,
|
||||
nextPaydayAfter as nextPaydayAfterTz,
|
||||
periodWindow,
|
||||
} from "@shared/timezone.ts";
|
||||
} from "@shared/timezone.ts";
|
||||
import { slugify, handle, famUsername } from "@shared/slugify.ts";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -124,10 +125,9 @@ async function getFamPaydayTime(famId: string): Promise<string> {
|
||||
async function getFamTimezone(famId: string): Promise<string> {
|
||||
try {
|
||||
const fam = await pb.getList("fams", `id = '${famId}'`);
|
||||
const tz = fam.items?.[0]?.timezone;
|
||||
return tz || "auto";
|
||||
return resolveTz(fam.items?.[0]?.timezone);
|
||||
} catch {
|
||||
return "auto";
|
||||
return resolveTz("auto");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,35 +139,42 @@ async function requireAdmin(c: any, next: any) {
|
||||
if (!famId || !userId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const admins = await pb.getList(
|
||||
"fam_admins",
|
||||
`famId = '${famId}' && userId = '${userId}'`,
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
|
||||
);
|
||||
if (!admins.items?.length) {
|
||||
if (!parents.items?.length) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
c.set("famId", famId);
|
||||
return next();
|
||||
}
|
||||
|
||||
async function requireDeviceToken(c: any, next: any) {
|
||||
const deviceToken = c.req.header("x-device-token");
|
||||
const famId = c.req.header("x-device-famid");
|
||||
if (!famId || !deviceToken) {
|
||||
return c.json(
|
||||
{ error: "x-device-token and x-device-famid headers required" },
|
||||
400,
|
||||
);
|
||||
// Member (child) auth: validates the user's pb_token JWT and identifies the
|
||||
// child. Children live in the `users` auth collection (role='child'); memberId
|
||||
// (the child-scoped foreign key) is now the users record id. auth-refresh both
|
||||
// cryptographically validates the token and returns the record in one call.
|
||||
async function requireMember(c: any, next: any) {
|
||||
const auth = c.req.header("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ")
|
||||
? auth.slice(7)
|
||||
: c.req.header("x-pb-token");
|
||||
if (!token) {
|
||||
return c.json({ error: "Member auth required" }, 401);
|
||||
}
|
||||
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
|
||||
const members = await pb.getList(
|
||||
"members",
|
||||
`famId = '${famId}' && deviceToken = '${hashHex}'`,
|
||||
);
|
||||
const member = members.items?.[0];
|
||||
if (!member) return c.json({ error: "Invalid device token" }, 401);
|
||||
c.set("famId", famId);
|
||||
c.set("memberId", member.id);
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/users/auth-refresh`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return c.json({ error: "Unauthorized" }, 401);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const record = body?.record;
|
||||
if (!record || record.role !== "child") {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
c.set("famId", record.famId);
|
||||
c.set("memberId", record.id);
|
||||
c.set("user", record);
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -179,24 +186,16 @@ app.post("/api/admin/signup", async (c) => {
|
||||
if (!email || !password || !famName) {
|
||||
return c.json({ error: "email, password, famName required" }, 400);
|
||||
}
|
||||
const slug = famName
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const slug = slugify(famName);
|
||||
const user = await pb.createUser(email, password);
|
||||
const fam = await pb.create("fams", {
|
||||
name: famName,
|
||||
slug,
|
||||
inviteCode,
|
||||
featureFlags: {},
|
||||
timezone: "auto",
|
||||
});
|
||||
const adminName = parentName || email.split("@")[0];
|
||||
const admin = await pb.create("fam_admins", {
|
||||
famId: fam.id,
|
||||
userId: user.id,
|
||||
email,
|
||||
await pb.update("users", user.id, {
|
||||
name: adminName,
|
||||
color: "#6366f1",
|
||||
});
|
||||
@@ -207,9 +206,9 @@ app.post("/api/admin/signup", async (c) => {
|
||||
famSlug: slug,
|
||||
userId: user.id,
|
||||
token: authResult.token,
|
||||
memberId: admin.id,
|
||||
memberName: admin.name,
|
||||
memberColor: admin.color,
|
||||
memberId: user.id,
|
||||
memberName: adminName,
|
||||
memberColor: "#6366f1",
|
||||
role: "parent",
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -224,28 +223,30 @@ app.post("/api/admin/login", async (c) => {
|
||||
return c.json({ error: "email, password required" }, 400);
|
||||
const authResult = await pb.authWithPassword(email, password);
|
||||
const userId = authResult.record.id;
|
||||
const admins = await pb.getList("fam_admins", `userId = '${userId}'`);
|
||||
const admin = admins.items?.[0];
|
||||
if (!admin) return c.json({ error: "No fam found for user" }, 404);
|
||||
const fams = await pb.getList("fams", `id = '${admin.famId}'`);
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId != '' && role = 'parent' && id = '${userId}'`,
|
||||
);
|
||||
const parent = parents.items?.[0];
|
||||
if (!parent) return c.json({ error: "No fam found for user" }, 404);
|
||||
const fams = await pb.getList("fams", `id = '${parent.famId}'`);
|
||||
const fam = fams.items?.[0];
|
||||
if (!fam) return c.json({ error: "Fam not found" }, 404);
|
||||
// Update fam_admins name/color on login
|
||||
const defaultName = email.split("@")[0];
|
||||
const adminPatch: Record<string, string> = {
|
||||
const parentPatch: Record<string, string> = {
|
||||
name: defaultName,
|
||||
color: "#6366f1",
|
||||
};
|
||||
if (!admin.email) adminPatch.email = email;
|
||||
const updatedAdmin = await pb.update("fam_admins", admin.id, adminPatch);
|
||||
if (!parent.email) parentPatch.email = email;
|
||||
await pb.update("users", parent.id, parentPatch);
|
||||
return c.json({
|
||||
famId: fam.id,
|
||||
famSlug: fam.slug,
|
||||
userId,
|
||||
token: authResult.token,
|
||||
memberId: updatedAdmin.id,
|
||||
memberName: updatedAdmin.name,
|
||||
memberColor: updatedAdmin.color,
|
||||
memberId: parent.id,
|
||||
memberName: defaultName,
|
||||
memberColor: "#6366f1",
|
||||
role: "parent",
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -253,124 +254,7 @@ app.post("/api/admin/login", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function joinMemberFlow(
|
||||
famId: string,
|
||||
name: string,
|
||||
deviceToken: string,
|
||||
) {
|
||||
const existing = await pb.getList(
|
||||
"members",
|
||||
`famId = '${famId}' && name = '${name}'`,
|
||||
);
|
||||
const slot = existing.items?.[0];
|
||||
if (!slot) throw new Error(`No member named "${name}" in this family`);
|
||||
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
|
||||
const tokenHint = deviceToken.substring(0, 8);
|
||||
await pb.update("members", slot.id, {
|
||||
deviceToken: hashHex,
|
||||
deviceTokenHint: tokenHint,
|
||||
});
|
||||
const newCode = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
await pb.update("fams", famId, { inviteCode: newCode });
|
||||
return {
|
||||
memberId: slot.id,
|
||||
deviceToken,
|
||||
name: slot.name,
|
||||
inviteCode: newCode,
|
||||
};
|
||||
}
|
||||
|
||||
app.post("/api/members/join", async (c) => {
|
||||
try {
|
||||
const { inviteCode, name, deviceToken } = await c.req.json();
|
||||
if (!inviteCode || !name || !deviceToken)
|
||||
return c.json({ error: "inviteCode, name, deviceToken required" }, 400);
|
||||
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
|
||||
const fam = fams.items?.[0];
|
||||
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
|
||||
const result = await joinMemberFlow(fam.id, name, deviceToken);
|
||||
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/members/direct-join", async (c) => {
|
||||
try {
|
||||
const { inviteCode, name } = await c.req.json();
|
||||
if (!inviteCode || !name)
|
||||
return c.json({ error: "inviteCode, name required" }, 400);
|
||||
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
|
||||
const fam = fams.items?.[0];
|
||||
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
|
||||
const deviceToken = crypto.randomUUID();
|
||||
const result = await joinMemberFlow(fam.id, name, deviceToken);
|
||||
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/members/verify", async (c) => {
|
||||
try {
|
||||
const { famId, deviceToken } = await c.req.json();
|
||||
if (!famId || !deviceToken)
|
||||
return c.json({ error: "famId, deviceToken required" }, 400);
|
||||
const hashHex = crypto
|
||||
.createHash("sha256")
|
||||
.update(deviceToken)
|
||||
.digest("hex");
|
||||
const members = await pb.getList(
|
||||
"members",
|
||||
`famId = '${famId}' && deviceToken = '${hashHex}'`,
|
||||
);
|
||||
const member = members.items?.[0];
|
||||
if (!member) return c.json({ error: "Invalid device token" }, 401);
|
||||
return c.json({
|
||||
famId: member.famId,
|
||||
memberId: member.id,
|
||||
name: member.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/members/verify-token", async (c) => {
|
||||
try {
|
||||
const { deviceToken, famSlug } = await c.req.json();
|
||||
if (!deviceToken || !famSlug)
|
||||
return c.json({ error: "deviceToken, famSlug required" }, 400);
|
||||
const hashHex = crypto
|
||||
.createHash("sha256")
|
||||
.update(deviceToken)
|
||||
.digest("hex");
|
||||
// Look the member up by its (unique) device token hash, then confirm the
|
||||
// requested slug belongs to that member's own fam. Looking up by slug first
|
||||
// is unsafe because slug isn't unique — duplicate fams (e.g. after dev↔prod
|
||||
// store drift) would resolve to the wrong family and reject valid tokens.
|
||||
const members = await pb.getList(
|
||||
"members",
|
||||
`deviceToken = '${hashHex}'`,
|
||||
);
|
||||
const member = members.items?.[0];
|
||||
if (!member) return c.json({ error: "Invalid device token" }, 401);
|
||||
const fams = await pb.getList("fams", `id = '${member.famId}'`);
|
||||
const fam = fams.items?.[0];
|
||||
if (!fam || fam.slug !== famSlug)
|
||||
return c.json({ error: "Invalid device token" }, 401);
|
||||
return c.json({
|
||||
famId: member.famId,
|
||||
memberId: member.id,
|
||||
name: member.name,
|
||||
color: member.color,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/members/me", requireDeviceToken, async (c) => {
|
||||
app.patch("/api/members/me", requireMember, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const memberId = c.get("memberId");
|
||||
@@ -379,7 +263,7 @@ app.patch("/api/members/me", requireDeviceToken, async (c) => {
|
||||
if (body.color) update.color = body.color;
|
||||
if (!Object.keys(update).length)
|
||||
return c.json({ error: "Nothing to update" }, 400);
|
||||
const record = await pb.update("members", memberId, update);
|
||||
const record = await pb.update("users", memberId, update);
|
||||
return c.json({ id: record.id, name: record.name, color: record.color });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
@@ -435,7 +319,10 @@ app.delete("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => {
|
||||
app.get("/api/admin/:famId/members", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const data = await pb.getList("members", `famId = '${famId}'`);
|
||||
const data = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'child'`,
|
||||
);
|
||||
return c.json(data.items);
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
@@ -446,7 +333,22 @@ app.post("/api/admin/:famId/members", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
const record = await pb.create("members", { famId, ...body });
|
||||
const fam = (await pb.getList("fams", `id = '${famId}'`)).items?.[0];
|
||||
if (!fam) return c.json({ error: "Fam not found" }, 404);
|
||||
const handleName = handle(body.name || body.username || "");
|
||||
if (!handleName) return c.json({ error: "username required" }, 400);
|
||||
const username = famUsername(fam.slug, handleName);
|
||||
const password = `${MEMBER_SECRET}${fam.slug}${handleName}`;
|
||||
const record = await pb.create("users", {
|
||||
famId,
|
||||
role: "child",
|
||||
username,
|
||||
name: body.name || handleName,
|
||||
color: body.color || "#6366f1",
|
||||
emailVisibility: false,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
});
|
||||
return c.json(record);
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
@@ -457,7 +359,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId, id } = c.req.param();
|
||||
const body = await c.req.json();
|
||||
const record = await pb.update("members", id, body);
|
||||
const record = await pb.update("users", id, body);
|
||||
return c.json(record);
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
@@ -467,7 +369,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
|
||||
app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.param();
|
||||
await pb.delete("members", id);
|
||||
await pb.delete("users", id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
@@ -592,7 +494,6 @@ app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
||||
return c.json({
|
||||
name: fam.name,
|
||||
slug: fam.slug,
|
||||
inviteCode: fam.inviteCode,
|
||||
payday: fam.payday,
|
||||
paydayTime: fam.paydayTime || "18:00",
|
||||
timezone: fam.timezone || "auto",
|
||||
@@ -609,10 +510,7 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
||||
if (body.name !== undefined) {
|
||||
const name = body.name;
|
||||
if (!name) return c.json({ error: "name required" }, 400);
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const slug = slugify(name);
|
||||
const record = await pb.update("fams", famId, { name, slug });
|
||||
return c.json({
|
||||
name: record.name,
|
||||
@@ -667,20 +565,6 @@ app.get("/api/admin/:famId/verify", requireAdmin, async (c) => {
|
||||
return c.json({ verified: true });
|
||||
});
|
||||
|
||||
app.post("/api/admin/:famId/regen-invite", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const fams = await pb.getList("fams", `id = '${famId}'`);
|
||||
const fam = fams.items?.[0];
|
||||
if (!fam) return c.json({ error: "Fam not found" }, 404);
|
||||
await pb.update("fams", famId, { inviteCode });
|
||||
return c.json({ inviteCode });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin: Weekly Summary ──────────────────────────────
|
||||
|
||||
app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
||||
@@ -690,7 +574,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
||||
const tz = await getFamTimezone(famId);
|
||||
const ws = weekStart(payday, tz);
|
||||
const [members, assigned, completions] = await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
|
||||
]);
|
||||
@@ -854,7 +738,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
||||
|
||||
const [members, assigned, completions, configs, rewards] =
|
||||
await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
|
||||
pb
|
||||
@@ -1183,7 +1067,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
|
||||
);
|
||||
} catch {}
|
||||
const [members, assigned, completions] = await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||
pb.getList("completions", `famId = '${famId}'`),
|
||||
]);
|
||||
@@ -1310,7 +1194,7 @@ async function evaluateFam(famId: string): Promise<void> {
|
||||
if (!configs.length) return;
|
||||
|
||||
const [allMembers, allAssigned, allCompletions] = await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||
pb.getList("completions", `famId = '${famId}'`),
|
||||
]);
|
||||
@@ -1581,7 +1465,10 @@ app.post(
|
||||
`famId = '${famId}' && bonusConfigId = '${cfg.id}'`,
|
||||
);
|
||||
|
||||
const members = await pb.getList("members", `famId = '${famId}'`);
|
||||
const members = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'child'`,
|
||||
);
|
||||
|
||||
const targetMembers: any[] = [];
|
||||
if (cfg.target === "competitive" || cfg.target === "collaborative") {
|
||||
@@ -1672,7 +1559,7 @@ app.post(
|
||||
|
||||
// ── Member: Completion Toggle ────────────────────────────
|
||||
|
||||
app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
|
||||
app.post("/api/completions/toggle", requireMember, async (c) => {
|
||||
try {
|
||||
const famId = c.get("famId");
|
||||
const memberId = c.get("memberId");
|
||||
@@ -1737,19 +1624,11 @@ app.post(
|
||||
|
||||
// ── Member: Get seasons ─────────────────────────────────
|
||||
|
||||
app.get("/api/members/seasons", async (c) => {
|
||||
app.get("/api/members/seasons", requireMember, async (c) => {
|
||||
try {
|
||||
const deviceToken = c.req.header("x-device-token");
|
||||
if (!deviceToken) return c.json({ error: "x-device-token required" }, 400);
|
||||
const hashHex = crypto
|
||||
.createHash("sha256")
|
||||
.update(deviceToken)
|
||||
.digest("hex");
|
||||
const members = await pb.getList("members", `deviceToken = '${hashHex}'`);
|
||||
const member = members.items?.[0];
|
||||
if (!member) return c.json({ error: "Invalid device token" }, 401);
|
||||
const seasons = await pb.getList("seasons", `famId = '${member.famId}'`);
|
||||
return c.json({ seasons: seasons.items, famId: member.famId });
|
||||
const famId = c.get("famId");
|
||||
const seasons = await pb.getList("seasons", `famId = '${famId}'`);
|
||||
return c.json({ seasons: seasons.items, famId });
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
@@ -1757,7 +1636,7 @@ app.get("/api/members/seasons", async (c) => {
|
||||
|
||||
// ── Member: Get chores (for kanban) ──────────────────────
|
||||
|
||||
app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
|
||||
app.post("/api/members/my-chores", requireMember, async (c) => {
|
||||
try {
|
||||
const famId = c.get("famId");
|
||||
const memberId = c.get("memberId");
|
||||
@@ -1926,56 +1805,56 @@ app.post(
|
||||
);
|
||||
|
||||
app.get("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const userId = c.req.header("x-session-userid");
|
||||
const admins = await pb.getList(
|
||||
"fam_admins",
|
||||
`famId = '${famId}' && userId = '${userId}'`,
|
||||
);
|
||||
const admin = admins.items?.[0];
|
||||
if (!admin) return c.json({ error: "Admin not found" }, 404);
|
||||
return c.json({
|
||||
id: admin.id,
|
||||
name: admin.name,
|
||||
color: admin.color,
|
||||
email: admin.email || "",
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const userId = c.req.header("x-session-userid");
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
|
||||
);
|
||||
const parent = parents.items?.[0];
|
||||
if (!parent) return c.json({ error: "Admin not found" }, 404);
|
||||
return c.json({
|
||||
id: parent.id,
|
||||
name: parent.name || "",
|
||||
color: parent.color || "#6366f1",
|
||||
email: parent.email || "",
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const userId = c.req.header("x-session-userid");
|
||||
const body = await c.req.json();
|
||||
const admins = await pb.getList(
|
||||
"fam_admins",
|
||||
`famId = '${famId}' && userId = '${userId}'`,
|
||||
);
|
||||
const admin = admins.items?.[0];
|
||||
if (!admin) return c.json({ error: "Admin not found" }, 404);
|
||||
const update: Record<string, string> = {};
|
||||
if (body.name) update.name = body.name;
|
||||
if (body.color) update.color = body.color;
|
||||
if (body.email !== undefined) update.email = body.email;
|
||||
const record = await pb.update("fam_admins", admin.id, update);
|
||||
return c.json({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
color: record.color,
|
||||
email: record.email || "",
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
|
||||
try {
|
||||
const { famId } = c.req.param();
|
||||
const userId = c.req.header("x-session-userid");
|
||||
const body = await c.req.json();
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
|
||||
);
|
||||
const parent = parents.items?.[0];
|
||||
if (!parent) return c.json({ error: "Admin not found" }, 404);
|
||||
const update: Record<string, string> = {};
|
||||
if (body.name) update.name = body.name;
|
||||
if (body.color) update.color = body.color;
|
||||
if (body.email !== undefined) update.email = body.email;
|
||||
const record = await pb.update("users", parent.id, update);
|
||||
return c.json({
|
||||
id: record.id,
|
||||
name: record.name || "",
|
||||
color: record.color || "#6366f1",
|
||||
email: record.email || "",
|
||||
});
|
||||
} catch (err) {
|
||||
return handleError(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Member: Claim a reward ─────────────────────────────
|
||||
|
||||
app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
||||
app.post("/api/members/rewards/:id/claim", requireMember, async (c) => {
|
||||
try {
|
||||
const { id } = c.req.param();
|
||||
const famId = c.get("famId");
|
||||
@@ -2008,7 +1887,7 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
||||
|
||||
// ── Member: Request all unclaimed rewards ────────────────
|
||||
|
||||
app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
|
||||
app.post("/api/members/rewards/request-all", requireMember, async (c) => {
|
||||
try {
|
||||
const famId = c.get("famId");
|
||||
const memberId = c.get("memberId");
|
||||
@@ -2064,7 +1943,10 @@ async function releaseWeek(famId: string) {
|
||||
|
||||
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
||||
|
||||
const members = await pb.getList("members", `famId = '${famId}'`);
|
||||
const members = await pb.getList(
|
||||
"users",
|
||||
`famId = '${famId}' && role = 'child'`,
|
||||
);
|
||||
let cashRewards: any = { items: [] };
|
||||
try {
|
||||
cashRewards = await pb.getList(
|
||||
@@ -2118,21 +2000,26 @@ async function authorizeFamReq(c: any): Promise<string | null> {
|
||||
const sessFam = c.req.header("x-session-famid");
|
||||
const sessUser = c.req.header("x-session-userid");
|
||||
if (sessFam && sessUser) {
|
||||
const admins = await pb.getList(
|
||||
"fam_admins",
|
||||
`famId = '${sessFam}' && userId = '${sessUser}'`,
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId = '${sessFam}' && role = 'parent' && id = '${sessUser}'`,
|
||||
);
|
||||
if (admins.items?.length) return sessFam;
|
||||
if (parents.items?.length) return sessFam;
|
||||
}
|
||||
const devToken = c.req.header("x-device-token");
|
||||
const devFam = c.req.header("x-device-famid");
|
||||
if (devFam && devToken) {
|
||||
const hashHex = crypto.createHash("sha256").update(devToken).digest("hex");
|
||||
const members = await pb.getList(
|
||||
"members",
|
||||
`famId = '${devFam}' && deviceToken = '${hashHex}'`,
|
||||
const auth = c.req.header("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ")
|
||||
? auth.slice(7)
|
||||
: c.req.header("x-pb-token");
|
||||
if (token) {
|
||||
const res = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/users/auth-refresh`,
|
||||
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (members.items?.length) return devFam;
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const record = body?.record;
|
||||
if (record?.famId) return record.famId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2162,7 +2049,7 @@ app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
|
||||
|
||||
// Fetch data for summary
|
||||
const [members, assigned, completions] = await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("assigned_chores", `famId = '${famId}'`),
|
||||
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
|
||||
]);
|
||||
@@ -2252,7 +2139,7 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
|
||||
const days = body.days || 7;
|
||||
|
||||
const [members, templates] = await Promise.all([
|
||||
pb.getList("members", `famId = '${famId}'`),
|
||||
pb.getList("users", `famId = '${famId}' && role = 'child'`),
|
||||
pb.getList("chore_templates", `famId = '${famId}'`),
|
||||
]);
|
||||
|
||||
@@ -2329,42 +2216,46 @@ async function resolveChatActor(c: any) {
|
||||
const hsFamId = c.req.header("x-session-famid");
|
||||
const hsUserId = c.req.header("x-session-userid");
|
||||
if (hsFamId && hsUserId) {
|
||||
const admins = await pb.getList(
|
||||
"fam_admins",
|
||||
`famId = '${hsFamId}' && userId = '${hsUserId}'`,
|
||||
const parents = await pb.getList(
|
||||
"users",
|
||||
`famId = '${hsFamId}' && role = 'parent' && id = '${hsUserId}'`,
|
||||
);
|
||||
const admin = admins.items?.[0];
|
||||
if (admin) {
|
||||
const parent = parents.items?.[0];
|
||||
if (parent) {
|
||||
return {
|
||||
famId: hsFamId,
|
||||
actor: {
|
||||
id: admin.id,
|
||||
id: parent.id,
|
||||
type: "admin",
|
||||
name: admin.name,
|
||||
color: admin.color,
|
||||
name: parent.name || "",
|
||||
color: parent.color || "#6366f1",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const famId = c.req.header("x-device-famid");
|
||||
const deviceToken = c.req.header("x-device-token");
|
||||
if (famId && deviceToken) {
|
||||
const hash = crypto.createHash("sha256").update(deviceToken).digest("hex");
|
||||
const members = await pb.getList(
|
||||
"members",
|
||||
`famId = '${famId}' && deviceToken = '${hash}'`,
|
||||
const auth = c.req.header("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ")
|
||||
? auth.slice(7)
|
||||
: c.req.header("x-pb-token");
|
||||
if (token) {
|
||||
const res = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/users/auth-refresh`,
|
||||
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const member = members.items?.[0];
|
||||
if (member) {
|
||||
return {
|
||||
famId,
|
||||
actor: {
|
||||
id: member.id,
|
||||
type: "member",
|
||||
name: member.name,
|
||||
color: member.color,
|
||||
},
|
||||
};
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
const user = body?.record;
|
||||
if (user?.famId) {
|
||||
return {
|
||||
famId: user.famId,
|
||||
actor: {
|
||||
id: user.id,
|
||||
type: user.role === "child" ? "member" : "admin",
|
||||
name: user.name || user.username || "",
|
||||
color: user.color || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -75,6 +75,10 @@ async function ensureSchema(): Promise<void> {
|
||||
console.log("[migrate] Bootstrapping base schema on fresh PocketBase...");
|
||||
|
||||
const ids: Record<string, string> = {};
|
||||
// `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) {
|
||||
const createdId = await createCollection(entry.build(ids));
|
||||
if (createdId) ids[entry.name] = createdId;
|
||||
@@ -1520,5 +1524,568 @@ export async function migrate(): Promise<void> {
|
||||
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. Once SvelteKit
|
||||
// writes as the authenticated user, 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).
|
||||
{
|
||||
const WRITE_RULE = "@request.body.famId = @request.auth.famId";
|
||||
const SCOPED_RULE = "famId = @request.auth.famId";
|
||||
const WRITE_SCOPED_COLLECTIONS = [
|
||||
"members",
|
||||
"chore_templates",
|
||||
"assigned_chores",
|
||||
"completions",
|
||||
"bonus_configs",
|
||||
"bonus_templates",
|
||||
"rewards",
|
||||
"seasons",
|
||||
"settings",
|
||||
"weekly_history",
|
||||
"monthly_bonuses",
|
||||
"messages",
|
||||
"chat_typing",
|
||||
];
|
||||
for (const name of WRITE_SCOPED_COLLECTIONS) {
|
||||
const c = await getCollection(name);
|
||||
if (!c) continue;
|
||||
if (
|
||||
c.createRule === WRITE_RULE &&
|
||||
c.updateRule === SCOPED_RULE &&
|
||||
c.deleteRule === SCOPED_RULE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await updateCollection(c.id, {
|
||||
name,
|
||||
type: c.type,
|
||||
listRule: c.listRule,
|
||||
viewRule: c.viewRule,
|
||||
createRule: WRITE_RULE,
|
||||
updateRule: SCOPED_RULE,
|
||||
deleteRule: SCOPED_RULE,
|
||||
fields: c.fields,
|
||||
});
|
||||
console.log(` ↳ Locked ${name} write rules to famId scoping`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 28b. Allow each admin to 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 + create/delete stay superuser-only.
|
||||
{
|
||||
const c = await getCollection("fams");
|
||||
if (c && c.updateRule !== "id = @request.auth.famId") {
|
||||
await updateCollection(c.id, {
|
||||
name: "fams",
|
||||
type: c.type,
|
||||
listRule: c.listRule,
|
||||
viewRule: c.viewRule,
|
||||
createRule: c.createRule,
|
||||
updateRule: "id = @request.auth.famId",
|
||||
deleteRule: c.deleteRule,
|
||||
fields: c.fields,
|
||||
});
|
||||
console.log(" ↳ fams.updateRule 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. user_configs: identity + OTP store (superuser-only) ──
|
||||
// Holds the rotating one-time code, colour, and the OTP-issue timestamp used
|
||||
// for the 20-minute 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 (colour edits must not bump it). userId links to the users auth
|
||||
// record so each child's config is uniquely addressable.
|
||||
{
|
||||
if (!(await getCollection("user_configs"))) {
|
||||
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 user_configs collection...");
|
||||
await createCollection({
|
||||
name: "user_configs",
|
||||
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: "colour", type: "text", required: false },
|
||||
{ name: "updatedAt", type: "text", required: false },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.log(" ↳ user_configs 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user