feat(photos): record when and where a photo was taken

The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
This commit is contained in:
Maurice
2026-08-18 20:08:29 +02:00
committed by Maurice
parent 3e4b36ac71
commit 6185cf96b6
3 changed files with 140 additions and 0 deletions
+15
View File
@@ -3955,6 +3955,21 @@ function runMigrations(db: Database.Database): void {
upsert.run(column, row.value);
}
},
// Capture metadata on the photo itself (#1614): when it was taken and where.
// Both are needed before photos can sit on the Journey map by their own
// coordinates, and before a gallery can be ordered by when a picture was
// taken rather than when it happened to be added. Nullable throughout —
// most providers answer with neither, and a photo without them is normal.
// Guarded so re-running the migration tail is a no-op.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>;
const has = (name: string) => cols.some(c => c.name === name);
if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT');
if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL');
if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL');
// Answering "which photos of this journey have coordinates" without a scan.
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL');
},
];
if (currentVersion < migrations.length) {
@@ -89,6 +89,36 @@ export class TrekPhotosRepository {
);
}
/**
* Where and when the picture was taken (#1614).
*
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
* three sources disagree in how much they know. A provider search answers with
* coordinates, the same provider's album listing does not, and a local file
* only gives them up once its EXIF has been read. Whichever arrives first wins,
* and a later, emptier answer must not blank what is already there.
*
* A photo with neither is the normal case, not a failure.
*/
recordCaptureMetadata(
photoId: number,
meta: { takenAt?: string | null; lat?: number | null; lng?: number | null },
): void {
const { takenAt = null, lat = null, lng = null } = meta;
if (takenAt == null && lat == null && lng == null) return;
// Coordinates are stored as a pair or not at all — a lone latitude is not a
// place, and half a pair would put the photo on the null island.
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
this.db.run(
`UPDATE trek_photos
SET taken_at = COALESCE(taken_at, ?),
lat = COALESCE(lat, ?),
lng = COALESCE(lng, ?)
WHERE id = ?`,
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
);
}
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
@@ -0,0 +1,95 @@
/**
* TrekPhotosRepository — capture metadata (#1614).
*
* The three sources of a photo's "when and where" disagree in how much they know:
* a provider search answers with coordinates, the same provider's album listing
* does not, and a local file gives them up only once its EXIF has been read. These
* pin the merge rule that keeps a later, emptier answer from erasing an earlier one.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => undefined, isOwner: () => false } };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
let repo: TrekPhotosRepository;
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
repo = new TrekPhotosRepository(new DatabaseService(testDb));
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// A fresh path per call: getOrCreateLocal is keyed on file_path and resetTestDb
// leaves trek_photos alone, so a shared path would hand every test the same row.
let seq = 0;
function makePhoto(): number {
return repo.getOrCreateLocal(`/uploads/journey/${++seq}.jpg`, null, null, null, 'image', null);
}
function read(id: number) {
return testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE id = ?').get(id) as {
taken_at: string | null; lat: number | null; lng: number | null;
};
}
describe('TrekPhotosRepository.recordCaptureMetadata', () => {
it('TREKPHOTO-001: stores the capture time and coordinates', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-002: a later, emptier answer does not erase what is known', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
// The album listing knows the date but not the place.
repo.recordCaptureMetadata(id, { takenAt: '2020-01-01T00:00:00Z', lat: null, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-003: fills only the half that was still missing', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z' });
repo.recordCaptureMetadata(id, { lat: 48.8584, lng: 2.2945 });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
it('TREKPHOTO-004: refuses half a coordinate pair rather than landing on null island', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: null });
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: null, lng: null });
});
it('TREKPHOTO-005: an answer with nothing in it touches no row', () => {
const id = makePhoto();
repo.recordCaptureMetadata(id, { takenAt: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
repo.recordCaptureMetadata(id, {});
expect(read(id)).toEqual({ taken_at: '2026-03-15T10:20:00Z', lat: 48.8584, lng: 2.2945 });
});
});