feat: journey gallery 1-to-N model with M:N entry-photo junction table

Replaces the old model where journey_photos was keyed per-entry with a
per-journey gallery table (one row per unique photo per journey) and a new
junction table journey_entry_photos that links gallery photos to entries.

Key changes:
- Migration 121: renames old journey_photos to journey_photos_old, creates the
  new gallery table + junction table, backfills both from existing data, drops
  the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries
- journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via
  journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos,
  addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies
  deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts
  directly into the gallery instead of a wrapper entry
- journeyShareService: updates public photo and asset validation queries to join
  through the gallery table instead of entry_id; getPublicJourney now returns a
  dedicated gallery array alongside per-entry photos
- journey routes: adds gallery upload, provider-photo, and delete endpoints
  (POST/DELETE /:id/gallery/*); adds unlink-from-entry route
  (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to
  accept journey_photo_id with a backwards-compat photo_id alias
- types: adds GalleryPhoto interface
- client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto,
  deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId
- journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail,
  uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions
- JourneyDetailPage + tests: updated to work with the new gallery model
This commit is contained in:
jubnl
2026-04-22 15:58:31 +02:00
parent 7c9e945b8c
commit 71aa8f8051
10 changed files with 510 additions and 297 deletions
+38 -24
View File
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
},
],
stats: { entries: 2, photos: 1, places: 2 },
gallery: [
{
id: 100,
journey_id: 1,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
owner_id: null,
thumbnail_path: null,
caption: 'Colosseum',
sort_order: 0,
width: 800,
height: 600,
shared: 1,
created_at: now,
},
],
};
// ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos
// Override with entries that have no photos and empty gallery
const emptyEntry = {
...mockJourneyDetail.entries[0],
photos: [],
};
setupDefaultHandlers({
entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 },
});
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
});
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
const dateOverlay = document.querySelector('[class*="opacity-0"]');
expect(dateOverlay).toBeTruthy();
// Gallery photos render in a grid; each photo has a group container
const photos = document.querySelectorAll('[class*="aspect-square"]');
expect(photos.length).toBeGreaterThanOrEqual(1);
});
});
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
});
render(<JourneyDetailPage />);
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
});
render(<JourneyDetailPage />);
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery creates an entry and uploads photos', async () => {
it('uploading files in gallery calls gallery upload API', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false;
server.use(
http.post('/api/journeys/1/entries', () => {
createCalled = true;
return HttpResponse.json({
id: 99, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.post('/api/journeys/entries/99/photos', () => {
http.post('/api/journeys/1/gallery/photos', () => {
uploadCalled = true;
return HttpResponse.json([]);
return HttpResponse.json({ photos: [] });
}),
);
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => {
expect(uploadCalled).toBe(true);
});
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false;
server.use(
http.delete('/api/journeys/photos/100', () => {
http.delete('/api/journeys/1/gallery/100', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
return new HttpResponse(null, { status: 204 });
}),
);