Files
picpeak/backend/src/utils/dateNormalize.js
T
Paul Nothaft c6ec93eef9 fix(dates): normalize SQLite epoch timestamps at remaining API surfaces (#485 follow-up) (#857)
The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.

Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.

- extract toIso() from adminUsers.js into utils/dateNormalize.js
  (contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
  revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
  uploaded_at/captured_at; api-tokens list normalizes all four
  timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
  tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
  serves ISO strings

activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-22 21:07:32 +02:00

37 lines
1.6 KiB
JavaScript

/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres → Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite → integer milliseconds since epoch when a raw `new Date()` was
* written through knex (the surface that crashed the admin Users page
* in #485 — `parseISO(123456789)` blows up with "e.split is not a
* function"). Native installs default to SQLite, so this path matters
* every release.
* Already a string → assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset value surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*
* Extracted from routes/adminUsers.js (#485) so every route that
* serializes timestamps can share one contract.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
module.exports = { toIso };