-
Notifications
You must be signed in to change notification settings - Fork 3
ADR 022 Diary Drafts
Accepted
Issue #1426 reported that diary photos attached on the create form (DiaryEntryCreatePage) are buffered in component state and only uploaded after the entry is POSTed. If any subsequent upload fails (offline, server error, tab closed) the user's photos are silently lost, and the original File objects are no longer accessible. Several users have lost on-site photos this way that cannot be retaken.
The agreed remedy (issue #1426 acceptance criteria) is to flip the create flow so a diary entry exists server-side before any photo is uploaded:
- Auto-create a server-side draft entry on first meaningful interaction.
- Upload photos immediately to the draft via the existing
POST /api/photosendpoint. - Auto-save subsequent field edits to the draft.
- Promote the draft to a saved entry when the user clicks Save (and validation passes).
- Drafts are visible in the diary list with a clear badge, but excluded from automatic aggregations, the dashboard "Recent diary" tile, and any export workflows.
The edit flow already does the right thing (it uploads photos immediately to an existing entry id) and is out of scope.
We need a model for representing "draft" entries that:
- Preserves a single id across draft → saved (so already-uploaded photos do not need re-attachment).
- Reuses the existing photo cascade,
document_links, polymorphic source-entity references, and auto-event services unchanged. - Minimises divergence in the CRUD endpoints (the bug fix should not require a parallel API surface).
- Lets us relax validation for draft writes without contaminating the saved-entry contract.
- Allows a small, focused migration (this is a bug fix, not a new epic).
diary_entries.status TEXT NOT NULL DEFAULT 'saved' CHECK(status IN ('draft', 'saved'))
- Default is
'saved'so all existing rows (created before this migration) remain in their current "fully validated" state with zero data migration risk. - A separate
diary_draftstable was rejected because promoting a draft to a saved entry would either (a) reuse the draft id and require migrating photos and document links across tables, or (b) issue a new id and require updating every photo and document link that already references the draft. Both are operationally awkward and create a window where the entry is neither in one table nor the other. - A status column keeps the single-id invariant, lets
photos.entity_idanddocument_links.entity_idcontinue to referencediary_entries.idunchanged, and means the cascade delete on draft discard works through the same path as a normal entry delete.
POST /api/diary-entries accepts an optional status: 'draft' | 'saved' (default 'saved', backward-compatible). When status === 'draft':
-
bodyis optional and may be empty. -
entryDateis optional; if omitted, the server defaults it to today (new Date().toISOString().split('T')[0]). -
entryTyperemains required (drafts are pinned to a type at creation — see decision #7). - Type-specific metadata fields (e.g.,
inspectorNameforsite_visit) are not validated. - Metadata shape validation (e.g.,
weathermust be an enum value) is still enforced — we only relax presence requirements, not data integrity.
PATCH /api/diary-entries/:id accepts the same relaxed validation when the entry is status === 'draft'. A new PATCH /api/diary-entries/:id/promote endpoint runs the full saved-entry validation and flips status to 'saved' atomically. Promoting an entry that fails validation returns 400 VALIDATION_ERROR with field-level details and leaves the entry as a draft.
This keeps the API surface narrow (one new endpoint, one new optional field on existing endpoints). A separate POST /api/diary-entries/draft was rejected because it would duplicate the entire JSON schema, the metadata validator branch table, and the controller — for a bug fix.
-
Text inputs (
title,body, free-text metadata fields): debounced on blur with a 1-second safety debounce after typing stops to catch users who tab away quickly. - Selects, checkboxes, signature add/remove, photo attach/remove: immediate (no debounce) — these are discrete user actions that the user expects to "stick" right away.
- The client coalesces concurrent in-flight PATCHes against the same draft id (cancel previous, send latest). This is implemented in the frontend draft hook; the server treats each PATCH independently.
- A
node-cronjob (the dependency is already in the tree, used by the backup scheduler) runs daily at03:00server time. Implementation reuses the same lifecycle pattern asbackupService.initializeBackupScheduler/stopScheduler. - The job deletes diary entries where
status = 'draft' AND updated_at < datetime('now', '-30 days'). - Cascade: each deleted draft also deletes its attached photos (via
deletePhotosForEntity, same asdeleteDiaryEntrydoes today). - Configurable via
DIARY_DRAFT_RETENTION_DAYSenv var (default30). Setting it to0disables the cleanup job (useful for tests). - Hard delete (not soft delete) — drafts have no audit value once abandoned and disk space is the dominant concern.
PO leaning was 90 days. 30 days is a tighter default chosen because (a) construction-site photos are large and a draft can accumulate dozens of them and (b) a draft sitting untouched for a month is almost certainly abandoned. The env var lets self-hosters extend it.
-
GET /api/diary-entriesreturns drafts and saved entries by default (no breaking change in response shape; clients seestatus: 'draft' | 'saved'on each item). - A new query parameter
status=draft|savedfilters one or the other. Omitting it returns both. - The diary timeline UI renders drafts with a "Draft" badge using the existing
Badgeshared component (variant="warning"or equivalent — UX designer to confirm). - Clicking a draft in the list navigates to
/diary/<id>/edit(the same route as a saved entry — the form decides whether to show "Save" or "Save changes" based onstatus).
PO leaning was "included by default", which matches the diary-list-as-inbox model.
Drafts are work-in-progress and must not leak into aggregations, automatic-event chronology, dashboard tiles, or report-like surfaces. Concrete read paths to filter:
| Read path | Location | Filter |
|---|---|---|
| Dashboard "Recent diary" tile |
client/src/pages/DashboardPage/DashboardPage.tsx calls listDiaryEntries
|
Pass status: 'saved' query parameter |
| Diary list when explicitly filtered to saved | GET /api/diary-entries?status=saved |
Server enforces |
| Auto-event chronology (future PDF/report endpoints) | Any new listDiaryEntries-style queries from reporting code |
Default status = 'saved' unless caller opts in |
| Signed-document workflows | A draft can't be signed today (no signature path in create flow); add a defensive WHERE status = 'saved' to any future "find signed entries" query |
Defensive |
| Source-entity backlink listing (future "diary for this work item" UI) | Any new "find diary entries for entity X" service | Filter to status = 'saved' when surfacing to other entities' detail pages |
There is no PDF export endpoint today (it was removed during EPIC-13 UAT — confirmed in server/src/routes/diary.test.ts:473-490). When/if it is re-added, it must filter to status = 'saved' by default.
The base GET /api/diary-entries endpoint does not filter by status by default — the diary list is the user's draft inbox. Callers that want only saved entries pass status=saved explicitly.
- The
entryTypeis required at draft creation and cannot be changed viaPATCH. The existingupdateDiaryEntryservice already rejectsentryTypechanges (it is not in the update schema). This behaviour extends to drafts unchanged. - If the user wants to switch types, they must discard the draft and start a new one. This is enforced by UI (the type selector is read-only on a draft) and by the API (
entryTypeis not inUpdateDiaryEntryRequest). - Rationale: type-specific metadata fields differ across types, and a type change could silently invalidate or strand metadata and photos. The discard-and-restart cost is small and matches the PO leaning.
- This is a 1-5 user self-hosted product. The realistic concurrency case is "the same user opens the same draft on a phone and a laptop" — not a true multi-user collaboration problem.
- No version column, no
If-Matchheader, no conflict detection. Every PATCH overwrites the prior state for the fields it touches. The server returns the resulting entry; the client reconciles its local state to the response. - If users repeatedly report lost edits from cross-device concurrency, revisit with a
versioninteger column and412 Precondition Failedsemantics. Not built today to keep the bug fix focused.
- Client-side queue with a concurrency limit of
3parallelPOST /api/photosrequests; remaining photos wait. Implemented inside the existingPhotoUploadcomponent (or a newPhotoUploadQueuewrapper that owns the queue). - Per-photo state:
queued | uploading | succeeded | failed. The originalFileis retained in client memory for failed photos so the user can click "Retry" or "Remove" — that is the core fix for issue #1426. - A failure on one photo does not block other queued photos.
- Failures are surfaced inline next to each photo (per-photo error UI), not as a single toast.
- 3 parallel uploads is a reasonable balance: enough to feel responsive on a 5G/wifi connection, low enough not to saturate a slow upload pipe with 30 simultaneous large requests.
-
/diary/newis a thin shell that renders the type selector. Once the user picks a type and makes a first interaction that creates the draft, the page callsnavigate('/diary/<id>/edit', { replace: true }). - Using
replace: true(notpush) so the back button takes the user to/diary, not back to/diary/new(which would otherwise show the type selector again). - The existing
DiaryEntryEditPagebecomes the host for both saved entries and drafts. The page readsentry.statusand shows the appropriate buttons (Saveto promote a draft vsSave changesfor a saved entry;Discard draftvsDelete entry). - No new route is introduced (no
/diary/<id>/draft) — drafts and saved entries are the same resource at the same URL, differentiated by theirstatusfield.
A new migration 0033_diary_entry_status.sql adds the status column with default 'saved' and indexes draft-filtering queries:
ALTER TABLE diary_entries ADD COLUMN status TEXT NOT NULL DEFAULT 'saved'
CHECK(status IN ('draft', 'saved'));
CREATE INDEX idx_diary_entries_status_updated
ON diary_entries (status, updated_at)
WHERE status = 'draft';The partial index supports the orphan cleanup query (WHERE status = 'draft' AND updated_at < ?) without indexing the much larger saved-entry set. Backfill is trivial — DEFAULT 'saved' applies to all existing rows during the ALTER.
- Photos are durable from the moment they leave the client. The class of bug described in #1426 disappears.
- The create and edit flows converge on a single page (
DiaryEntryEditPage) — the create page becomes a thin type-selector shell that auto-creates a draft and forwards. - The single-table-with-status model means zero new ORM relationships, no id remapping, no parallel API surface.
- Reusing
node-cron(already in the dependency tree) for cleanup adds no new dependencies. - The 30-day cleanup window is configurable per deployment via env var.
- Every read path that surfaces diary entries outside the timeline must remember to filter by
status = 'saved'. We mitigate this with: (a) an explicit checklist in this ADR, (b) a service-level helper (listSavedDiaryEntries) wrappinglistDiaryEntrieswith the filter baked in for any non-timeline caller, and (c) review-time vigilance on any new code that touchesdiaryEntriesfor reporting purposes. - The frontend must implement an auto-save hook, an upload queue with per-photo state, and a "draft promoted to saved" UI transition. This is non-trivial frontend work but contained to two files (
DiaryEntryCreatePageandDiaryEntryEditPage). - The
POST /api/diary-entriesandPATCH /api/diary-entries/:idendpoints become slightly more complex: their validation branches onstatus. We accept this as the cost of avoiding endpoint duplication. - Last-write-wins concurrency means the rare cross-device edit conflict silently overwrites. Documented as a known limitation; we revisit if users complain.
- If we later need true multi-user collaboration on diary entries, add a
versioninteger column andIf-Match/412semantics — the status column model does not block this. - If we re-introduce a PDF export endpoint, it must filter to
status = 'saved'by default (called out in this ADR). - If draft volume becomes a problem before the 30-day cleanup runs (very heavy use of the create form), we can shorten the default retention without a schema change.
- Issue #1426 — bug report
- ADR-020 (Construction Diary Architecture) — the model this ADR extends
- ADR-019 (Discretionary Funding) — precedent for adding a
statuscolumn with a backward-compatible default -
server/src/services/backupService.ts—node-cronlifecycle pattern reused for the orphan cleanup job