From 97220b72f66a901e333bfb39bb3dd8cf44b471ac Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 18 Aug 2026 20:23:46 +0200 Subject: [PATCH] feat(journey): carry capture metadata into the payload, and keep it behind share_map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gallery rows now hand the client taken_at, lat and lng, which is what the map and a capture-ordered gallery will read. The coordinates needed a projection before they could ship. A photo's capture location is a place the owner never typed and may not expect to publish, so it follows share_map — the same switch the entry coordinates already follow. Without that, turning the map off and leaving the gallery on would have handed out exactly the locations the owner had just withheld. The capture time is not a location and stays either way; a blog-style view sorts by it. --- .../nest/journey/journey-domain.service.ts | 4 +- .../src/nest/journey/journey-share.service.ts | 16 ++++-- .../unit/nest/journey-share.service.test.ts | 49 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 3f4967580..6b7f74bdb 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -14,7 +14,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository'; const JP_SELECT = ` gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, - tp.media_type, tp.duration_ms + tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng `; const JP_JOIN = `journey_entry_photos jep @@ -24,7 +24,7 @@ const JP_JOIN = `journey_entry_photos jep const GALLERY_SELECT = ` gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height, - tp.media_type, tp.duration_ms + tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng `; const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id'; diff --git a/server/src/nest/journey/journey-share.service.ts b/server/src/nest/journey/journey-share.service.ts index 2aefb766d..c75519626 100644 --- a/server/src/nest/journey/journey-share.service.ts +++ b/server/src/nest/journey/journey-share.service.ts @@ -130,7 +130,7 @@ export class JourneyShareService { const photos = this.db.prepare(` SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at, tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height, - tkp.media_type, tkp.duration_ms + tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng FROM journey_entry_photos jep JOIN journey_photos gp ON gp.id = jep.journey_photo_id JOIN trek_photos tkp ON tkp.id = gp.photo_id @@ -146,7 +146,7 @@ export class JourneyShareService { const gallery = this.db.prepare(` SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at, tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height, - tkp.media_type, tkp.duration_ms + tkp.media_type, tkp.duration_ms, tkp.taken_at, tkp.lat, tkp.lng FROM journey_photos gp JOIN trek_photos tkp ON tkp.id = gp.photo_id WHERE gp.journey_id = ? @@ -183,6 +183,7 @@ export class JourneyShareService { const projected: Record = { ...e }; if (!shareMap) { projected.location_lat = null; projected.location_lng = null; } if (!shareGallery) projected.photos = []; + else if (!shareMap) projected.photos = stripPhotoGps(e.photos); return projected; }); } else if (shareMap) { @@ -208,7 +209,11 @@ export class JourneyShareService { status: journey.status, }, entries: publicEntries, - gallery: shareGallery ? gallery : [], + // A photo now carries the coordinates it was taken at, which is a location the + // owner never typed and may not expect to publish. It follows share_map, the + // same switch the entry coordinates follow — otherwise a gallery-only share + // would hand out places the map was deliberately turned off for. + gallery: shareGallery ? (shareMap ? gallery : stripPhotoGps(gallery)) : [], stats, permissions: { share_timeline: shareTimeline, @@ -218,3 +223,8 @@ export class JourneyShareService { }; } } + +/** Drop capture coordinates from a photo list, keeping everything else. */ +function stripPhotoGps(photos: T[] | undefined | null): T[] { + return (photos ?? []).map(p => ({ ...(p as Record), lat: null, lng: null })) as T[]; +} diff --git a/server/tests/unit/nest/journey-share.service.test.ts b/server/tests/unit/nest/journey-share.service.test.ts index 0f48104a0..005f946ef 100644 --- a/server/tests/unit/nest/journey-share.service.test.ts +++ b/server/tests/unit/nest/journey-share.service.test.ts @@ -495,6 +495,55 @@ describe('getPublicJourney', () => { expect(e.story).toBeUndefined(); // narrative withheld }); + // #1614 — a photo now carries the coordinates it was taken at. That is a place + // the owner never typed, so it has to follow the same switch the entry + // coordinates follow rather than riding in on the gallery flag. + it('JOURNEY-SHARE-025: withholds photo capture coordinates when the map flag is off', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { + type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', + }); + const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?') + .run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId); + + const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + share_timeline: true, share_gallery: true, share_map: false, + }); + + const result = svc.getPublicJourney(token)!; + const gallery = result.gallery as Record[]; + expect(gallery).toHaveLength(1); + expect(gallery[0].lat).toBeNull(); + expect(gallery[0].lng).toBeNull(); + // The capture time is not a location and stays — it is what a blog view sorts by. + expect(gallery[0].taken_at).toBe('2026-05-01T10:00:00Z'); + + const inline = (result.entries[0] as Record).photos as Record[]; + expect(inline[0].lat).toBeNull(); + expect(inline[0].lng).toBeNull(); + }); + + it('JOURNEY-SHARE-026: hands out photo coordinates once the map is shared', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { + type: 'entry', title: 'Day 1', entry_date: '2026-05-01', + }); + const trekId = insertJourneyPhoto(entry.id, { ownerId: user.id }); + testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?') + .run(48.8584, 2.2945, trekId); + + const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { + share_timeline: true, share_gallery: true, share_map: true, + }); + + const gallery = svc.getPublicJourney(token)!.gallery as Record[]; + expect(gallery[0].lat).toBe(48.8584); + expect(gallery[0].lng).toBe(2.2945); + }); + it('JOURNEY-SHARE-024: strips inline entry photos (and their asset metadata) when the gallery is off', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id);