make major bonus updates
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
Vendored
+2
@@ -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
@@ -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));
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+748
-212
File diff suppressed because it is too large
Load Diff
+564
-147
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user