410b8f8f6f
* fix(external-media): record capture dates on import, and backfill existing libraries (#1172) External imports never read EXIF, so photos.captured_at stayed NULL for every row they created. The gallery sorts "Date Taken" with COALESCE(captured_at, uploaded_at), which on a bulk import is the import timestamp — so the sort silently degraded into "order by import batch" with no error and nothing in the UI to say the sort key was missing. The reporter's 12-day trip came back with its first two days at positions 4204-5296 of 5555, because those folders happened to be imported second. - the import reads the capture date next to the sharp().metadata() call that already opens the file, so this costs one more read of the same source rather than a second pass over the mount. Best-effort like the dimensions: a source without EXIF imports with captured_at NULL, as before. - POST /api/admin/photos/repair-capture-dates backfills existing libraries, modelled on the dimension repair beside it — background pass, in-flight guard, status endpoint, and resolvePhotoFilePath, which is what reaches an external row at all. Not a migration: the originals sit on a mount that may be down at upgrade time, reading 8000+ of them would block the boot, and a run that found nothing has to be repeatable. - "no EXIF date" is counted separately from "could not read the file". An operator needs to tell "these files carry no date" from "the mount is broken" before deciding to re-run. - the update is guarded whereNull, so an import finishing mid-run is not overwritten by a slower pass. - every sort branch now carries photos.id as a tiebreaker, not just capture_date. A bulk import writes hundreds of rows inside one second, so uploaded_at and the COALESCE fallback both collapse and the grid reshuffles between loads. id is insertion order, which makes the fallback meaningful. Not addressed: extractCaptureDate reads no OffsetTimeOriginal, and exifr resolves a naive EXIF timestamp against the HOST timezone — so captured_at is not a true instant, and the same file imported on two machines yields two values. That predates this and applies to managed uploads equally; the tests here deliberately assert ordering rather than an absolute instant so they do not encode the bug. Worth its own issue. * fix(capture-dates): read managed originals through storage, skip archived, claim the run flag (#1172) Four holes in the backfill endpoint, all found in review: - Managed photos were resolved with resolvePhotoFilePath, which builds a STORAGE_PATH filesystem path. On an S3 install nothing is there, so every managed row failed. Now split the way the thumbnail regenerator does: external rows read from the mount directly, managed rows go through resolvePhotoStorageKey + withLocalCopy. - Archived events keep their photos rows but their originals are deleted on archive, so those rows failed every run and kept the button lit forever. Excluded from both the job and the status counts. - isRunning was claimed after the candidate query, so two concurrent POSTs could both pass the guard and start a pass. Claimed before the await, with every early exit releasing it. - The noExif comment promised a distinction extractCaptureDate does not make (it returns null for unreadable files too). Reworded to what it is. * chore: drop a stray node_modules symlink committed by mistake The .gitignore pattern is `node_modules/`, which matches a directory and not a symlink of the same name, so a local convenience link slipped past it. It pointed at an absolute path on one machine and would dangle everywhere else, breaking `cd backend && npm install`. * fix(capture-dates): gate the backfill as system maintenance, stop overstating the counters (#1172) The endpoint walks every event in the install and rewrites their metadata, but required only photos.edit — which the built-in team_photographer preset holds (175_granular_permissions_and_presets.js:106). That role exists for a contributing shooter, who should not be able to start a whole-library S3/NAS scan or touch another owner's photos. Now system.manage, with the status endpoint on system.view so the panel simply stays hidden for everyone else. The "without EXIF date" wording also promised a distinction the code does not draw: extractCaptureDate returns null for an unparseable file as well as for one that genuinely carries no date, so both land in that bucket. Reworded to "no date found" / "unreachable" in en, de and fr, which is what the two numbers actually separate. * docs: point the permission note at the follow-up PR (#1172) The dimension repair's matching gate landed in #1182, so the comment no longer needs to describe it as unaddressed. * fix(i18n): align the Slovenian capture-date wording with the other locales (#1172) sl was missed when the counters were reworded from 'without EXIF date' / 'unreadable' to what they actually measure. * fix(capture-dates): gate the status card on the permission the button needs (#1172) system.view and system.manage are independent grants, and StatusTab has no permission gate of its own — a successful status payload is what renders the card and its enabled button (StatusTab.tsx:637). Gating the status endpoint on system.view therefore handed a system.view-only role a live Backfill button whose every click 403s, with no error surfaced by the mutation. The comment above it already claimed this endpoint matched the POST. Now it does. * fix(gallery): make the Date Taken sort correct on SQLite (#1172) photos.captured_at does not hold one type on SQLite. Three writers put three different things in it: integer managed uploads — photoProcessor.js:488 hands knex a Date, which the sqlite3 binding stores as epoch milliseconds text external imports and the backfill, which write ISO-8601 null no capture date, so the sort falls through to uploaded_at, itself text in knex's 'YYYY-MM-DD HH:MM:SS' default shape A plain COALESCE over that is not an ordering. SQLite sorts INTEGER before TEXT unconditionally, so every managed photo carrying EXIF came back ahead of every photo that did not, whatever the dates said — a 2027 capture landing before a 2020 one. Among the text values 'T' (0x54) also outranks the space (0x20), so a same-day ISO 01:15 sorted behind a fallback 23:00. Both failures predate this branch — the first needs only two managed photos — but making that sort correct is what #1172 is about, so it is fixed here rather than left for the issue it belongs to. Normalised in the ORDER BY rather than by rewriting the column: the data fix would have to touch every existing row and every writer, which is a far heavier change than the sort it corrects. The cost is that this sort no longer uses idx_photos_captured_at on SQLite — an acceptable trade on the fallback engine, where the alternative is an index-assisted wrong answer. Postgres is untouched: captured_at is a real timestamp there and COALESCE already compares correctly. The regression tests drive the real gallery route on real SQLite. They write the epoch-millisecond integer directly, because the Date that produces it in production cannot be reproduced inside jest — there the binding's type dispatch misses sandbox Dates and stores "[object Object]" (CLAUDE.md). All four behavioural tests fail on the unfixed ORDER BY; verified by reverting it. * fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172) Two follow-ups from review. uploaded_at is not always text on SQLite either. A legacy archive restore leaves epoch milliseconds in it — there is a test pinning exactly that (__tests__/integration/sqliteEpochTimestamps.test.js) — and the fallback branch read it with substr(), so '1830297600000' was compared against '2020-01-01 00:00:00' as text and a 2028 upload sorted first. Both columns now get the integer/real branch. The status card also polled every ten seconds regardless of permission. With the endpoint correctly requiring system.manage, anyone who can open the Status tab but cannot run the job would have had a 403 and a logged denial every ten seconds for a panel they were never shown. The query is now gated on the same permission the endpoint requires, so it never starts. * style: quote convention in the capture-sort test (#1172) * fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172) Three follow-ups from review. fileWatcher.processNewPhoto sets type='video' and a video/* mime but never media_type (fileWatcher.js:128-130), so those rows keep the 'image' default from migration 048. Filtering on media_type alone queued every such video on every run — extractCaptureDate returns null for a video, captured_at stays null, and the backlog never cleared. Candidate query and status scope now check all three markers. The status counts were two separate queries, so an import committing a dated photo between them could be counted by the second and not the first: the card then showed withCaptureDate > total and a negative backlog, with the button enabled to "fix" it. One aggregate now. And the card's render checked only the cached payload. TanStack keeps that after `enabled` flips false, so a lower-privileged admin logging in behind a system.manage user inside the cache lifetime would still have seen the card and a button whose POST 403s. The permission is part of the render condition now. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
171 lines
7.6 KiB
JavaScript
171 lines
7.6 KiB
JavaScript
/**
|
|
* "Date Taken" ordering across SQLite's storage classes (#1172).
|
|
*
|
|
* photos.captured_at does not hold one type on SQLite. Three writers put three
|
|
* different things in it:
|
|
*
|
|
* integer managed uploads — photoProcessor.js:488 hands knex a Date, which
|
|
* the sqlite3 binding stores as epoch milliseconds
|
|
* text external imports and the capture-date backfill, which write
|
|
* ISO-8601 ('2026-06-03T01:15:00.000Z')
|
|
* null no capture date, so the sort falls through to uploaded_at —
|
|
* itself text, in knex's 'YYYY-MM-DD HH:MM:SS' shape
|
|
*
|
|
* A plain COALESCE over that mixture is not an ordering. SQLite sorts INTEGER
|
|
* before TEXT unconditionally, so every managed photo carrying EXIF came back
|
|
* ahead of every photo that did not, whatever the dates said. And among the
|
|
* text values 'T' (0x54) outranks the space (0x20), so a same-day ISO 01:15
|
|
* sorted behind a fallback 23:00.
|
|
*
|
|
* Both failures predate #1172 — the first needs only two managed photos — but
|
|
* the sort is what that issue is about, so they are fixed and pinned here.
|
|
* Every test below fails on the unfixed ORDER BY.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-capsort-')), 'db.sqlite',
|
|
);
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capsort-test-secret';
|
|
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-capsort-storage-'));
|
|
|
|
const request = require('supertest');
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
|
|
|
const SLUG = 'capsort-gallery';
|
|
|
|
describe('capture-date ordering on SQLite (#1172)', () => {
|
|
let db; let cleanup; let app; let eventId;
|
|
|
|
// Managed uploads store an epoch-millisecond INTEGER, because
|
|
// photoProcessor.js:488 hands knex a Date and the sqlite3 binding converts
|
|
// it. That conversion cannot be reproduced from inside jest — there the
|
|
// binding's type dispatch misses sandbox-created Dates and writes the string
|
|
// "[object Object]" instead (CLAUDE.md). Verified outside jest: a Date lands
|
|
// as {"c":1830211200000,"ty":"integer"}. So these tests write the integer
|
|
// production would have written, rather than a Date that jest mangles.
|
|
const managed = (iso) => new Date(iso).getTime();
|
|
|
|
const addPhoto = async (filename, capturedAt, uploadedAt) => {
|
|
const row = await db('photos').insert({
|
|
event_id: eventId,
|
|
filename,
|
|
path: `${SLUG}/${filename}`,
|
|
type: 'individual',
|
|
captured_at: capturedAt,
|
|
uploaded_at: uploadedAt,
|
|
}).returning('id');
|
|
return row[0]?.id ?? row[0];
|
|
};
|
|
|
|
const orderedFilenames = async (order = 'asc') => {
|
|
const res = await request(app).get(`/api/gallery/${SLUG}/photos?sort=capture_date&order=${order}`);
|
|
expect(res.status).toBe(200);
|
|
return res.body.photos.map((p) => p.filename);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
await seedMinimal(db);
|
|
|
|
const ev = await db('events').insert({
|
|
slug: SLUG,
|
|
event_type: 'wedding',
|
|
event_name: 'Capture Sort',
|
|
event_date: '2026-08-01',
|
|
host_email: 'h@example.com',
|
|
admin_email: 'a@example.com',
|
|
password_hash: 'x',
|
|
share_link: `/gallery/${SLUG}/s`,
|
|
share_token: 'capsort-share',
|
|
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
is_active: 1,
|
|
is_archived: 0,
|
|
is_draft: 0,
|
|
require_password: 0,
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
eventId = ev[0]?.id ?? ev[0];
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/api/gallery', require('../../src/routes/gallery'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => { if (cleanup) await cleanup(); });
|
|
|
|
beforeEach(async () => { await db('photos').where({ event_id: eventId }).del(); });
|
|
|
|
test('the fixture really does put three storage classes in one column', async () => {
|
|
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
|
|
await addPhoto('m.jpg', managed('2026-06-03T01:15:00Z'), '2026-01-01 00:00:00');
|
|
await addPhoto('e.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
|
|
await addPhoto('n.jpg', null, '2026-01-01 00:00:00');
|
|
|
|
const rows = await db.raw('select filename, typeof(captured_at) as t from photos order by filename');
|
|
const byName = Object.fromEntries((rows.rows || rows).map((r) => [r.filename, r.t]));
|
|
// Exactly the mixture that made COALESCE meaningless.
|
|
expect(byName).toEqual({ 'm.jpg': 'integer', 'e.jpg': 'text', 'n.jpg': 'null' });
|
|
});
|
|
|
|
test('a managed EXIF date does not outrank an earlier one stored as text', async () => {
|
|
// The pre-existing failure, reachable with managed photos alone: integer
|
|
// beat text regardless of the dates, so this came back exactly reversed.
|
|
await addPhoto('managed-2027.jpg', managed('2027-12-31T00:00:00Z'), '2026-01-01 00:00:00');
|
|
await addPhoto('external-2020.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
|
|
|
|
expect(await orderedFilenames('asc')).toEqual(['external-2020.jpg', 'managed-2027.jpg']);
|
|
expect(await orderedFilenames('desc')).toEqual(['managed-2027.jpg', 'external-2020.jpg']);
|
|
});
|
|
|
|
test('a photo with no capture date sorts by its upload time, not ahead of everything', async () => {
|
|
await addPhoto('has-exif-2027.jpg', managed('2027-12-31T00:00:00Z'), '2027-12-31 00:00:00');
|
|
await addPhoto('no-exif-2020.jpg', null, '2020-01-01 00:00:00');
|
|
|
|
expect(await orderedFilenames('asc')).toEqual(['no-exif-2020.jpg', 'has-exif-2027.jpg']);
|
|
});
|
|
|
|
test('an ISO capture time and a fallback upload time compare by clock, not by separator', async () => {
|
|
// Same day: 'T' vs ' ' decided this before, so 01:15 sorted after 23:00.
|
|
await addPhoto('iso-0115.jpg', '2026-06-03T01:15:00.000Z', '2026-06-03 05:00:00');
|
|
await addPhoto('fallback-2300.jpg', null, '2026-06-03 23:00:00');
|
|
|
|
expect(await orderedFilenames('asc')).toEqual(['iso-0115.jpg', 'fallback-2300.jpg']);
|
|
});
|
|
|
|
test('an epoch-integer uploaded_at is compared as a date, not as its digits', async () => {
|
|
// uploaded_at is not always text either: a legacy archive restore leaves
|
|
// epoch milliseconds in it (__tests__/integration/sqliteEpochTimestamps.js).
|
|
// Reading that with substr() would have compared the string '1830297600000'
|
|
// against '2020-01-01 00:00:00', putting the 2028 row first.
|
|
await addPhoto('epoch-upload-2028.jpg', null, new Date('2028-01-01T00:00:00Z').getTime());
|
|
await addPhoto('captured-2020.jpg', managed('2020-01-01T00:00:00Z'), '2020-01-01 00:00:00');
|
|
|
|
const [row] = await db.raw('select typeof(uploaded_at) as t from photos where filename = \'epoch-upload-2028.jpg\'');
|
|
expect((row.t || row).toString()).toBe('integer');
|
|
|
|
expect(await orderedFilenames('asc')).toEqual(['captured-2020.jpg', 'epoch-upload-2028.jpg']);
|
|
});
|
|
|
|
test('all three storage classes order together correctly', async () => {
|
|
await addPhoto('c-managed-2026-08.jpg', managed('2026-08-15T12:00:00Z'), '2026-09-01 00:00:00');
|
|
await addPhoto('a-external-2026-06.jpg', '2026-06-03T01:15:00.000Z', '2026-09-01 00:00:00');
|
|
await addPhoto('d-fallback-2026-09.jpg', null, '2026-09-01 00:00:00');
|
|
await addPhoto('b-managed-2026-07.jpg', managed('2026-07-04T09:30:00Z'), '2026-09-01 00:00:00');
|
|
|
|
expect(await orderedFilenames('asc')).toEqual([
|
|
'a-external-2026-06.jpg',
|
|
'b-managed-2026-07.jpg',
|
|
'c-managed-2026-08.jpg',
|
|
'd-fallback-2026-09.jpg',
|
|
]);
|
|
});
|
|
});
|