make major bonus updates

This commit is contained in:
JCEEE
2026-07-31 16:13:05 +01:00
parent 8834281b4d
commit d720f4e863
12 changed files with 4174 additions and 2598 deletions
+34 -5
View File
@@ -19,7 +19,7 @@
## Auth ## Auth
| Role | Auth | Session | Record in | | Role | Auth | Session | Record in |
| ---------------- | -------------------------- | --------------------------- | -------------------- | | -------------- | -------------------------- | -------------------------- | ------------ |
| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | | Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` |
| Member (child) | Invite code + device token | `device_token` cookie only | `members` | | Member (child) | Invite code + device token | `device_token` cookie only | `members` |
@@ -61,11 +61,13 @@
## Data Flow ## Data Flow
### Reads (both roles) ### Reads (both roles)
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie). - **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie).
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections. - **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections.
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed. - **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
### Writes (both roles go through Hono proxy) ### Writes (both roles go through Hono proxy)
- **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT) - **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT)
- **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`) - **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`)
- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`) - **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`)
@@ -75,6 +77,7 @@
- **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface
### UI reactivity ### UI reactivity
- Svelte `$state` / `$derived` / `$effect` - Svelte `$state` / `$derived` / `$effect`
- PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles) - PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles)
- `famStore.applyRecord()` for instant optimistic UI feedback from form actions - `famStore.applyRecord()` for instant optimistic UI feedback from form actions
@@ -84,15 +87,16 @@
Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`: Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`:
```ts ```ts
import { defineEnvVars } from '@sveltejs/kit/hooks'; import { defineEnvVars } from "@sveltejs/kit/hooks";
export const variables = defineEnvVars({ export const variables = defineEnvVars({
DEBUG_RECORD_ID: {}, DEBUG_RECORD_ID: {},
SERVER_IP: {public: true}, SERVER_IP: { public: true },
PB_PORT: {public: true} PB_PORT: { public: true },
}); });
``` ```
Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must: Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must:
1. Add it to `.env` with `PUBLIC_` prefix 1. Add it to `.env` with `PUBLIC_` prefix
2. Add it to `frontend/src/env.ts` with `{public: true}` 2. Add it to `frontend/src/env.ts` with `{public: true}`
@@ -101,6 +105,7 @@ The actual values come from `.env` (symlinked from project root at `frontend/.en
## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore) ## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore)
**Do NOT do this:** **Do NOT do this:**
```svelte ```svelte
<!-- ❌ WRONG: $effect syncing local state from famStore fights Svelte reactivity --> <!-- ❌ WRONG: $effect syncing local state from famStore fights Svelte reactivity -->
let items = $state(data.items); let items = $state(data.items);
@@ -111,21 +116,45 @@ $effect(() => {
``` ```
**Do this instead:** **Do this instead:**
```svelte ```svelte
<!-- ✅ RIGHT: Initialize from famStore, mutate via applyRecord in callbacks --> <!-- ✅ RIGHT: Initialize from famStore, mutate via applyRecord in callbacks -->
let items = $state(famStore.initialized ? famStore.items : (data.items || [])); let items = $state(famStore.initialized ? famStore.items : (data.items || []));
``` ```
### ⚠️ CRITICAL: `$state` vs `$derived` for famStore collections
**When a page reads data that should update via SSE (realtime from other users/actions), you MUST use `$derived` — NOT `$state`.**
This is the most commonly missed rule. Agents repeatedly initialize collections from `famStore` with `$state`, which captures a **frozen snapshot** at init time. When PocketBase pushes a record via SSE → `famStore.applyRecord()`, the `$state` variable never reacts — the UI stays stale until a page refresh.
**Rule: if the data should update without a refresh, use `$derived`.**
```svelte
// ❌ WRONG — frozen snapshot, won't react to SSE updates
let configs = $state(famStore.initialized ? famStore.bonusConfigs : data.configs);
// ✅ RIGHT — reactive, updates when famStore changes via SSE
let configs = $derived(
famStore.initialized ? famStore.bonusConfigs : (data.configs || [])
);
```
**The only exception:** high-frequency member actions (like chore toggles) that need **optimistic UI** before the server responds. Those use `$state` + `famStore.applyRecord()` for instant feedback, then reconcile on the server response. See `applyRecord` pattern below.
**All other pages (admin CRUD, bonuses, rewards, settings, etc.) must use `$derived`** so that SSE updates flow through `famStore``$derived` → UI automatically.
## Update Patterns ## Update Patterns
Two patterns based on who's acting: Two patterns based on who's acting:
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | | Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
|---------|-----|-----------|-------------|-------------|------| | ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header | | Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header |
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie | | Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie |
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response: **Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
```svelte ```svelte
let completions = $state(data.completions) let completions = $state(data.completions)
async function toggle(chore) { async function toggle(chore) {
+2
View File
@@ -1,4 +1,5 @@
import { PocketBase } from 'pocketbase'; import { PocketBase } from 'pocketbase';
import type { Session } from './lib/types';
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
@@ -8,6 +9,7 @@ declare global {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
pb: PocketBase; pb: PocketBase;
session?: Session | null;
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
+126 -25
View File
@@ -9,15 +9,20 @@ function sessionHeaders(event: RequestEvent): Record<string, string> {
return { return {
'x-session-famid': s.famId, 'x-session-famid': s.famId,
'x-session-userid': s.userId, 'x-session-userid': s.userId,
'Content-Type': 'application/json', 'Content-Type': 'application/json'
}; };
} }
async function request(method: string, path: string, body?: unknown, headers?: Record<string, string>) { async function request(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
) {
const res = await fetch(`${HONO_URL}${path}`, { const res = await fetch(`${HONO_URL}${path}`, {
method, method,
headers: headers || { 'Content-Type': 'application/json' }, headers: headers || { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
@@ -29,14 +34,30 @@ export const hono = {
async list(event: RequestEvent, resource: string, famId: string) { async list(event: RequestEvent, resource: string, famId: string) {
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
}, },
async create(event: RequestEvent, resource: string, famId: string, data: Record<string, unknown>) { async create(
event: RequestEvent,
resource: string,
famId: string,
data: Record<string, unknown>
) {
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event)); return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
}, },
async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record<string, unknown>) { async update(
event: RequestEvent,
resource: string,
famId: string,
id: string,
data: Record<string, unknown>
) {
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event)); return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
}, },
async remove(event: RequestEvent, resource: string, famId: string, id: string) { async remove(event: RequestEvent, resource: string, famId: string, id: string) {
return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event)); return request(
'DELETE',
`/api/admin/${famId}/${resource}/${id}`,
undefined,
sessionHeaders(event)
);
}, },
async renameFam(event: RequestEvent, famId: string, name: string) { async renameFam(event: RequestEvent, famId: string, name: string) {
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event)); return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
@@ -63,46 +84,126 @@ export const hono = {
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
}, },
async claimReward(event: RequestEvent, famId: string, rewardId: string) { async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/rewards/${rewardId}/claim`,
undefined,
sessionHeaders(event)
);
}, },
async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) { async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) {
return request('POST', `/api/admin/${famId}/rewards/issue-all`, { memberId, message }, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/rewards/issue-all`,
{ memberId, message },
sessionHeaders(event)
);
}, },
async bonusConfigs(event: RequestEvent, famId: string) { async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
}, },
async bonusConfigProgress(event: RequestEvent, famId: string) { async bonusConfigProgress(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event)); return request(
'GET',
`/api/admin/${famId}/bonus-configs/progress`,
undefined,
sessionHeaders(event)
);
}, },
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) { async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/bonus-configs/evaluate`,
{ configId },
sessionHeaders(event)
);
}, },
async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) { async triggerBonusConfig(
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event)); event: RequestEvent,
famId: string,
configId: string,
memberId?: string
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/trigger`,
{ memberId },
sessionHeaders(event)
);
}, },
async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record<string, unknown>) { async assignBonusConfig(
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event)); event: RequestEvent,
famId: string,
configId: string,
data: Record<string, unknown>
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/assign`,
data,
sessionHeaders(event)
);
}, },
async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) { async completeBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/complete`,
undefined,
sessionHeaders(event)
);
}, },
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) { async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/destroy`,
undefined,
sessionHeaders(event)
);
}, },
async bonusConfigTallies(event: RequestEvent, famId: string) { async bonusConfigTallies(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event)); return request(
'GET',
`/api/admin/${famId}/bonus-configs/tallies`,
undefined,
sessionHeaders(event)
);
}, },
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) { async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/completions/${completionId}/revoke`,
undefined,
sessionHeaders(event)
);
}, },
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) { async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event)); return request(
'POST',
`/api/admin/${famId}/send-message`,
{ memberId, message },
sessionHeaders(event)
);
}, },
async memberChores(event: RequestEvent, famId: string, memberId: string) { async memberChores(event: RequestEvent, famId: string, memberId: string) {
return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event)); return request(
'GET',
`/api/admin/${famId}/members/${memberId}/chores`,
undefined,
sessionHeaders(event)
);
}, },
async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record<string, unknown>) { async updateMember(
return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event)); event: RequestEvent,
famId: string,
memberId: string,
data: Record<string, unknown>
) {
return request(
'PATCH',
`/api/admin/${famId}/members/${memberId}`,
data,
sessionHeaders(event)
);
}, },
async getProfile(event: RequestEvent, famId: string) { async getProfile(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event)); return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
@@ -115,6 +216,6 @@ export const hono = {
}, },
async request(event: RequestEvent, method: string, path: string, body?: unknown) { async request(event: RequestEvent, method: string, path: string, body?: unknown) {
return request(method, path, body, sessionHeaders(event)); return request(method, path, body, sessionHeaders(event));
}, }
}, }
}; };
+166 -86
View File
@@ -1,99 +1,145 @@
import { pb } from '$lib/pocketbase'; import { pb } from '$lib/pocketbase';
import type { import type {
Member, ChoreTemplate, AssignedChore, Completion, Member,
WeeklyHistory, Reward, BonusConfig, Fam, Season, ChoreTemplate,
AssignedChore,
Completion,
WeeklyHistory,
Reward,
BonusConfig,
BonusTemplate,
Fam,
Season
} from '$lib/types'; } from '$lib/types';
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons'; type CollectionName =
| 'members'
| 'chore_templates'
| 'assigned_chores'
| 'completions'
| 'bonus_configs'
| 'bonus_templates'
| 'rewards'
| 'seasons';
class FamStore { class FamStore {
fam = $state<Fam | null>(null) fam = $state<Fam | null>(null);
members = $state<Member[]>([]) members = $state<Member[]>([]);
templates = $state<ChoreTemplate[]>([]) templates = $state<ChoreTemplate[]>([]);
assigned = $state<AssignedChore[]>([]) assigned = $state<AssignedChore[]>([]);
completions = $state<Completion[]>([]) completions = $state<Completion[]>([]);
history = $state<WeeklyHistory[]>([]) history = $state<WeeklyHistory[]>([]);
rewards = $state<Reward[]>([]) rewards = $state<Reward[]>([]);
bonusConfigs = $state<BonusConfig[]>([]) bonusConfigs = $state<BonusConfig[]>([]);
seasons = $state<Season[]>([]) bonusTemplates = $state<BonusTemplate[]>([]);
initialized = $state(false) seasons = $state<Season[]>([]);
famId = $state('') initialized = $state(false);
famId = $state('');
private unsubs: (() => void)[] = [] private unsubs: (() => void)[] = [];
private destroyed = false private destroyed = false;
memberMap(): Map<string, Member> { memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m])) return new Map(this.members.map((m) => [m.id, m]));
} }
templateMap(): Map<string, ChoreTemplate> { templateMap(): Map<string, ChoreTemplate> {
return new Map(this.templates.map((t) => [t.id, t])) return new Map(this.templates.map((t) => [t.id, t]));
} }
bonusConfigMap(): Map<string, BonusConfig> { bonusConfigMap(): Map<string, BonusConfig> {
return new Map(this.bonusConfigs.map((b) => [b.id, b])) return new Map(this.bonusConfigs.map((b) => [b.id, b]));
} }
assignedForMember(memberId: string): AssignedChore[] { assignedForMember(memberId: string): AssignedChore[] {
return this.assigned.filter((a) => a.memberId === memberId) return this.assigned.filter((a) => a.memberId === memberId);
} }
completionsForDate(date: string): Completion[] { completionsForDate(date: string): Completion[] {
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date) return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date);
} }
isCompleted(assignedChoreId: string, date: string): boolean { isCompleted(assignedChoreId: string, date: string): boolean {
return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date) return this.completions.some(
(c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date
);
} }
private initPromise: Promise<void> | null = null private initPromise: Promise<void> | null = null;
async init(famId: string) { async init(famId: string) {
if (this.initialized && this.famId === famId) return if (this.initialized && this.famId === famId) return;
// Wait for any in-flight init to finish first // Wait for any in-flight init to finish first
if (this.initPromise) { if (this.initPromise) {
await this.initPromise await this.initPromise;
if (this.initialized && this.famId === famId) return if (this.initialized && this.famId === famId) return;
} }
this.cleanup() this.cleanup();
this.destroyed = false this.destroyed = false;
this.famId = famId this.famId = famId;
this.initPromise = (async () => { this.initPromise = (async () => {
try { try {
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes, seasonsRes] = const [
await Promise.all([ famRes,
membersRes,
templatesRes,
assignedRes,
completionsRes,
bonusConfigsRes,
bonusTemplatesRes,
rewardsRes,
seasonsRes
] = await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>, pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>, pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<ChoreTemplate[]>, Member[]
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<AssignedChore[]>, >,
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>, pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>, ChoreTemplate[]
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>, >,
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<Season[]>, pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<
]) AssignedChore[]
this.fam = famRes >,
this.members = membersRes pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<
this.templates = templatesRes Completion[]
this.assigned = assignedRes >,
this.completions = completionsRes pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<
this.bonusConfigs = bonusConfigsRes BonusConfig[]
this.rewards = rewardsRes >,
this.seasons = seasonsRes pb.collection('bonus_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
this.initialized = true BonusTemplate[]
>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Reward[]
>,
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Season[]
>
]);
this.fam = famRes;
this.members = membersRes;
this.templates = templatesRes;
this.assigned = assignedRes;
this.completions = completionsRes;
this.bonusConfigs = bonusConfigsRes;
this.bonusTemplates = bonusTemplatesRes;
this.rewards = rewardsRes;
this.seasons = seasonsRes;
this.initialized = true;
} catch (e) { } catch (e) {
console.error('FamStore.init failed:', e) console.error('FamStore.init failed:', e);
this.initPromise = null this.initPromise = null;
throw e throw e;
} }
await this.subscribe() await this.subscribe();
this.initPromise = null this.initPromise = null;
})() })();
return this.initPromise! return this.initPromise!;
} }
private async subscribe() { private async subscribe() {
@@ -103,58 +149,92 @@ class FamStore {
{ collection: 'assigned_chores', filter: this.famId }, { collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId }, { collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId }, { collection: 'bonus_configs', filter: this.famId },
{ collection: 'bonus_templates', filter: this.famId },
{ collection: 'rewards', filter: this.famId }, { collection: 'rewards', filter: this.famId },
{ collection: 'seasons', filter: this.famId }, { collection: 'seasons', filter: this.famId }
] ];
const promises = subs.map(({ collection, filter }) => { const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : '' const filterStr = filter ? `famId = '${filter}'` : '';
return pb.collection(collection).subscribe('*', (data: any) => { return pb
if (this.destroyed) return .collection(collection)
this.handleRealtime(collection, data.action, data.record) .subscribe(
}, { filter: filterStr || undefined }).then((unsub) => { '*',
if (this.destroyed) { unsub(); return } (data: any) => {
this.unsubs.push(unsub) if (this.destroyed) return;
}).catch((err: Error) => { this.handleRealtime(collection, data.action, data.record);
console.error(`[famStore] subscribe failed for ${collection}:`, err) },
}) { filter: filterStr || undefined }
)
.then((unsub) => {
if (this.destroyed) {
unsub();
return;
}
this.unsubs.push(unsub);
}) })
.catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err);
});
});
await Promise.allSettled(promises) await Promise.allSettled(promises);
} }
// Called from form action callbacks for instant UI feedback, // Called from form action callbacks for instant UI feedback,
// and from PB subscribe SSE for multi-user realtime. // and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => { const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list] if (action === 'create') return [record, ...list];
if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)) if (action === 'update')
if (action === 'delete') return list.filter((x) => x.id !== record.id) return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
return list if (action === 'delete') return list.filter((x) => x.id !== record.id);
} return list;
};
switch (collection) { switch (collection) {
case 'members': this.members = apply(this.members); break case 'members':
case 'chore_templates': this.templates = apply(this.templates); break this.members = apply(this.members);
case 'assigned_chores': this.assigned = apply(this.assigned); break break;
case 'completions': this.completions = apply(this.completions); break case 'chore_templates':
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break this.templates = apply(this.templates);
case 'rewards': this.rewards = apply(this.rewards); break break;
case 'seasons': this.seasons = apply(this.seasons); break case 'assigned_chores':
this.assigned = apply(this.assigned);
break;
case 'completions':
this.completions = apply(this.completions);
break;
case 'bonus_configs':
this.bonusConfigs = apply(this.bonusConfigs);
break;
case 'bonus_templates':
this.bonusTemplates = apply(this.bonusTemplates);
break;
case 'rewards':
this.rewards = apply(this.rewards);
break;
case 'seasons':
this.seasons = apply(this.seasons);
break;
} }
} }
private handleRealtime(collection: string, action: string, record: any) { private handleRealtime(collection: string, action: string, record: any) {
this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete') this.applyRecord(
collection as CollectionName,
record,
action as 'create' | 'update' | 'delete'
);
} }
cleanup() { cleanup() {
// TODO - we need to properly unsubscribe from pocketbase // TODO - we need to properly unsubscribe from pocketbase
this.destroyed = true this.destroyed = true;
for (const unsub of this.unsubs) unsub() for (const unsub of this.unsubs) unsub();
this.unsubs = [] this.unsubs = [];
this.initialized = false this.initialized = false;
} }
} }
export const famStore = new FamStore() export const famStore = new FamStore();
+132 -117
View File
@@ -1,156 +1,171 @@
export type Frequency = 'daily' | 'weekly' export type Frequency = 'daily' | 'weekly';
export type RewardType = 'points' | 'money' export type RewardType = 'points' | 'money';
export type BonusTarget = 'individual' | 'competitive' | 'collaborative' export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
export type BonusType = 'threshold' | 'count' | 'manual' export type BonusType = 'threshold' | 'count' | 'manual';
export type BonusOccurrence = 'recurring' | 'once' export type BonusOccurrence = 'recurring' | 'once';
export type BonusRewardType = 'points' | 'cash' | 'prize' export type BonusRewardType = 'points' | 'cash' | 'prize';
export type BonusPeriod = 'weekly' | 'monthly' export type BonusPeriod = 'weekly' | 'monthly' | 'daily';
export type BonusStatus = 'active' | 'archived' export type BonusStatus = 'active' | 'completed' | 'disabled';
export type BonusState = 'pending' | 'unclaimed' | 'claimed' export type BonusState = 'pending' | 'unclaimed' | 'claimed';
export interface BonusTemplate {
id: string;
famId: string;
name: string;
description?: string;
target: BonusTarget;
type: BonusType;
occurrence: BonusOccurrence;
rewardType: BonusRewardType;
rewardValue: string;
criteriaValue?: number;
period?: BonusPeriod;
created: string;
updated: string;
}
export interface Season { export interface Season {
id: string id: string;
famId: string famId: string;
name: string name: string;
color: string color: string;
active: boolean active: boolean;
autoDisable?: string autoDisable?: string;
autoStart?: string autoStart?: string;
created: string created: string;
updated: string updated: string;
} }
export interface Fam { export interface Fam {
id: string id: string;
name: string name: string;
slug: string slug: string;
inviteCode: string inviteCode: string;
stripeCustomerId?: string stripeCustomerId?: string;
featureFlags: Record<string, boolean> featureFlags: Record<string, boolean>;
created: string created: string;
updated: string updated: string;
} }
export interface Member { export interface Member {
id: string id: string;
famId: string famId: string;
name: string name: string;
color: string color: string;
deviceToken: string deviceToken: string;
deviceTokenHint: string deviceTokenHint: string;
created: string created: string;
updated: string updated: string;
} }
export interface ChoreTemplate { export interface ChoreTemplate {
id: string id: string;
famId: string famId: string;
name: string name: string;
description?: string description?: string;
defaultFrequency: Frequency defaultFrequency: Frequency;
defaultType: RewardType defaultType: RewardType;
defaultValue: number defaultValue: number;
created: string created: string;
updated: string updated: string;
} }
export interface AssignedChore { export interface AssignedChore {
id: string id: string;
famId: string famId: string;
memberId: string memberId: string;
templateId: string templateId: string;
frequency: Frequency frequency: Frequency;
type: RewardType type: RewardType;
value: number value: number;
customName?: string customName?: string;
seasonIds?: string[] seasonIds?: string[];
created: string created: string;
updated: string updated: string;
} }
export interface Completion { export interface Completion {
id: string id: string;
famId: string famId: string;
memberId: string memberId: string;
assignedChoreId: string assignedChoreId: string;
date: string date: string;
completedAt: string completedAt: string;
} }
export interface WeeklyHistory { export interface WeeklyHistory {
id: string id: string;
famId: string famId: string;
memberId: string memberId: string;
weekStart: string weekStart: string;
pointsEarned: number pointsEarned: number;
moneyEarned: number moneyEarned: number;
choresCompleted: number choresCompleted: number;
bonusEarned: number bonusEarned: number;
} }
export interface BonusConfig { export interface BonusConfig {
id: string id: string;
famId: string famId: string;
name: string name: string;
description?: string description?: string;
target: BonusTarget target: BonusTarget;
memberId?: string memberId?: string;
type: BonusType type: BonusType;
occurrence: BonusOccurrence occurrence: BonusOccurrence;
rewardType: BonusRewardType rewardType: BonusRewardType;
rewardValue: string rewardValue: string;
criteriaValue?: number criteriaValue?: number;
period?: BonusPeriod period?: BonusPeriod;
status: BonusStatus status: BonusStatus;
phase?: 'template' | 'ready' | 'active' | 'completed' created: string;
created: string updated: string;
updated: string
} }
export interface Reward { export interface Reward {
id: string id: string;
famId: string famId: string;
memberId: string memberId: string;
bonusConfigId?: string bonusConfigId?: string;
label: string label: string;
value: number value: number;
rewardType: BonusRewardType rewardType: BonusRewardType;
status: 'unclaimed' | 'requested' | 'claimed' status: 'unclaimed' | 'requested' | 'claimed';
claimedAt?: string claimedAt?: string;
requestedAt?: string requestedAt?: string;
date: string date: string;
created: string created: string;
updated: string updated: string;
} }
export interface BonusProgress { export interface BonusProgress {
memberId: string memberId: string;
memberName: string memberName: string;
memberColor: string memberColor: string;
current: number current: number;
criteriaValue: number criteriaValue: number;
reward: { id: string; status: string } | null reward: { id: string; status: string } | null;
state: BonusState state: BonusState;
achieved: boolean achieved: boolean;
} }
export interface BonusConfigWithProgress { export interface BonusConfigWithProgress {
config: BonusConfig config: BonusConfig;
progress: BonusProgress[] progress: BonusProgress[];
periodStart: string periodStart: string;
periodEnd: string periodEnd: string;
} }
export interface Settings { export interface Settings {
id: string id: string;
famId: string famId: string;
webhookUrl?: string webhookUrl?: string;
} }
export interface Session { export interface Session {
famId: string famId: string;
userId: string userId: string;
famSlug: string famSlug: string;
memberName?: string memberName?: string;
role?: string role?: string;
} }
@@ -118,16 +118,22 @@
} }
function rewardLabel(r: any): string { function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`; if (r.rewardType === 'cash') return ${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return r.label;
} }
function manualConfigs() { function manualConfigs() {
return parentBonusConfigs.filter( return parentBonusConfigs.filter((b: any) => {
(b: any) => if (!(b.status === 'active' && b.type === 'manual')) return false;
(b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual' if (b.occurrence === 'once') {
const hasReward = parentRewards.some(
(r: any) => r.bonusConfigId === b.id && (!b.memberId || r.memberId === b.memberId)
); );
if (hasReward) return false;
}
return true;
});
} }
// ─── Child View (kanban) ─── // ─── Child View (kanban) ───
@@ -140,11 +146,29 @@
let nameInput = $state(''); let nameInput = $state('');
let showColorPicker = $state(false); let showColorPicker = $state(false);
let templates = $derived(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])); let templates = $derived(
let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[])); famStore.initialized
let completions = $derived(famStore.initialized ? (famStore.completions as Completion[]) : (data.completions as Completion[])); ? (famStore.templates as ChoreTemplate[])
let rewards = $derived(famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])); : (data.templates as ChoreTemplate[])
let bonusConfigs = $derived(famStore.initialized ? (famStore.bonusConfigs as BonusConfig[]) : (data.bonusConfigs as BonusConfig[])); );
let assigned = $derived(
famStore.initialized
? (famStore.assigned as AssignedChore[])
: (data.assigned as AssignedChore[])
);
let completions = $derived(
famStore.initialized
? (famStore.completions as Completion[])
: (data.completions as Completion[])
);
let rewards = $derived(
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])
);
let bonusConfigs = $derived(
famStore.initialized
? (famStore.bonusConfigs as BonusConfig[])
: (data.bonusConfigs as BonusConfig[])
);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
@@ -187,8 +211,12 @@
let completedToday = $derived( let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
); );
let claimableRewardsChild = $derived(rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed')); let claimableRewardsChild = $derived(
let requestedRewardsChild = $derived(rewards.filter((r) => r.memberId === memberId && r.status === 'requested')); rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed')
);
let requestedRewardsChild = $derived(
rewards.filter((r) => r.memberId === memberId && r.status === 'requested')
);
let weeklyTotal = $derived.by(() => { let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily'); const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
@@ -211,9 +239,7 @@
const chore = assigned.find((a) => a.id === c.assignedChoreId); const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0); return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0) + }, 0) +
myRewards myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
.filter((r) => r.rewardType === 'cash')
.reduce((sum, r) => sum + Number(r.value), 0)
); );
}); });
@@ -248,13 +274,12 @@
const myCompletions = completions.filter((c) => c.memberId === memberId); const myCompletions = completions.filter((c) => c.memberId === memberId);
const activeConfigs = bonusConfigs.filter( const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) => (b: BonusConfig) =>
(b.phase === 'active' || (!b.phase && b.status === 'active')) && b.status === 'active' &&
b.target === 'individual' && b.target === 'individual' &&
(!b.memberId || b.memberId === memberId) (!b.memberId || b.memberId === memberId)
); );
return activeConfigs.map((cfg) => { return activeConfigs.map((cfg) => {
const pStart = const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : '';
cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : '';
const pEnd = pStart const pEnd = pStart
? (() => { ? (() => {
if (cfg.period === 'weekly') { if (cfg.period === 'weekly') {
@@ -274,8 +299,7 @@
const periodCompletions = pStart const periodCompletions = pStart
? myCompletions.filter( ? myCompletions.filter(
(c) => (c) =>
(c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd
(c.date?.slice(0, 10) || c.date) <= pEnd
) )
: myCompletions; : myCompletions;
@@ -374,14 +398,18 @@
famStore.applyRecord('completions', existing, 'delete'); famStore.applyRecord('completions', existing, 'delete');
} }
} else { } else {
famStore.applyRecord('completions', { famStore.applyRecord(
'completions',
{
id: 'optimistic-' + chore.id, id: 'optimistic-' + chore.id,
famId, famId,
memberId, memberId,
assignedChoreId: chore.id, assignedChoreId: chore.id,
date: todayChild, date: todayChild,
completedAt: new Date().toISOString() completedAt: new Date().toISOString()
} as Completion, 'create'); } as Completion,
'create'
);
} }
try { try {
@@ -392,14 +420,18 @@
} }
} catch (e) { } catch (e) {
if (wasCompleted) { if (wasCompleted) {
famStore.applyRecord('completions', { famStore.applyRecord(
'completions',
{
id: 'revert-' + chore.id + '-' + Date.now(), id: 'revert-' + chore.id + '-' + Date.now(),
famId, famId,
memberId, memberId,
assignedChoreId: chore.id, assignedChoreId: chore.id,
date: todayChild, date: todayChild,
completedAt: new Date().toISOString() completedAt: new Date().toISOString()
} as Completion, 'create'); } as Completion,
'create'
);
} else { } else {
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) { if (optimistic) {
@@ -421,7 +453,7 @@
} }
function rewardLabelChild(r: Reward): string { function rewardLabelChild(r: Reward): string {
if (r.rewardType === 'cash') return ${(Number(r.value) / 100).toFixed(2)}`; if (r.rewardType === 'cash') return ${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return r.label;
} }
@@ -468,7 +500,14 @@
{#each todays as c} {#each todays as c}
<li> <li>
✅ {choreNameFor(c.assignedChoreId)} ✅ {choreNameFor(c.assignedChoreId)}
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args); }} class="revoke-form"> <form
method="POST"
action="?/revoke"
use:enhance={() => {
return async (args) => handleResult(args);
}}
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} /> <input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke">↩</button> <button type="submit" class="revoke-btn" title="Revoke">↩</button>
</form> </form>
@@ -487,7 +526,7 @@
{#each manualConfigs() as bc} {#each manualConfigs() as bc}
{@const val = {@const val =
bc.rewardType === 'cash' bc.rewardType === 'cash'
? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` ? `£${Number(bc.rewardValue).toFixed(2)}`
: bc.rewardType === 'points' : bc.rewardType === 'points'
? `${bc.rewardValue} pts` ? `${bc.rewardValue} pts`
: bc.rewardValue} : bc.rewardValue}
@@ -500,7 +539,13 @@
<span class="trigger-value">{val}</span> <span class="trigger-value">{val}</span>
</div> </div>
{#if targeted} {#if targeted}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}> <form
method="POST"
action="?/trigger"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="configId" value={bc.id} /> <input type="hidden" name="configId" value={bc.id} />
<input type="hidden" name="memberId" value={targeted.id} /> <input type="hidden" name="memberId" value={targeted.id} />
<div class="trigger-row"> <div class="trigger-row">
@@ -511,7 +556,13 @@
</div> </div>
</form> </form>
{:else} {:else}
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}> <form
method="POST"
action="?/trigger"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="configId" value={bc.id} /> <input type="hidden" name="configId" value={bc.id} />
<div class="trigger-row"> <div class="trigger-row">
<select name="memberId" class="trigger-select"> <select name="memberId" class="trigger-select">
@@ -562,6 +613,7 @@
<span class="value">{rewardLabel(r)}</span> <span class="value">{rewardLabel(r)}</span>
<Button type="submit" size="sm" variant="primary">Issue</Button> <Button type="submit" size="sm" variant="primary">Issue</Button>
</div> </div>
</div>
</form> </form>
{/each} {/each}
{#if requested.length > 1} {#if requested.length > 1}
@@ -569,13 +621,14 @@
method="POST" method="POST"
action="?/issueAll" action="?/issueAll"
use:enhance={() => { use:enhance={() => {
return async (args: any) => return async (args: any) => handleResult(args);
handleResult(args);
}} }}
> >
<input type="hidden" name="memberId" value={m.id} /> <input type="hidden" name="memberId" value={m.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} /> <input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<Button type="submit" size="sm" variant="secondary">Issue All ({requested.length})</Button> <Button type="submit" size="sm" variant="secondary"
>Issue All ({requested.length})</Button
>
</form> </form>
{/if} {/if}
{/if} {/if}
@@ -746,11 +799,11 @@
</div> </div>
<div class="badge-card cash"> <div class="badge-card cash">
<span class="badge-label">Total Cash</span> <span class="badge-label">Total Cash</span>
<span class="badge-value">£{(totalCash / 100).toFixed(2)}</span> <span class="badge-value">£{totalCash.toFixed(2)}</span>
</div> </div>
<div class="badge-card pending"> <div class="badge-card pending">
<span class="badge-label">Pending Cash</span> <span class="badge-label">Pending Cash</span>
<span class="badge-value">£{(pendingCash / 100).toFixed(2)}</span> <span class="badge-value">£{pendingCash.toFixed(2)}</span>
</div> </div>
<div class="badge-card points"> <div class="badge-card points">
<span class="badge-label">Total Points</span> <span class="badge-label">Total Points</span>
@@ -1440,9 +1493,16 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.request-all-btn:hover { background: #1d4ed8; } .request-all-btn:hover {
.request-col { border-color: #93c5fd; background: #eff6ff; } background: #1d4ed8;
.claim-card.requested { opacity: 0.7; } }
.request-col {
border-color: #93c5fd;
background: #eff6ff;
}
.claim-card.requested {
opacity: 0.7;
}
.requested-badge { .requested-badge {
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 8px; padding: 2px 8px;
@@ -1465,7 +1525,9 @@
color: #2563eb; color: #2563eb;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
} }
.payment-row.outstanding { opacity: 0.6; } .payment-row.outstanding {
opacity: 0.6;
}
h1 { h1 {
display: flex; display: flex;
@@ -4,21 +4,95 @@ import { hono } from '$lib/server/hono';
export async function load(event) { export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const [configs, members, progress, rewards] = await Promise.all([ const [configs, templates, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId), hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'bonus-templates', famId),
hono.admin.list(event, 'members', famId), hono.admin.list(event, 'members', famId),
hono.admin.bonusConfigProgress(event, famId), hono.admin.bonusConfigProgress(event, famId),
hono.admin.rewards(event, famId), hono.admin.rewards(event, famId)
]); ]);
return { configs, members, progress, rewards }; return { configs, templates, members, progress, rewards };
} }
export const actions = { export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data: Record<string, unknown> = {
name: fd.get('name'),
description: fd.get('description') || '',
target: fd.get('target'),
type: fd.get('type'),
occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue')
};
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = '';
try {
const record = await hono.admin.create(event, 'bonus-templates', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
}
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const name = fd.get('name');
if (name) data.name = name;
const target = fd.get('target');
if (target) data.target = target;
const type = fd.get('type');
if (type) data.type = type;
const occurrence = fd.get('occurrence');
if (occurrence) data.occurrence = occurrence;
const rewardType = fd.get('rewardType');
if (rewardType) data.rewardType = rewardType;
const rewardValue = fd.get('rewardValue');
if (rewardValue) data.rewardValue = rewardValue;
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
if (occurrence === 'once') data.period = '';
const description = fd.get('description');
if (description) data.description = description;
try {
const record = await hono.admin.update(event, 'bonus-templates', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
}
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-templates', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
}
},
createConfig: async (event) => { createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const phase = fd.get('phase') as string; const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
name: fd.get('name'), name: fd.get('name'),
target: fd.get('target'), target: fd.get('target'),
@@ -26,12 +100,13 @@ export const actions = {
occurrence: fd.get('occurrence'), occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'), rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue'), rewardValue: fd.get('rewardValue'),
phase: phase || 'ready', status
}; };
const criteriaValue = fd.get('criteriaValue'); const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -66,6 +141,7 @@ export const actions = {
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (occurrence === 'once') data.period = '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -87,7 +163,7 @@ export const actions = {
await hono.admin.remove(event, 'bonus-configs', famId, id); await hono.admin.remove(event, 'bonus-configs', famId, id);
return { deleted: true }; return { deleted: true };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' });
} }
}, },
@@ -95,6 +171,8 @@ export const actions = {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const startMode = fd.get('startMode') as string;
const status = startMode === 'disabled' ? 'disabled' : 'active';
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
name: fd.get('name'), name: fd.get('name'),
description: fd.get('description') || '', description: fd.get('description') || '',
@@ -106,13 +184,16 @@ export const actions = {
criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0, criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0,
period: fd.get('period') || '', period: fd.get('period') || '',
memberId: fd.get('memberId') || '', memberId: fd.get('memberId') || '',
phase: 'active', status
}; };
if (data.occurrence === 'once') data.period = '';
try { try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data); const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' }); return fail(400, {
error: e instanceof Error ? e.message : 'Failed to create bonus from template'
});
} }
}, },
@@ -124,7 +205,10 @@ export const actions = {
const target = fd.get('target') as string; const target = fd.get('target') as string;
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
try { try {
const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null }); const record = await hono.admin.assignBonusConfig(event, famId, id, {
target,
memberId: memberId || null
});
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' });
@@ -136,12 +220,11 @@ export const actions = {
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const id = fd.get('id') as string; const id = fd.get('id') as string;
const phase = fd.get('phase') as string;
try { try {
const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed'); const record = await hono.admin.completeBonusConfig(event, famId, id);
return { record }; return { record };
} catch (e) { } catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' }); return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' });
} }
}, },
@@ -158,6 +241,23 @@ export const actions = {
} }
}, },
toggleConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const currentStatus = fd.get('currentStatus') as string;
const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled';
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, {
status: newStatus
});
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to toggle bonus' });
}
},
claimReward: async (event) => { claimReward: async (event) => {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
@@ -178,5 +278,5 @@ export const actions = {
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to evaluate' }; return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
} }
}, }
}; };
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@
} }
function rewardAmount(r: any): string { function rewardAmount(r: any): string {
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`; if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return r.label;
} }
@@ -112,7 +112,7 @@
<td></td> <td></td>
<td></td> <td></td>
<td class="num"><strong>Outstanding cash</strong></td> <td class="num"><strong>Outstanding cash</strong></td>
<td class="num"><strong>£{(totalOutstanding / 100).toFixed(2)}</strong></td> <td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
<td></td> <td></td>
<td></td> <td></td>
</tr> </tr>
+47 -26
View File
@@ -3,27 +3,27 @@ import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
interface FieldDef { interface FieldDef {
name: string name: string;
type: string type: string;
required?: boolean required?: boolean;
unique?: boolean unique?: boolean;
max?: number max?: number;
min?: number min?: number;
values?: string[] values?: string[];
maxSelect?: number maxSelect?: number;
collectionId?: string collectionId?: string;
cascadeDelete?: boolean cascadeDelete?: boolean;
} }
interface CollectionDef { interface CollectionDef {
name: string name: string;
type: string type: string;
fields: FieldDef[] fields: FieldDef[];
listRule?: string | null listRule?: string | null;
viewRule?: string | null viewRule?: string | null;
createRule?: string | null createRule?: string | null;
updateRule?: string | null updateRule?: string | null;
deleteRule?: string | null deleteRule?: string | null;
} }
async function getSuperadminToken(): Promise<string> { async function getSuperadminToken(): Promise<string> {
@@ -63,7 +63,8 @@ async function createCollection(
body: JSON.stringify(col), body: JSON.stringify(col),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); if (!res.ok)
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created: ${col.name}`); console.log(` ✓ Created: ${col.name}`);
return data.id; return data.id;
} }
@@ -146,10 +147,7 @@ async function main() {
createRule: null, createRule: null,
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ fields: [rel("famId", ids.fams!, true), text("webhookUrl")],
rel("famId", ids.fams!, true),
text("webhookUrl"),
],
}); });
ids.members = await createCollection(token, { ids.members = await createCollection(token, {
@@ -216,14 +214,37 @@ async function main() {
rel("famId", ids.fams!, true), rel("famId", ids.fams!, true),
text("name", true), text("name", true),
text("description"), text("description"),
select("target", ["individual", "competitive"], true), select("target", ["individual", "competitive", "collaborative"], true),
select("type", ["threshold", "count", "manual"], true), select("type", ["threshold", "count", "manual"], true),
select("occurrence", ["recurring", "once"], true), select("occurrence", ["recurring", "once"], true),
select("rewardType", ["points", "cash", "prize"], true), select("rewardType", ["points", "cash", "prize"], true),
text("rewardValue", true), text("rewardValue", true),
number("criteriaValue"), number("criteriaValue"),
select("period", ["weekly", "monthly"]), rel("memberId", ids.members!),
select("status", ["active", "archived"], true), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true),
],
});
ids.bonus_templates = await createCollection(token, {
name: "bonus_templates",
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", "collaborative"], 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", ["schedule", "daily", "weekly", "monthly"]),
], ],
}); });
+749 -213
View File
File diff suppressed because it is too large Load Diff
+564 -147
View File
@@ -6,11 +6,14 @@ let token: string | null = null;
async function auth(): Promise<string> { async function auth(): Promise<string> {
if (token) return token; if (token) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { const res = await fetch(
`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }), body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
}); },
);
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token; token = data.token;
@@ -19,9 +22,12 @@ async function auth(): Promise<string> {
async function getCollection(name: string): Promise<any | null> { async function getCollection(name: string): Promise<any | null> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { const res = await fetch(
`${PB_ENDPOINT}/api/collections?filter=name='${name}'`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const data = await res.json(); const data = await res.json();
return data?.items?.[0] || null; return data?.items?.[0] || null;
} }
@@ -30,11 +36,15 @@ async function createCollection(col: any): Promise<void> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, { const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify(col), body: JSON.stringify(col),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); if (!res.ok)
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created collection: ${col.name}`); console.log(` ✓ Created collection: ${col.name}`);
} }
@@ -42,11 +52,15 @@ async function updateCollection(id: string, col: any): Promise<void> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify(col), body: JSON.stringify(col),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`); if (!res.ok)
throw new Error(`Update collection ${id} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Updated collection: ${col.name || id}`); console.log(` ✓ Updated collection: ${col.name || id}`);
} }
@@ -71,17 +85,60 @@ export async function migrate(): Promise<void> {
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ fields: [
{ name: "famId", type: "relation", required: true, collectionId: famsId, maxSelect: 1, cascadeDelete: false }, {
name: "famId",
type: "relation",
required: true,
collectionId: famsId,
maxSelect: 1,
cascadeDelete: false,
},
{ name: "name", type: "text", required: true }, { name: "name", type: "text", required: true },
{ name: "description", type: "text", required: false }, { 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: "target",
{ name: "occurrence", type: "select", required: true, values: ["recurring", "once"], maxSelect: 1 }, type: "select",
{ name: "rewardType", type: "select", required: true, values: ["points", "cash", "prize"], maxSelect: 1 }, 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: "rewardValue", type: "text", required: true },
{ name: "criteriaValue", type: "number", required: false }, { 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 }, name: "period",
type: "select",
required: false,
values: ["schedule", "daily", "weekly", "monthly"],
maxSelect: 1,
},
{
name: "status",
type: "select",
required: true,
values: ["active", "archived"],
maxSelect: 1,
},
], ],
}); });
} else { } else {
@@ -89,13 +146,17 @@ export async function migrate(): Promise<void> {
const targetField = existing.fields.find((f: any) => f.name === "target"); const targetField = existing.fields.find((f: any) => f.name === "target");
const periodField = existing.fields.find((f: any) => f.name === "period"); const periodField = existing.fields.find((f: any) => f.name === "period");
const needTargetUpdate = targetField && !targetField.values.includes("collaborative"); const needTargetUpdate =
const needPeriodUpdate = periodField && !periodField.values.includes("schedule"); targetField && !targetField.values.includes("collaborative");
const needPeriodUpdate =
periodField && !periodField.values.includes("daily");
if (needTargetUpdate || needPeriodUpdate) { if (needTargetUpdate || needPeriodUpdate) {
console.log("[migrate] Updating bonus_configs fields..."); console.log("[migrate] Updating bonus_configs fields...");
if (needTargetUpdate) targetField.values = ["individual", "competitive", "collaborative"]; if (needTargetUpdate)
if (needPeriodUpdate) periodField.values = ["schedule", "weekly", "monthly"]; targetField.values = ["individual", "competitive", "collaborative"];
if (needPeriodUpdate)
periodField.values = ["schedule", "daily", "weekly", "monthly"];
await updateCollection(existing.id, { await updateCollection(existing.id, {
name: "bonus_configs", name: "bonus_configs",
type: "base", type: "base",
@@ -125,16 +186,49 @@ export async function migrate(): Promise<void> {
const bonusConfigsCol = await getCollection("bonus_configs"); const bonusConfigsCol = await getCollection("bonus_configs");
const keepFields = rewardsCol.fields.filter((f: any) => const keepFields = rewardsCol.fields.filter((f: any) =>
["famId", "memberId", "label", "value", "claimed", "claimedAt", "rewardType", "bonusConfigId", "created", "updated", "id"].includes(f.name) [
"famId",
"memberId",
"label",
"value",
"claimed",
"claimedAt",
"rewardType",
"bonusConfigId",
"created",
"updated",
"id",
].includes(f.name),
); );
const newFields = [ const newFields = [
...keepFields, ...keepFields,
...(bonusConfigsCol && !fieldNames.includes("bonusConfigId") ...(bonusConfigsCol && !fieldNames.includes("bonusConfigId")
? [{ name: "bonusConfigId", type: "relation", required: false, collectionId: bonusConfigsCol.id, maxSelect: 1, cascadeDelete: false }] ? [
{
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("rewardType")
...(fieldNames.includes("claimedAt") ? [] : [{ name: "claimedAt", type: "date", required: false }]), ? []
: [
{
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 }, { name: "date", type: "text", required: false },
]; ];
@@ -168,11 +262,17 @@ export async function migrate(): Promise<void> {
patches.claimedAt = new Date().toISOString(); patches.claimedAt = new Date().toISOString();
} }
if (Object.keys(patches).length > 0) { if (Object.keys(patches).length > 0) {
await fetch(`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, { await fetch(
`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t2}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t2}`,
},
body: JSON.stringify(patches), body: JSON.stringify(patches),
}); },
);
} }
} }
console.log(` ✓ Backfilled ${backfillData.items.length} rewards`); console.log(` ✓ Backfilled ${backfillData.items.length} rewards`);
@@ -188,10 +288,15 @@ export async function migrate(): Promise<void> {
const settingsCol = await getCollection("settings"); const settingsCol = await getCollection("settings");
if (settingsCol) { if (settingsCol) {
const fieldNames = settingsCol.fields.map((f: any) => f.name); const fieldNames = settingsCol.fields.map((f: any) => f.name);
if (fieldNames.includes("pointsThreshold") || fieldNames.includes("weeklyBonus")) { if (
console.log("[migrate] Updating settings collection (dropping old fields)..."); fieldNames.includes("pointsThreshold") ||
fieldNames.includes("weeklyBonus")
) {
console.log(
"[migrate] Updating settings collection (dropping old fields)...",
);
const keepFields = settingsCol.fields.filter((f: any) => const keepFields = settingsCol.fields.filter((f: any) =>
["famId", "webhookUrl", "created", "updated", "id"].includes(f.name) ["famId", "webhookUrl", "created", "updated", "id"].includes(f.name),
); );
await updateCollection(settingsCol.id, { await updateCollection(settingsCol.id, {
name: "settings", name: "settings",
@@ -225,8 +330,22 @@ export async function migrate(): Promise<void> {
updateRule: null, updateRule: null,
deleteRule: null, deleteRule: null,
fields: [ 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: "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: "message", type: "text", required: true },
{ name: "read", type: "bool", required: false }, { name: "read", type: "bool", required: false },
], ],
@@ -241,7 +360,13 @@ export async function migrate(): Promise<void> {
const hasPayday = famsCol.fields.some((f: any) => f.name === "payday"); const hasPayday = famsCol.fields.some((f: any) => f.name === "payday");
if (!hasPayday) { if (!hasPayday) {
console.log("[migrate] Adding payday field to fams..."); console.log("[migrate] Adding payday field to fams...");
const paydayField = { name: "payday", type: "number", required: false, min: 0, max: 6 }; const paydayField = {
name: "payday",
type: "number",
required: false,
min: 0,
max: 6,
};
famsCol.fields.push(paydayField); famsCol.fields.push(paydayField);
await updateCollection(famsCol.id, { await updateCollection(famsCol.id, {
name: "fams", name: "fams",
@@ -265,42 +390,60 @@ export async function migrate(): Promise<void> {
const completionsCol = await getCollection("completions"); const completionsCol = await getCollection("completions");
if (completionsCol) { if (completionsCol) {
const t = await auth(); const t = await auth();
const all = await fetch(`${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`, { const all = await fetch(
`${PB_ENDPOINT}/api/collections/completions/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const data = await all.json(); const data = await all.json();
if (data?.items?.length) { if (data?.items?.length) {
let backfilled = 0; let backfilled = 0;
for (const rec of data.items) { for (const rec of data.items) {
const plain = rec.date?.slice(0, 10); const plain = rec.date?.slice(0, 10);
if (plain && plain !== rec.date) { if (plain && plain !== rec.date) {
await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`, { await fetch(
`${PB_ENDPOINT}/api/collections/completions/records/${rec.id}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ date: plain }), body: JSON.stringify({ date: plain }),
}); },
);
backfilled++; backfilled++;
} }
} }
if (backfilled > 0) console.log(` ✓ Backfilled ${backfilled} completion dates`); if (backfilled > 0)
console.log(` ✓ Backfilled ${backfilled} completion dates`);
else console.log(` ↳ completions.date already normalised`); else console.log(` ↳ completions.date already normalised`);
} }
} }
} catch (e) { } catch (e) {
console.log(` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`); console.log(
` ↳ Step 6 backfill skipped: ${e instanceof Error ? e.message : e}`,
);
} }
// ── 7. Add memberId field to bonus_configs ── // ── 7. Add memberId field to bonus_configs ──
const bonusConfigsCol = await getCollection("bonus_configs"); const bonusConfigsCol = await getCollection("bonus_configs");
if (bonusConfigsCol) { if (bonusConfigsCol) {
const hasMemberId = bonusConfigsCol.fields.some((f: any) => f.name === "memberId"); const hasMemberId = bonusConfigsCol.fields.some(
(f: any) => f.name === "memberId",
);
if (!hasMemberId) { if (!hasMemberId) {
const membersCol = await getCollection("members"); const membersCol = await getCollection("members");
if (membersCol) { if (membersCol) {
console.log("[migrate] Adding memberId field to bonus_configs..."); console.log("[migrate] Adding memberId field to bonus_configs...");
bonusConfigsCol.fields.push({ bonusConfigsCol.fields.push({
name: "memberId", type: "relation", required: false, name: "memberId",
collectionId: membersCol.id, maxSelect: 1, cascadeDelete: false, type: "relation",
required: false,
collectionId: membersCol.id,
maxSelect: 1,
cascadeDelete: false,
}); });
await updateCollection(bonusConfigsCol.id, { await updateCollection(bonusConfigsCol.id, {
name: "bonus_configs", name: "bonus_configs",
@@ -318,46 +461,8 @@ export async function migrate(): Promise<void> {
} }
} }
// ── 8. Add phase field to bonus_configs ── // ── 8. (Removed) bonus_configs.phase field was dropped — replaced by status: active|completed.
if (bonusConfigsCol) { // Templates now live in a dedicated `bonus_templates` collection (see step 23+).
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 ── // ── 9. Add role + userId fields to members ──
const membersCol = await getCollection("members"); const membersCol = await getCollection("members");
@@ -366,8 +471,20 @@ export async function migrate(): Promise<void> {
const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); const hasUserId = membersCol.fields.some((f: any) => f.name === "userId");
if (!hasRole || !hasUserId) { if (!hasRole || !hasUserId) {
console.log("[migrate] Adding role/userId fields to members..."); 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 (!hasRole)
if (!hasUserId) membersCol.fields.push({ name: "userId", type: "text", required: false }); 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, { await updateCollection(membersCol.id, {
name: "members", name: "members",
type: "base", type: "base",
@@ -408,19 +525,29 @@ export async function migrate(): Promise<void> {
if (membersCol) { if (membersCol) {
try { try {
const t = await auth(); const t = await auth();
const allMembers = await fetch(`${PB_ENDPOINT}/api/collections/members/records?perPage=1000`, { const allMembers = await fetch(
`${PB_ENDPOINT}/api/collections/members/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const membersData = await allMembers.json(); const membersData = await allMembers.json();
const needBackfill = (membersData?.items || []).filter((m: any) => !m.userId); const needBackfill = (membersData?.items || []).filter(
(m: any) => !m.userId,
);
if (needBackfill.length > 0) { if (needBackfill.length > 0) {
console.log(`[migrate] Backfilling userId for ${needBackfill.length} members...`); console.log(
const adminsRes = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { `[migrate] Backfilling userId for ${needBackfill.length} members...`,
);
const adminsRes = await fetch(
`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const adminsData = await adminsRes.json(); const adminsData = await adminsRes.json();
const adminsByFam = new Map<string, any[]>(); const adminsByFam = new Map<string, any[]>();
for (const a of (adminsData?.items || [])) { for (const a of adminsData?.items || []) {
const list = adminsByFam.get(a.famId) || []; const list = adminsByFam.get(a.famId) || [];
list.push(a); list.push(a);
adminsByFam.set(a.famId, list); adminsByFam.set(a.famId, list);
@@ -429,21 +556,32 @@ export async function migrate(): Promise<void> {
for (const m of needBackfill) { for (const m of needBackfill) {
const admins = adminsByFam.get(m.famId) || []; const admins = adminsByFam.get(m.famId) || [];
if (admins.length === 1) { if (admins.length === 1) {
await fetch(`${PB_ENDPOINT}/api/collections/members/records/${m.id}`, { await fetch(
`${PB_ENDPOINT}/api/collections/members/records/${m.id}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ userId: admins[0].userId }), body: JSON.stringify({ userId: admins[0].userId }),
}); },
);
count++; count++;
} }
} }
if (count > 0) console.log(` ✓ Backfilled ${count} member userIds`); 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)`); if (count < needBackfill.length)
console.log(
` ↳ Skipped ${needBackfill.length - count} members (no unique fam_admin match)`,
);
} else { } else {
console.log(` ↳ members.userId already backfilled`); console.log(` ↳ members.userId already backfilled`);
} }
} catch (e) { } catch (e) {
console.log(` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`); console.log(
` ↳ Backfill step skipped: ${e instanceof Error ? e.message : e}`,
);
} }
} }
@@ -452,7 +590,9 @@ export async function migrate(): Promise<void> {
const hasUserId = membersCol.fields.some((f: any) => f.name === "userId"); const hasUserId = membersCol.fields.some((f: any) => f.name === "userId");
if (hasUserId) { if (hasUserId) {
console.log("[migrate] Dropping userId field from members..."); console.log("[migrate] Dropping userId field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "userId"); membersCol.fields = membersCol.fields.filter(
(f: any) => f.name !== "userId",
);
await updateCollection(membersCol.id, { await updateCollection(membersCol.id, {
name: "members", name: "members",
type: "base", type: "base",
@@ -475,8 +615,10 @@ export async function migrate(): Promise<void> {
const hasColor = adminsCol.fields.some((f: any) => f.name === "color"); const hasColor = adminsCol.fields.some((f: any) => f.name === "color");
if (!hasName || !hasColor) { if (!hasName || !hasColor) {
console.log("[migrate] Adding name/color to fam_admins..."); console.log("[migrate] Adding name/color to fam_admins...");
if (!hasName) adminsCol.fields.push({ name: "name", type: "text", required: false }); if (!hasName)
if (!hasColor) adminsCol.fields.push({ name: "color", type: "text", required: false }); adminsCol.fields.push({ name: "name", type: "text", required: false });
if (!hasColor)
adminsCol.fields.push({ name: "color", type: "text", required: false });
await updateCollection(adminsCol.id, { await updateCollection(adminsCol.id, {
name: "fam_admins", name: "fam_admins",
type: "base", type: "base",
@@ -489,23 +631,34 @@ export async function migrate(): Promise<void> {
}); });
// Backfill name from email prefix // Backfill name from email prefix
const t = await auth(); const t = await auth();
const all = await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`, { const all = await fetch(
`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const data = await all.json(); const data = await all.json();
for (const a of (data?.items || [])) { for (const a of data?.items || []) {
const patch: Record<string, string> = {}; const patch: Record<string, string> = {};
if (!a.name) patch.name = (a.email || "admin").split("@")[0]; if (!a.name) patch.name = (a.email || "admin").split("@")[0];
if (!a.color) patch.color = "#6366f1"; if (!a.color) patch.color = "#6366f1";
if (Object.keys(patch).length) { if (Object.keys(patch).length) {
await fetch(`${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`, { await fetch(
`${PB_ENDPOINT}/api/collections/fam_admins/records/${a.id}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); },
);
} }
} }
console.log(` ✓ Backfilled name/color for ${(data?.items || []).length} admins`); console.log(
` ✓ Backfilled name/color for ${(data?.items || []).length} admins`,
);
} else { } else {
console.log(` ↳ fam_admins.name/color already exists`); console.log(` ↳ fam_admins.name/color already exists`);
} }
@@ -516,7 +669,9 @@ export async function migrate(): Promise<void> {
const hasRole = membersCol.fields.some((f: any) => f.name === "role"); const hasRole = membersCol.fields.some((f: any) => f.name === "role");
if (hasRole) { if (hasRole) {
console.log("[migrate] Dropping role field from members..."); console.log("[migrate] Dropping role field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "role"); membersCol.fields = membersCol.fields.filter(
(f: any) => f.name !== "role",
);
await updateCollection(membersCol.id, { await updateCollection(membersCol.id, {
name: "members", name: "members",
type: "base", type: "base",
@@ -537,7 +692,9 @@ export async function migrate(): Promise<void> {
const hasEmail = membersCol.fields.some((f: any) => f.name === "email"); const hasEmail = membersCol.fields.some((f: any) => f.name === "email");
if (hasEmail) { if (hasEmail) {
console.log("[migrate] Dropping email field from members..."); console.log("[migrate] Dropping email field from members...");
membersCol.fields = membersCol.fields.filter((f: any) => f.name !== "email"); membersCol.fields = membersCol.fields.filter(
(f: any) => f.name !== "email",
);
await updateCollection(membersCol.id, { await updateCollection(membersCol.id, {
name: "members", name: "members",
type: "base", type: "base",
@@ -562,10 +719,14 @@ export async function migrate(): Promise<void> {
console.log("[migrate] Adding seasons to fams..."); console.log("[migrate] Adding seasons to fams...");
c.fields.push({ name: "seasons", type: "json" }); c.fields.push({ name: "seasons", type: "json" });
await updateCollection(c.id, { await updateCollection(c.id, {
name: "fams", type: "base", name: "fams",
listRule: c.listRule, viewRule: c.viewRule, type: "base",
createRule: c.createRule, updateRule: c.updateRule, listRule: c.listRule,
deleteRule: c.deleteRule, fields: c.fields, viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
}); });
} else { } else {
console.log(` ↳ fams.seasons already exists`); console.log(` ↳ fams.seasons already exists`);
@@ -582,10 +743,14 @@ export async function migrate(): Promise<void> {
console.log("[migrate] Adding seasonIds to assigned_chores..."); console.log("[migrate] Adding seasonIds to assigned_chores...");
c.fields.push({ name: "seasonIds", type: "json" }); c.fields.push({ name: "seasonIds", type: "json" });
await updateCollection(c.id, { await updateCollection(c.id, {
name: "assigned_chores", type: "base", name: "assigned_chores",
listRule: c.listRule, viewRule: c.viewRule, type: "base",
createRule: c.createRule, updateRule: c.updateRule, listRule: c.listRule,
deleteRule: c.deleteRule, fields: c.fields, viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
}); });
} else { } else {
console.log(` ↳ assigned_chores.seasonIds already exists`); console.log(` ↳ assigned_chores.seasonIds already exists`);
@@ -602,13 +767,26 @@ export async function migrate(): Promise<void> {
const famsCol = await getCollection("fams"); const famsCol = await getCollection("fams");
const res = await fetch(`${PB_ENDPOINT}/api/collections`, { const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ body: JSON.stringify({
name: "seasons", type: "base", name: "seasons",
listRule: "", viewRule: "", type: "base",
createRule: null, updateRule: null, deleteRule: null, listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [ fields: [
{ name: "famId", type: "relation", required: true, maxSelect: 1, collectionId: famsCol?.id || "" }, {
name: "famId",
type: "relation",
required: true,
maxSelect: 1,
collectionId: famsCol?.id || "",
},
{ name: "name", type: "text", required: true }, { name: "name", type: "text", required: true },
{ name: "color", type: "text" }, { name: "color", type: "text" },
{ name: "active", type: "bool" }, { name: "active", type: "bool" },
@@ -618,7 +796,10 @@ export async function migrate(): Promise<void> {
}), }),
}); });
if (res.ok) console.log(" ✓ Created seasons collection"); if (res.ok) console.log(" ✓ Created seasons collection");
else console.log(` ↳ seasons collection creation skipped or already exists`); else
console.log(
` ↳ seasons collection creation skipped or already exists`,
);
} else { } else {
console.log(` ↳ seasons collection already exists`); console.log(` ↳ seasons collection already exists`);
} }
@@ -633,10 +814,14 @@ export async function migrate(): Promise<void> {
console.log("[migrate] Adding active bool to seasons..."); console.log("[migrate] Adding active bool to seasons...");
c.fields.push({ name: "active", type: "bool" }); c.fields.push({ name: "active", type: "bool" });
await updateCollection(c.id, { await updateCollection(c.id, {
name: "seasons", type: "base", name: "seasons",
listRule: c.listRule, viewRule: c.viewRule, type: "base",
createRule: c.createRule, updateRule: c.updateRule, listRule: c.listRule,
deleteRule: c.deleteRule, fields: c.fields, viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
}); });
} else { } else {
console.log(` ↳ seasons.active already exists`); console.log(` ↳ seasons.active already exists`);
@@ -649,22 +834,35 @@ export async function migrate(): Promise<void> {
const t = await auth(); const t = await auth();
const fams = await getCollection("fams"); const fams = await getCollection("fams");
if (fams) { if (fams) {
const hasSeasonsField = fams.fields.some((f: any) => f.name === "seasons"); const hasSeasonsField = fams.fields.some(
(f: any) => f.name === "seasons",
);
if (hasSeasonsField) { if (hasSeasonsField) {
console.log("[migrate] Backfilling season active flags from fams.seasons..."); console.log(
const allFams = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`, { "[migrate] Backfilling season active flags from fams.seasons...",
);
const allFams = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const famData = await allFams.json(); const famData = await allFams.json();
for (const fam of famData.items || []) { for (const fam of famData.items || []) {
const activeIds = fam.seasons || []; const activeIds = fam.seasons || [];
if (activeIds.length > 0) { if (activeIds.length > 0) {
for (const sid of activeIds) { for (const sid of activeIds) {
await fetch(`${PB_ENDPOINT}/api/collections/seasons/records/${sid}`, { await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records/${sid}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ active: true }), body: JSON.stringify({ active: true }),
}); },
);
} }
} }
} }
@@ -684,10 +882,14 @@ export async function migrate(): Promise<void> {
console.log("[migrate] Removing seasons field from fams..."); console.log("[migrate] Removing seasons field from fams...");
c.fields = c.fields.filter((f: any) => f.name !== "seasons"); c.fields = c.fields.filter((f: any) => f.name !== "seasons");
await updateCollection(c.id, { await updateCollection(c.id, {
name: "fams", type: "base", name: "fams",
listRule: c.listRule, viewRule: c.viewRule, type: "base",
createRule: c.createRule, updateRule: c.updateRule, listRule: c.listRule,
deleteRule: c.deleteRule, fields: c.fields, viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
}); });
} else { } else {
console.log(` ↳ fams.seasons already removed`); console.log(` ↳ fams.seasons already removed`);
@@ -709,17 +911,26 @@ export async function migrate(): Promise<void> {
let page = 1; let page = 1;
let total = 0; let total = 0;
while (true) { while (true) {
const res = await fetch(`${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`, { const res = await fetch(
`${PB_ENDPOINT}/api/collections/rewards/records?page=${page}&perPage=100`,
{
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); },
);
const data = await res.json(); const data = await res.json();
for (const r of data.items || []) { for (const r of data.items || []) {
const status = r.claimed ? "claimed" : "unclaimed"; const status = r.claimed ? "claimed" : "unclaimed";
await fetch(`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`, { await fetch(
`${PB_ENDPOINT}/api/collections/rewards/records/${r.id}`,
{
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}); },
);
total++; total++;
} }
if (!data.items || data.items.length < 100) break; if (!data.items || data.items.length < 100) break;
@@ -730,16 +941,26 @@ export async function migrate(): Promise<void> {
// Remove claimed field and add status + requestedAt fields // Remove claimed field and add status + requestedAt fields
c.fields = c.fields.filter((f: any) => f.name !== "claimed"); c.fields = c.fields.filter((f: any) => f.name !== "claimed");
if (!c.fields.some((f: any) => f.name === "status")) { if (!c.fields.some((f: any) => f.name === "status")) {
c.fields.push({ name: "status", type: "select", required: true, values: ["unclaimed", "requested", "claimed"], maxSelect: 1 }); c.fields.push({
name: "status",
type: "select",
required: true,
values: ["unclaimed", "requested", "claimed"],
maxSelect: 1,
});
} }
if (!c.fields.some((f: any) => f.name === "requestedAt")) { if (!c.fields.some((f: any) => f.name === "requestedAt")) {
c.fields.push({ name: "requestedAt", type: "date", required: false }); c.fields.push({ name: "requestedAt", type: "date", required: false });
} }
await updateCollection(c.id, { await updateCollection(c.id, {
name: "rewards", type: "base", name: "rewards",
listRule: c.listRule, viewRule: c.viewRule, type: "base",
createRule: c.createRule, updateRule: c.updateRule, listRule: c.listRule,
deleteRule: c.deleteRule, fields: c.fields, viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
}); });
} else if (!hasClaimedField) { } else if (!hasClaimedField) {
console.log(` ↳ rewards.claimed already converted to status`); console.log(` ↳ rewards.claimed already converted to status`);
@@ -747,5 +968,201 @@ export async function migrate(): Promise<void> {
} }
} }
// ── 23. Create bonus_templates collection if missing ──
{
const existing = await getCollection("bonus_templates");
if (!existing) {
const famsCol = await getCollection("fams");
if (!famsCol) throw new Error("fams collection not found");
console.log("[migrate] Creating bonus_templates collection...");
await createCollection({
name: "bonus_templates",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "famId",
type: "relation",
required: true,
collectionId: famsCol.id,
maxSelect: 1,
cascadeDelete: false,
},
{ name: "name", type: "text", required: true },
{ name: "description", type: "text", required: false },
{
name: "target",
type: "select",
required: true,
values: ["individual", "competitive", "collaborative"],
maxSelect: 1,
},
{
name: "type",
type: "select",
required: true,
values: ["threshold", "count", "manual"],
maxSelect: 1,
},
{
name: "occurrence",
type: "select",
required: true,
values: ["recurring", "once"],
maxSelect: 1,
},
{
name: "rewardType",
type: "select",
required: true,
values: ["points", "cash", "prize"],
maxSelect: 1,
},
{ name: "rewardValue", type: "text", required: true },
{ name: "criteriaValue", type: "number", required: false },
{
name: "period",
type: "select",
required: false,
values: ["schedule", "daily", "weekly", "monthly"],
maxSelect: 1,
},
],
});
} else {
console.log(` ↳ bonus_templates already exists`);
}
}
// ── 24. Migrate phase='template' bonus_configs → bonus_templates, then delete originals ──
{
const configsCol = await getCollection("bonus_configs");
const templatesCol = await getCollection("bonus_templates");
if (configsCol && templatesCol) {
const t = await auth();
const all = await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` },
},
);
const data = await all.json();
const templateRecords = (data?.items || []).filter(
(r: any) => r.phase === "template",
);
if (templateRecords.length > 0) {
console.log(
`[migrate] Moving ${templateRecords.length} templates from bonus_configs → bonus_templates...`,
);
for (const rec of templateRecords) {
const {
id,
phase,
status,
completedAt,
memberId,
created,
updated,
collectionId,
expand,
...fields
} = rec;
await fetch(
`${PB_ENDPOINT}/api/collections/bonus_templates/records`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify(fields),
},
);
await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records/${id}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${t}` },
},
);
}
console.log(` ✓ Moved ${templateRecords.length} bonus templates`);
} else {
console.log(` ↳ no phase=template bonus_configs to migrate`);
}
}
}
// ── 25. Drop phase/completedAt from bonus_configs; status → active|completed ──
{
const c = await getCollection("bonus_configs");
if (c) {
const hasPhase = c.fields.some((f: any) => f.name === "phase");
const hasCompletedAt = c.fields.some(
(f: any) => f.name === "completedAt",
);
const statusField = c.fields.find((f: any) => f.name === "status");
const needsStatusUpdate =
statusField && !statusField.values.includes("completed");
if (hasPhase || hasCompletedAt || needsStatusUpdate) {
console.log(
"[migrate] Restructuring bonus_configs (drop phase/completedAt, status → active|completed)...",
);
if (statusField && needsStatusUpdate)
statusField.values = ["active", "completed"];
c.fields = c.fields.filter(
(f: any) => f.name !== "phase" && f.name !== "completedAt",
);
await updateCollection(c.id, {
name: "bonus_configs",
type: "base",
listRule: c.listRule,
viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
fields: c.fields,
});
const t = await auth();
const all = await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=1000`,
{
headers: { Authorization: `Bearer ${t}` },
},
);
const data = await all.json();
let updated = 0;
for (const rec of data?.items || []) {
const completed =
rec.phase === "completed" || rec.status === "archived";
const newStatus = completed ? "completed" : "active";
if (rec.status !== newStatus) {
await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ status: newStatus }),
},
);
updated++;
}
}
console.log(
` ✓ Updated ${updated} bonus_configs to status=active|completed`,
);
} else {
console.log(` ↳ bonus_configs already restructured`);
}
}
}
console.log("[migrate] Done"); console.log("[migrate] Done");
} }