|
2 | 2 |
|
3 | 3 | import { HookContext, Hook } from '@objectstack/spec/data'; |
4 | 4 |
|
| 5 | +/** |
| 6 | + * Lifecycle logic for `todo_task` — insert defaults and the completion stamp. |
| 7 | + * |
| 8 | + * ## `ctx.input` is an ENVELOPE, not the record (#7036) |
| 9 | + * |
| 10 | + * The engine builds one context shape per event and the record is always a |
| 11 | + * slot inside it, never the context's own keys: |
| 12 | + * |
| 13 | + * - `beforeInsert` → `{ data, options }` (one context per row) |
| 14 | + * - `beforeUpdate` → `{ id, data, options }` |
| 15 | + * - `afterUpdate` → `{ id, data, options }` |
| 16 | + * |
| 17 | + * (the contract table on `HookContextSchema.input`, pinned against the real |
| 18 | + * engine in `packages/objectql/src/hook-input-shape-contract.test.ts`). So the |
| 19 | + * record lives at `ctx.input.data`; writing `ctx.input.priority` sets a key on |
| 20 | + * the envelope that no write path ever reads. |
| 21 | + * |
| 22 | + * ## Why the completion stamp is written HERE, and unconditionally |
| 23 | + * |
| 24 | + * `completed_date` is `readonly: true` — a server-owned column. On the update |
| 25 | + * path the engine strips a non-system caller's write to such a column, and |
| 26 | + * `todo_task`'s own `completed_date_required` rule then refused the write for |
| 27 | + * missing exactly the value it had just dropped. That made "Complete task" |
| 28 | + * impossible for an ordinary user (#7036). |
| 29 | + * |
| 30 | + * A hook stamp is the platform's answer, and it works because the strip is |
| 31 | + * deliberately narrow: it runs AFTER the before-hooks and only deletes a key |
| 32 | + * that both (a) the caller supplied and (b) still holds *the caller's own |
| 33 | + * value* (#2948 + #5591 — `stripReadonlyFields`). A value a hook wrote is a |
| 34 | + * platform value and survives. |
| 35 | + * |
| 36 | + * That is also why the stamp below is UNCONDITIONAL rather than |
| 37 | + * `data.completed_date ??= …`: if a caller supplied the key and the hook left |
| 38 | + * it alone, the value would still be the caller's, the strip would delete it, |
| 39 | + * and the rule would refuse the write again — the original bug, restored. The |
| 40 | + * server owning the column means the server writes it on every transition. |
| 41 | + * |
| 42 | + * ## Leaving `completed` clears the stamp |
| 43 | + * |
| 44 | + * Reopening a completed task (completed → in_progress, …) nulls |
| 45 | + * `completed_date`. The field means "when this task was completed", so a task |
| 46 | + * that is not completed must not carry one; retaining it would leave a stale |
| 47 | + * timestamp that every report and list view reads as fact. The same |
| 48 | + * `readonly`/strip reasoning applies — only a hook can write the clear. |
| 49 | + */ |
5 | 50 | const taskHook: Hook = { |
6 | 51 | name: 'task_logic', |
7 | 52 | object: 'todo_task', |
8 | | - events: ['beforeInsert', 'afterUpdate'], |
| 53 | + events: ['beforeInsert', 'beforeUpdate', 'afterUpdate'], |
9 | 54 | handler: async (ctx: HookContext) => { |
| 55 | + const data = (ctx.input as { data?: Record<string, unknown> }).data; |
| 56 | + const previous = ctx.previous as Record<string, unknown> | undefined; |
| 57 | + if (!data) return; |
| 58 | + |
10 | 59 | if (ctx.event === 'beforeInsert') { |
11 | | - const { input } = ctx; |
12 | 60 | // Default priority |
13 | | - if (!input.priority) { |
14 | | - input.priority = 'normal'; |
| 61 | + if (!data.priority) { |
| 62 | + data.priority = 'normal'; |
15 | 63 | } |
16 | 64 | // Default status |
17 | | - if (!input.status) { |
18 | | - input.status = 'not_started'; |
| 65 | + if (!data.status) { |
| 66 | + data.status = 'not_started'; |
19 | 67 | } |
20 | 68 | // Validation |
21 | | - if (typeof input.subject === 'string' && input.subject.includes('spam')) { |
| 69 | + if (typeof data.subject === 'string' && data.subject.includes('spam')) { |
22 | 70 | throw new Error('Spam tasks are not allowed'); |
23 | 71 | } |
24 | 72 | } |
25 | | - |
| 73 | + |
| 74 | + if (ctx.event === 'beforeUpdate') { |
| 75 | + // The transition INTO completed — `previous` is the engine's pre-update |
| 76 | + // snapshot, bound before this hook runs, so "is this a transition?" is |
| 77 | + // answerable here without a read of our own. |
| 78 | + if (data.status === 'completed' && previous?.status !== 'completed') { |
| 79 | + data.completed_date = new Date().toISOString(); |
| 80 | + } |
| 81 | + // ...and the transition back OUT of it. Guarded on `status` actually |
| 82 | + // being part of this write: an unrelated edit of a completed task |
| 83 | + // (`{ progress_percent: 100 }`) carries no `status` and must not be read |
| 84 | + // as a reopen. |
| 85 | + else if ( |
| 86 | + data.status !== undefined && |
| 87 | + data.status !== 'completed' && |
| 88 | + previous?.status === 'completed' |
| 89 | + ) { |
| 90 | + data.completed_date = null; |
| 91 | + } |
| 92 | + } |
| 93 | + |
26 | 94 | if (ctx.event === 'afterUpdate') { |
| 95 | + // The kernel's logger, reached through the engine handle the context |
| 96 | + // carries. Not `console`: a hook runs inside the server, so its output |
| 97 | + // belongs on the platform logger, which honours the kernel's configured |
| 98 | + // level (a test booting `{ logger: { level: 'silent' } }` stays silent). |
| 99 | + // `ctx.ql` is declared `unknown` on `HookContextSchema`, hence the cast. |
| 100 | + const logger = (ctx.ql as { logger?: { info?: (message: string) => void } } | undefined)?.logger; |
| 101 | + |
27 | 102 | // Check if completed |
28 | | - if (ctx.input.status === 'completed' && ctx.previous && ctx.previous.status !== 'completed') { |
29 | | - console.log(`Task ${ctx.id} completed by ${ctx.session?.userId || 'unknown'}`); |
| 103 | + if (data.status === 'completed' && previous && previous.status !== 'completed') { |
| 104 | + logger?.info?.(`Task ${ctx.input.id} completed by ${ctx.session?.userId || 'unknown'}`); |
30 | 105 | // Could trigger notifications or integrations here |
31 | 106 | } |
32 | | - |
| 107 | + |
33 | 108 | // Check if task became overdue |
34 | | - if (ctx.input.is_overdue && ctx.previous && !ctx.previous.is_overdue) { |
35 | | - console.log(`Task ${ctx.id} is now overdue`); |
| 109 | + if (data.is_overdue && previous && !previous.is_overdue) { |
| 110 | + logger?.info?.(`Task ${ctx.input.id} is now overdue`); |
36 | 111 | } |
37 | 112 | } |
38 | 113 | } |
|
0 commit comments