From 6185cf96b6525bb0a9530c0b27a15c17c2a17e3c Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 18 Aug 2026 20:08:29 +0200 Subject: [PATCH] feat(photos): record when and where a photo was taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/src/db/migrations.ts | 15 +++ .../src/nest/photos/trek-photos.repository.ts | 30 ++++++ .../unit/nest/trek-photos.repository.test.ts | 95 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 server/tests/unit/nest/trek-photos.repository.test.ts diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 0e55ccdfc..cd87deb13 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/photos/trek-photos.repository.ts b/server/src/nest/photos/trek-photos.repository.ts index 0fb1f96ae..593f8812b 100644 --- a/server/src/nest/photos/trek-photos.repository.ts +++ b/server/src/nest/photos/trek-photos.repository.ts @@ -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. diff --git a/server/tests/unit/nest/trek-photos.repository.test.ts b/server/tests/unit/nest/trek-photos.repository.test.ts new file mode 100644 index 000000000..11bf8dabf --- /dev/null +++ b/server/tests/unit/nest/trek-photos.repository.test.ts @@ -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 }); + }); +});