feat(journey): carry capture metadata into the payload, and keep it behind share_map

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.
This commit is contained in:
Maurice
2026-08-18 20:23:46 +02:00
committed by Maurice
parent d2cf694882
commit 97220b72f6
3 changed files with 64 additions and 5 deletions
@@ -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';
@@ -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<string, unknown> = { ...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<T>(photos: T[] | undefined | null): T[] {
return (photos ?? []).map(p => ({ ...(p as Record<string, unknown>), lat: null, lng: null })) as T[];
}
@@ -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<string, unknown>[];
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<string, unknown>).photos as Record<string, unknown>[];
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<string, unknown>[];
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);