update ui and other frontend updates
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/dist
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
||||
Vendored
-35
@@ -1,35 +0,0 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8090';
|
||||
const PB_EMAIL = process.env.PB_SUPERUSER_EMAIL || '';
|
||||
const PB_PASSWORD = process.env.PB_SUPERUSER_PASSWORD || '';
|
||||
const DEBUG_RECORD_ID = process.env.DEBUG_RECORD_ID || '';
|
||||
const app = new Hono();
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }));
|
||||
app.post('/api/debug/increment-hono', async (c) => {
|
||||
const auth = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
|
||||
});
|
||||
const { token } = await auth.json();
|
||||
const record = await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const data = await record.json();
|
||||
const current = data.hono_count || 0;
|
||||
await fetch(`${PB_URL}/api/collections/debug/records/${DEBUG_RECORD_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ hono_count: current + 1 })
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
const port = parseInt(process.env.PROXY_PORT || '3456', 10);
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`Hono proxy listening on http://localhost:${info.port}`);
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,uBAAuB,CAAC;AAC7D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;AACtD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC;AAC5D,MAAM,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;AAE1D,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;AAEvB,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AAExD,GAAG,CAAC,IAAI,CAAC,2BAA2B,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACjD,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,iDAAiD,EAAE;QACpF,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;KACnE,CAAC,CAAC;IACH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;IAEpC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACxF,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE;KAC7C,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAErC,MAAM,KAAK,CAAC,GAAG,MAAM,kCAAkC,eAAe,EAAE,EAAE;QACzE,MAAM,EAAE,OAAO;QACf,OAAO,EAAE;YACR,cAAc,EAAE,kBAAkB;YAClC,aAAa,EAAE,UAAU,KAAK,EAAE;SAChC;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,EAAE,CAAC;KACjD,CAAC,CAAC;IAEH,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;AAC7B,CAAC,CAAC,CAAC;AAEH,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AAE5D,KAAK,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,EAAE;IAC1C,OAAO,CAAC,GAAG,CAAC,4CAA4C,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AACtE,CAAC,CAAC,CAAC"}
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
||||
|
||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
||||
|
||||
interface FieldDef {
|
||||
name: string
|
||||
type: string
|
||||
required?: boolean
|
||||
unique?: boolean
|
||||
max?: number
|
||||
min?: number
|
||||
values?: string[]
|
||||
maxSelect?: number
|
||||
collectionId?: string
|
||||
cascadeDelete?: boolean
|
||||
}
|
||||
|
||||
interface CollectionDef {
|
||||
name: string
|
||||
type: string
|
||||
fields: FieldDef[]
|
||||
listRule?: string | null
|
||||
viewRule?: string | null
|
||||
createRule?: string | null
|
||||
updateRule?: string | null
|
||||
deleteRule?: string | null
|
||||
}
|
||||
|
||||
async function getSuperadminToken(): Promise<string> {
|
||||
const res = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`Auth failed: ${JSON.stringify(data)}`);
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function createCollection(
|
||||
token: string,
|
||||
col: CollectionDef,
|
||||
): Promise<string | null> {
|
||||
const existing = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections?filter=name='${col.name}'`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const existingData = await existing.json();
|
||||
if (existingData?.items?.length > 0) {
|
||||
console.log(` ↳ Already exists: ${col.name}`);
|
||||
return existingData.items[0].id;
|
||||
}
|
||||
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(col),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
|
||||
console.log(` ✓ Created: ${col.name}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
function text(name: string, required = false): FieldDef {
|
||||
return { name, type: "text", required };
|
||||
}
|
||||
|
||||
function uniqueText(name: string): FieldDef {
|
||||
return { name, type: "text", required: true, unique: true };
|
||||
}
|
||||
|
||||
function number(name: string, required = false): FieldDef {
|
||||
return { name, type: "number", required };
|
||||
}
|
||||
|
||||
function bool(name: string): FieldDef {
|
||||
return { name, type: "bool" };
|
||||
}
|
||||
|
||||
function date(name: string): FieldDef {
|
||||
return { name, type: "date" };
|
||||
}
|
||||
|
||||
function jsonField(name: string): FieldDef {
|
||||
return { name, type: "json" };
|
||||
}
|
||||
|
||||
function select(name: string, values: string[], required = false): FieldDef {
|
||||
return { name, type: "select", required, values, maxSelect: 1 };
|
||||
}
|
||||
|
||||
function rel(name: string, collectionId: string, required = false): FieldDef {
|
||||
return {
|
||||
name,
|
||||
type: "relation",
|
||||
required,
|
||||
collectionId,
|
||||
maxSelect: 1,
|
||||
cascadeDelete: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Connecting to PB at", PB_ENDPOINT);
|
||||
const token = await getSuperadminToken();
|
||||
console.log("Authenticated as superadmin\n");
|
||||
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
// ── Pass 1: Independent collections ──
|
||||
console.log("--- Pass 1: Independent collections ---");
|
||||
|
||||
ids.fams = await createCollection(token, {
|
||||
name: "fams",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
text("name", true),
|
||||
uniqueText("slug"),
|
||||
text("inviteCode"),
|
||||
text("stripeCustomerId"),
|
||||
jsonField("featureFlags"),
|
||||
],
|
||||
});
|
||||
|
||||
// ── Pass 2: Collections that reference fams ──
|
||||
console.log("\n--- Pass 2: fam-scoped collections ---");
|
||||
|
||||
ids.settings = await createCollection(token, {
|
||||
name: "settings",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
text("webhookUrl"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.members = await createCollection(token, {
|
||||
name: "members",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
text("name", true),
|
||||
text("color"),
|
||||
text("deviceToken"),
|
||||
text("deviceTokenHint"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.chore_templates = await createCollection(token, {
|
||||
name: "chore_templates",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
text("name", true),
|
||||
text("description"),
|
||||
select("defaultFrequency", ["daily", "weekly"], true),
|
||||
select("defaultType", ["points", "money"], true),
|
||||
number("defaultValue", true),
|
||||
],
|
||||
});
|
||||
|
||||
ids.fam_admins = await createCollection(token, {
|
||||
name: "fam_admins",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
text("userId", true),
|
||||
text("email", true),
|
||||
text("name"),
|
||||
text("color"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.bonus_configs = await createCollection(token, {
|
||||
name: "bonus_configs",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
text("name", true),
|
||||
text("description"),
|
||||
select("target", ["individual", "competitive"], true),
|
||||
select("type", ["threshold", "count", "manual"], true),
|
||||
select("occurrence", ["recurring", "once"], true),
|
||||
select("rewardType", ["points", "cash", "prize"], true),
|
||||
text("rewardValue", true),
|
||||
number("criteriaValue"),
|
||||
select("period", ["weekly", "monthly"]),
|
||||
select("status", ["active", "archived"], true),
|
||||
],
|
||||
});
|
||||
|
||||
// ── Pass 3: Collections with member/template deps ──
|
||||
console.log("\n--- Pass 3: Nested collections ---");
|
||||
|
||||
ids.weekly_history = await createCollection(token, {
|
||||
name: "weekly_history",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
rel("memberId", ids.members!, true),
|
||||
date("weekStart"),
|
||||
number("pointsEarned"),
|
||||
number("moneyEarned"),
|
||||
number("choresCompleted"),
|
||||
number("bonusEarned"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.rewards = await createCollection(token, {
|
||||
name: "rewards",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
rel("memberId", ids.members!, true),
|
||||
rel("bonusConfigId", ids.bonus_configs!),
|
||||
text("label", true),
|
||||
number("value", true),
|
||||
select("rewardType", ["cash", "prize", "points"], true),
|
||||
bool("claimed"),
|
||||
date("claimedAt"),
|
||||
text("date"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.assigned_chores = await createCollection(token, {
|
||||
name: "assigned_chores",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
rel("memberId", ids.members!, true),
|
||||
rel("templateId", ids.chore_templates!, true),
|
||||
select("frequency", ["daily", "weekly"], true),
|
||||
select("type", ["points", "money"], true),
|
||||
number("value", true),
|
||||
text("customName"),
|
||||
],
|
||||
});
|
||||
|
||||
ids.completions = await createCollection(token, {
|
||||
name: "completions",
|
||||
type: "base",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
rel("famId", ids.fams!, true),
|
||||
rel("memberId", ids.members!, true),
|
||||
rel("assignedChoreId", ids.assigned_chores!, true),
|
||||
date("date"),
|
||||
],
|
||||
});
|
||||
|
||||
console.log("\n✅ All collections created successfully");
|
||||
console.log("Collection IDs:", ids);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const HONO = "http://192.168.1.225:3456";
|
||||
|
||||
async function main() {
|
||||
// Signup
|
||||
const signup = await fetch(`${HONO}/api/admin/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "admin2@test.com", password: "password1234", famName: "Admin Test" }),
|
||||
});
|
||||
const s = await signup.json();
|
||||
console.log("Signup:", s.famId, s.famSlug);
|
||||
const famId = s.famId;
|
||||
|
||||
// Test admin authenticated calls
|
||||
const headers = {
|
||||
"x-session-famid": famId,
|
||||
"x-session-role": "admin",
|
||||
"x-session-userid": s.userId,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Create a chore template
|
||||
console.log("\nCreate template...");
|
||||
const tmpl = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, {
|
||||
method: "POST", headers, body: JSON.stringify({
|
||||
name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 10,
|
||||
}),
|
||||
});
|
||||
const t = await tmpl.json();
|
||||
console.log("Template:", t.id, t.name);
|
||||
|
||||
// List templates
|
||||
console.log("\nList templates...");
|
||||
const list = await fetch(`${HONO}/api/admin/${famId}/chore-templates`, { headers });
|
||||
console.log("Templates:", (await list.json()).length);
|
||||
|
||||
// Create a member
|
||||
console.log("\nCreate member...");
|
||||
const mem = await fetch(`${HONO}/api/admin/${famId}/members`, {
|
||||
method: "POST", headers, body: JSON.stringify({ name: "Kid", color: "#6366f1" }),
|
||||
});
|
||||
const m = await mem.json();
|
||||
console.log("Member:", m.id, m.name);
|
||||
|
||||
// Assign chore
|
||||
console.log("\nAssign chore...");
|
||||
const assign = await fetch(`${HONO}/api/admin/${famId}/assigned-chores`, {
|
||||
method: "POST", headers, body: JSON.stringify({
|
||||
memberId: m.id, templateId: t.id, frequency: "daily", type: "points", value: 10,
|
||||
}),
|
||||
});
|
||||
const a = await assign.json();
|
||||
console.log("Assigned:", a.id);
|
||||
|
||||
// Test device token auth
|
||||
console.log("\nTest completion toggle with device token...");
|
||||
const devHeaders = {
|
||||
"x-device-token": "dev-token-test",
|
||||
"x-device-famid": famId,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// First need to join with this device token
|
||||
const join = await fetch(`${HONO}/api/members/join`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inviteCode: s.famSlug, ...(await (await fetch(`${HONO}/api/admin/${famId}/members`, { headers })).json()).length > 1 ? {} : { name: "Test", deviceToken: "dev-token-test" } }),
|
||||
});
|
||||
// Actually, just use the member we already created but we can't use device token with it since it has no token
|
||||
// Let's join a new one
|
||||
console.log("Joining with device token...");
|
||||
const j = await fetch(`${HONO}/api/members/join`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inviteCode: "TEST", name: "Test Kid", deviceToken: "dev-token-test" }),
|
||||
});
|
||||
const joinData = await j.json();
|
||||
console.log("Join result:", JSON.stringify(joinData));
|
||||
|
||||
if (joinData.famId) {
|
||||
// Toggle completion
|
||||
console.log("\nToggle completion...");
|
||||
const toggle = await fetch(`${HONO}/api/completions/toggle`, {
|
||||
method: "POST",
|
||||
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
|
||||
});
|
||||
console.log("Toggle:", await toggle.json());
|
||||
|
||||
// Toggle again (should undo)
|
||||
const toggle2 = await fetch(`${HONO}/api/completions/toggle`, {
|
||||
method: "POST",
|
||||
headers: { "x-device-token": "dev-token-test", "x-device-famid": famId, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assignedChoreId: a.id, date: "2026-06-24" }),
|
||||
});
|
||||
console.log("Toggle undo:", await toggle2.json());
|
||||
}
|
||||
|
||||
console.log("\n✅ All admin tests passed");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,63 @@
|
||||
const HONO = "http://192.168.1.225:3456";
|
||||
|
||||
async function main() {
|
||||
// 1. Signup
|
||||
console.log("=== Signup ===");
|
||||
const signup = await fetch(`${HONO}/api/admin/signup`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@fam.com", password: "password123", famName: "Test Fam" }),
|
||||
});
|
||||
const signupData = await signup.json();
|
||||
console.log("Signup:", JSON.stringify(signupData, null, 2));
|
||||
|
||||
// 2. Login
|
||||
console.log("\n=== Login ===");
|
||||
const login = await fetch(`${HONO}/api/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@fam.com", password: "password123" }),
|
||||
});
|
||||
const loginData = await login.json();
|
||||
console.log("Login:", JSON.stringify(loginData, null, 2));
|
||||
|
||||
// 3. Get invite code from fams directly via PB
|
||||
const pbTokenRes = await fetch("http://192.168.1.225:8090/api/collections/_superusers/auth-with-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: "debug@famchamp.dev", password: "debug123" }),
|
||||
});
|
||||
const pbTokenData = await pbTokenRes.json();
|
||||
const pbSuperToken = pbTokenData.token;
|
||||
|
||||
const famsRes = await fetch("http://192.168.1.225:8090/api/collections/fams/records?sort=-created", {
|
||||
headers: { Authorization: `Bearer ${pbSuperToken}` },
|
||||
});
|
||||
const famsData = await famsRes.json();
|
||||
const fam = famsData.items[0];
|
||||
console.log("\n=== Fam ===");
|
||||
console.log("Fam:", JSON.stringify(fam, null, 2));
|
||||
console.log("Invite code:", fam.inviteCode);
|
||||
|
||||
// 4. Join as member
|
||||
console.log("\n=== Join ===");
|
||||
const join = await fetch(`${HONO}/api/members/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inviteCode: fam.inviteCode, name: "Kid", deviceToken: "dev-token-xyz" }),
|
||||
});
|
||||
const joinData = await join.json();
|
||||
console.log("Join:", JSON.stringify(joinData, null, 2));
|
||||
|
||||
// 5. Verify member
|
||||
console.log("\n=== Verify ===");
|
||||
const verify = await fetch(`${HONO}/api/members/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ famId: fam.id, deviceToken: "dev-token-xyz" }),
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
console.log("Verify:", JSON.stringify(verifyData, null, 2));
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+1071
-41
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
||||
|
||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
async function auth(): Promise<string> {
|
||||
if (token) return token;
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
|
||||
token = data.token;
|
||||
return token!;
|
||||
}
|
||||
|
||||
async function getCollection(name: string): Promise<any | null> {
|
||||
const t = await auth();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
return data?.items?.[0] || null;
|
||||
}
|
||||
|
||||
async function createCollection(col: any): Promise<void> {
|
||||
const t = await auth();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
|
||||
method: "POST",
|
||||
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)}`);
|
||||
console.log(` ✓ Created collection: ${col.name}`);
|
||||
}
|
||||
|
||||
async function updateCollection(id: string, col: any): Promise<void> {
|
||||
const t = await auth();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
|
||||
method: "PATCH",
|
||||
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)}`);
|
||||
console.log(` ✓ Updated collection: ${col.name || id}`);
|
||||
}
|
||||
|
||||
export async function migrate(): Promise<void> {
|
||||
console.log("[migrate] Checking PB collection schemas...");
|
||||
|
||||
// ── 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", "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("schedule");
|
||||
|
||||
if (needTargetUpdate || needPeriodUpdate) {
|
||||
console.log("[migrate] Updating bonus_configs fields...");
|
||||
if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"];
|
||||
if (needPeriodUpdate) periodField.values = ["schedule", "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<string, any> = {};
|
||||
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)`);
|
||||
}
|
||||
|
||||
// ── 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`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Create notifications collection if missing ──
|
||||
const notifCol = await getCollection("notifications");
|
||||
if (!notifCol) {
|
||||
const famsCol = await getCollection("fams");
|
||||
if (!famsCol) throw new Error("fams collection not found");
|
||||
const membersCol = await getCollection("members");
|
||||
if (!membersCol) throw new Error("members collection not found");
|
||||
console.log("[migrate] Creating notifications collection...");
|
||||
await createCollection({
|
||||
name: "notifications",
|
||||
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: "memberId", type: "relation", required: true, collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false },
|
||||
{ name: "message", type: "text", required: true },
|
||||
{ name: "read", type: "bool", required: false },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
console.log(` ↳ notifications 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`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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. Add phase field to bonus_configs ──
|
||||
if (bonusConfigsCol) {
|
||||
const hasPhase = bonusConfigsCol.fields.some((f: any) => f.name === "phase");
|
||||
if (!hasPhase) {
|
||||
console.log("[migrate] Adding phase field to bonus_configs...");
|
||||
bonusConfigsCol.fields.push({
|
||||
name: "phase", type: "select", required: true,
|
||||
values: ["template", "ready", "active", "completed"], maxSelect: 1,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
// Backfill existing records → phase = "active"
|
||||
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();
|
||||
if (data?.items?.length) {
|
||||
for (const rec of data.items) {
|
||||
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
||||
body: JSON.stringify({ phase: "active" }),
|
||||
});
|
||||
}
|
||||
console.log(` ✓ Backfilled ${data.items.length} bonus_configs → phase=active`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ↳ bonus_configs.phase already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<string, any[]>();
|
||||
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<string, string> = {};
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[migrate] Done");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
|
||||
|
||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
||||
|
||||
let adminToken: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
if (adminToken && Date.now() < tokenExpiry) return adminToken;
|
||||
const res = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
|
||||
adminToken = data.token;
|
||||
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
|
||||
return adminToken!;
|
||||
}
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<Response> {
|
||||
const token = await ensureToken();
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (body) headers["Content-Type"] = "application/json";
|
||||
return fetch(`${PB_ENDPOINT}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const pb = {
|
||||
async create(collection: string, data: Record<string, unknown>) {
|
||||
const res = await request("POST", `/api/collections/${collection}/records`, data);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(`PB create ${collection}: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
},
|
||||
|
||||
async update(collection: string, id: string, data: Record<string, unknown>) {
|
||||
const res = await request("PATCH", `/api/collections/${collection}/records/${id}`, data);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(`PB update ${collection}: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
},
|
||||
|
||||
async delete(collection: string, id: string) {
|
||||
const res = await request("DELETE", `/api/collections/${collection}/records/${id}`);
|
||||
if (!res.ok) throw new Error(`PB delete ${collection}: ${res.status}`);
|
||||
},
|
||||
|
||||
async getList(collection: string, filter = "") {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) params.set("filter", filter);
|
||||
params.set("perPage", "200");
|
||||
const res = await request("GET", `/api/collections/${collection}/records?${params}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
},
|
||||
|
||||
async authWithPassword(identity: string, password: string) {
|
||||
const res = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/users/auth-with-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity, password }),
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(`PB auth: ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
},
|
||||
|
||||
async createUser(email: string, password: string) {
|
||||
return pb.create("users", {
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user