refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub

JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).
This commit is contained in:
jubnl
2026-09-24 06:31:34 +02:00
parent 23c292f2f2
commit 78a0952a96
7 changed files with 206 additions and 206 deletions
@@ -124,6 +124,26 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
.execute<JourneyEntry[]>('all', false);
}
/**
* JS13 (Plan 4 Task 8b relocation) — the public `getPublicJourney` route's
* entry list: `SELECT je.* FROM journey_entries je WHERE je.journey_id=?
* AND je.type != 'skeleton' AND je.dismissed=0 ORDER BY je.entry_date,
* je.sort_order`. A narrower filter than {@link listForJourney}/JG14 (adds
* the `type != 'skeleton'` exclusion — skeletons never appear publicly —
* and has no `id` ORDER BY tiebreak), so this is a distinct method, not a
* call to {@link listForJourney}. Previously lived as
* `JourneyShareTokens.repository.ts`'s own fallback stub from 3g Task 3
* (this repository was still mid-flight when that task landed) —
* relocated here now that it is stable.
*/
async listPublicEntries(journeyId: number): Promise<JourneyEntry[]> {
return await this.qb('je')
.select(['je.*'])
.where({ journey: journeyId, type: { $ne: 'skeleton' }, dismissed: 0 })
.orderBy({ entry_date: 'asc', sort_order: 'asc' })
.execute<JourneyEntry[]>('all', false);
}
/** JG22 — `getJourneyFull`'s `dismissed_count`: `SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ? AND dismissed = 1`. */
async countDismissed(journeyId: number): Promise<number> {
return await this.count({ journey: journeyId, dismissed: 1 });
@@ -31,6 +31,40 @@ const JP_COLUMNS = [
'tp.media_type', 'tp.duration_ms', 'tp.taken_at', 'tp.lat', 'tp.lng',
] as const;
/**
* JS14 (Plan 4 Task 8b relocation) — `JourneyShareService.getPublicJourney`'s
* per-entry photo row. Column-for-column identical to `JP_COLUMNS` above
* (only the widened nullability on a couple of fields to match the public
* route's original hand-kept shape) — {@link
* JourneyEntryPhotosRepository.listForPublicJourney} reuses `JP_COLUMNS`
* rather than a second, hand-duplicated select list. Previously lived as
* `JourneyShareTokens.repository.ts`'s own `JourneyPublicEntryPhotoRow` +
* `listEntryPhotosForPublicJourney`, a fallback stub from 3g Task 3 (this
* repository was still mid-flight when that task landed) — relocated here
* now that it is stable.
*/
export interface JourneyPublicEntryPhotoRow {
id: number;
entry_id: number;
photo_id: number;
caption: string | null;
sort_order: number | null;
shared: number;
created_at: number;
provider: string;
asset_id: string | null;
owner_id: number | null;
file_path: string | null;
thumbnail_path: string | null;
width: number | null;
height: number | null;
media_type: string | null;
duration_ms: number | null;
taken_at: string | null;
lat: number | null;
lng: number | null;
}
/**
* `journey_entry_photos` — the entry↔gallery-photo junction (Plan 3g Task 2,
* Part B). A genuine TWO-column composite primary key (`entry` +
@@ -101,6 +135,28 @@ export class JourneyEntryPhotosRepository extends TrekRepository<JourneyEntryPho
return row as JourneyPhoto | undefined;
}
/**
* JS14 (Plan 4 Task 8b relocation) — the public `getPublicJourney` route's
* per-entry photo read: `SELECT {JP_SELECT} FROM {JP_JOIN} WHERE
* gp.journey_id = ? ORDER BY jep.sort_order`. Filters directly on
* `gp.journey_id`, NOT via a `journey_entries` join like {@link
* listForJourney}/JG15/JG72 — a different statement text (the legacy
* public route never scoped through `journey_entries` at all), so this is
* a distinct method reusing the same `JP_COLUMNS` select list, not a call
* to {@link listForJourney}.
*/
async listForPublicJourney(journeyId: number): Promise<JourneyPublicEntryPhotoRow[]> {
const rows = await this.kysely<JourneyPhotoJoinKyselyDB>()
.selectFrom('journey_entry_photos as jep')
.innerJoin('journey_photos as gp', 'gp.id', 'jep.journey_photo_id')
.innerJoin('trek_photos as tp', 'tp.id', 'gp.photo_id')
.select(JP_COLUMNS)
.where('gp.journey_id', '=', journeyId)
.orderBy('jep.sort_order', 'asc')
.execute();
return rows as unknown as JourneyPublicEntryPhotoRow[];
}
/** JG91 — `linkGalleryPhotoToEntry`'s next-sort-order probe: `SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?`. */
async maxSortOrderForEntry(entryId: number): Promise<number | null> {
const row = await this.kysely<Pick<JourneyPhotoJoinKyselyDB, 'journey_entry_photos'>>()
@@ -50,6 +50,20 @@ interface GalleryKyselyDB {
};
journey_entry_photos: { entry_id: number; journey_photo_id: number };
journey_entries: { id: number; entry_date: string; entry_time: string | null };
journeys: { id: number; user_id: number };
}
/** JS7 (Plan 4 Task 8b relocation) — the public photo-validation join's row (`journey_photos` + `trek_photos`). */
export interface JourneyPublicPhotoValidationRow {
photo_id: number;
owner_id: number | null;
journey_id: number;
}
/** JS10 (Plan 4 Task 8b relocation) — the public asset-validation join's row (`journey_photos` + `trek_photos` + `journeys`). */
export interface JourneyPublicAssetValidationRow {
owner_id: number | null;
journey_owner_id: number;
}
/** `GALLERY_SELECT`'s exact column list (`journey-domain.service.ts`'s module const), aliased `gp`/`tp` — shared by every gallery-shaped read below. */
@@ -169,6 +183,49 @@ export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
return rows.map((r) => r.journey_id);
}
/**
* JS7 (Plan 4 Task 8b relocation) — `JourneyShareService
* .validateShareTokenForPhoto`'s photo/journey resolution: `SELECT
* gp.photo_id, tkp.owner_id, gp.journey_id FROM journey_photos gp JOIN
* trek_photos tkp ON tkp.id=gp.photo_id WHERE gp.photo_id=? AND
* gp.journey_id=?`. Public/anonymous — reachable from
* `JourneyPublicController` with no authentication, gated only by the
* unguessable share token the service already checked before calling
* this. Previously lived as `JourneyShareTokens.repository.ts`'s own
* fallback stub from 3g Task 3 (this repository was still mid-flight when
* that task landed) — relocated here now that it is stable.
*/
async findGalleryPhotoForValidation(photoId: number, journeyId: number): Promise<JourneyPublicPhotoValidationRow | undefined> {
return await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
.select(['gp.photo_id', 'tkp.owner_id', 'gp.journey_id'])
.where('gp.photo_id', '=', photoId)
.where('gp.journey_id', '=', journeyId)
.executeTakeFirst();
}
/**
* JS10 (Plan 4 Task 8b relocation) — `JourneyShareService
* .validateShareTokenForAsset`'s owner resolution: `SELECT tkp.owner_id,
* j.user_id AS journey_owner_id FROM journey_photos gp JOIN trek_photos
* tkp ON tkp.id=gp.photo_id JOIN journeys j ON j.id=gp.journey_id WHERE
* tkp.asset_id=? AND gp.journey_id=?`. Public/anonymous, security-critical
* — the service never trusts a caller-supplied owner id; only this join
* resolves `ownerId`. Relocated the same way as {@link
* findGalleryPhotoForValidation}/JS7.
*/
async findAssetForValidation(assetId: string, journeyId: number): Promise<JourneyPublicAssetValidationRow | undefined> {
return await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
.innerJoin('journeys as j', 'j.id', 'gp.journey_id')
.select(['tkp.owner_id', 'j.user_id as journey_owner_id'])
.where('tkp.asset_id', '=', assetId)
.where('gp.journey_id', '=', journeyId)
.executeTakeFirst();
}
/** JG88/JG99 — `ensureInGallery`'s and `uploadGalleryPhotos`'s next-sort-order probe: `SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?`, one statement text. */
async maxSortOrder(journeyId: number): Promise<number | null> {
const row = await this.kysely<GalleryKyselyDB>()
@@ -1,4 +1,3 @@
import type { JourneyEntry } from '../../types';
import type { JourneyShareTokens } from '../entities/JourneyShareTokens.entity';
import { TrekRepository } from './_shared/trek-repository';
@@ -30,42 +29,6 @@ export interface JourneyShareTokenAccessRow {
share_gallery: number | null;
}
/** JS7 — the photo-validation join's row (`journey_photos` + `trek_photos`). */
export interface JourneyPublicPhotoValidationRow {
photo_id: number;
owner_id: number | null;
journey_id: number;
}
/** JS10 — the asset-validation join's row (`journey_photos` + `trek_photos` + `journeys`). */
export interface JourneyPublicAssetValidationRow {
owner_id: number | null;
journey_owner_id: number;
}
/** JS14 — the public per-entry photo read. Column-for-column identical to `journey-domain.service.ts`'s `JP_SELECT` (only the `trek_photos` join alias differs, `tkp` vs `tp`) — see task-3-report.md for the reuse decision. */
export interface JourneyPublicEntryPhotoRow {
id: number;
entry_id: number;
photo_id: number;
caption: string | null;
sort_order: number | null;
shared: number;
created_at: number;
provider: string;
asset_id: string | null;
owner_id: number | null;
file_path: string | null;
thumbnail_path: string | null;
width: number | null;
height: number | null;
media_type: string | null;
duration_ms: number | null;
taken_at: string | null;
lat: number | null;
lng: number | null;
}
/** JS15 — the public gallery read (`GALLERY_SELECT` shape, `GALLERY_CHRONOLOGICAL_ORDER` applied). */
export interface JourneyPublicGalleryRow {
id: number;
@@ -89,82 +52,6 @@ export interface JourneyPublicGalleryRow {
lng: number | null;
}
/**
* The narrow Kysely shape the public-surface methods below need. `journeys`,
* `journey_photos`, `journey_entry_photos`, `journey_entries` and
* `trek_photos` are NOT this repository's own tables — Task 2's
* `JourneyPhotosRepository`/`JourneyEntryPhotosRepository`/
* `JourneyEntriesRepository` own them, but Task 2 was still mid-flight
* (uncommitted) when this task ran, so per the brief's own fallback
* instruction these six public/anonymous statements (JS7/JS10/JS13/JS14/
* JS15, plus JS8/JS12 which reuse Task 1's already-stable
* `JourneysRepository.findById` instead) get a typed Kysely read here
* rather than depending on an in-flight repository. Flagged in
* task-3-report.md for a possible later relocation once Task 2 lands.
*/
interface JourneyPublicKyselyDB {
journey_entries: {
id: number;
journey_id: number;
source_trip_id: number | null;
source_place_id: number | null;
source_assignment_id: number | null;
author_id: number;
type: string;
title: string | null;
story: string | null;
entry_date: string;
entry_time: string | null;
location_name: string | null;
location_lat: number | null;
location_lng: number | null;
mood: string | null;
weather: string | null;
tags: string | null;
pros_cons: string | null;
visibility: string | null;
sort_order: number | null;
created_at: number;
updated_at: number;
stats_excluded: number;
dismissed: number;
country_code: string | null;
};
journey_photos: {
id: number;
journey_id: number;
photo_id: number;
caption: string | null;
shared: number;
sort_order: number | null;
created_at: number;
};
journey_entry_photos: {
entry_id: number;
journey_photo_id: number;
sort_order: number | null;
};
journeys: {
id: number;
user_id: number;
};
trek_photos: {
id: number;
provider: string;
asset_id: string | null;
owner_id: number | null;
file_path: string | null;
thumbnail_path: string | null;
width: number | null;
height: number | null;
media_type: string | null;
duration_ms: number | null;
taken_at: string | null;
lat: number | null;
lng: number | null;
};
}
// L2 — `galleryChronologicalOrderExpr`/`listGalleryForPublicJourney` (JS15)
// used to be a byte-identical duplicate of `JourneyPhotosRepository`'s own
// `galleryChronologicalOrderExpr`/`galleryRead` (same `GALLERY_COLUMNS`
@@ -179,15 +66,17 @@ interface JourneyPublicKyselyDB {
// public gallery response.
/**
* `journey_share_tokens` — public share links (Plan 3g Task 3, R4). Six of
* its methods (`findAccessByToken`, `findByToken` and the five cross-table
* public reads below) are reachable from `JourneyPublicController` with NO
* authentication at all, gated purely by the unguessable token. Every token
* lookup here is a plain `.where({token})`/`.where('token', '=', token)`
* equality — no `LIKE`, no `COLLATE NOCASE`, no case-folding of any kind
* (R4) — and every one of these returns `undefined` on a miss, never throws,
* so a missing/revoked/wrong-case/NUL token 404s through the controller
* instead of 500ing.
* `journey_share_tokens` — public share links (Plan 3g Task 3, R4).
* `findAccessByToken`/`findByToken` are reachable from
* `JourneyPublicController` with NO authentication at all, gated purely by
* the unguessable token — a plain `.where({token})`/`.where('token', '=',
* token)` equality, no `LIKE`, no `COLLATE NOCASE`, no case-folding of any
* kind (R4), returning `undefined` on a miss, never throwing, so a
* missing/revoked/wrong-case/NUL token 404s through the controller instead
* of 500ing. The five cross-table public reads (JS7/JS10/JS13/JS14/JS15)
* that used to live here as a fallback Kysely stub (3g Task 3, before Task
* 2's own repositories landed) have all been relocated to their natural
* home repositories — see the bottom of this file (Plan 4 Task 8b).
*/
export class JourneyShareTokensRepository extends TrekRepository<JourneyShareTokens> {
/** JS1 — `createOrUpdateJourneyShareLink`'s existing-link read: `SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?`. */
@@ -260,72 +149,19 @@ export class JourneyShareTokensRepository extends TrekRepository<JourneyShareTok
return await this.qb('jst').select(['jst.*']).where({ token }).execute<JourneyShareTokenRow | undefined>('get', false);
}
/** JS7 — `validateShareTokenForPhoto`'s photo/journey resolution: `SELECT gp.photo_id, tkp.owner_id, gp.journey_id FROM journey_photos gp JOIN trek_photos tkp ON tkp.id=gp.photo_id WHERE gp.photo_id=? AND gp.journey_id=?`. */
async findGalleryPhotoForValidation(photoId: number, journeyId: number): Promise<JourneyPublicPhotoValidationRow | undefined> {
return await this.kysely<JourneyPublicKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
.select(['gp.photo_id', 'tkp.owner_id', 'gp.journey_id'])
.where('gp.photo_id', '=', photoId)
.where('gp.journey_id', '=', journeyId)
.executeTakeFirst();
}
// JS7/JS10 — `validateShareTokenForPhoto`'s/`validateShareTokenForAsset`'s
// photo/asset resolution moved to `JourneyPhotosRepository
// .findGalleryPhotoForValidation`/`.findAssetForValidation` (Plan 4 Task
// 8b relocation — this repository's own fallback stub from 3g Task 3 is
// gone now that `JourneyPhotosRepository` is stable).
/**
* JS10 — `validateShareTokenForAsset`'s owner resolution: `SELECT
* tkp.owner_id, j.user_id AS journey_owner_id FROM journey_photos gp JOIN
* trek_photos tkp ON tkp.id=gp.photo_id JOIN journeys j ON j.id=gp.journey_id
* WHERE tkp.asset_id=? AND gp.journey_id=?`. Security-critical (the
* service never trusts a caller-supplied owner id — this join is the only
* source of the resolved `ownerId`).
*/
async findAssetForValidation(assetId: string, journeyId: number): Promise<JourneyPublicAssetValidationRow | undefined> {
return await this.kysely<JourneyPublicKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
.innerJoin('journeys as j', 'j.id', 'gp.journey_id')
.select(['tkp.owner_id', 'j.user_id as journey_owner_id'])
.where('tkp.asset_id', '=', assetId)
.where('gp.journey_id', '=', journeyId)
.executeTakeFirst();
}
// JS13 — `getPublicJourney`'s entry list moved to
// `JourneyEntriesRepository.listPublicEntries` (Plan 4 Task 8b, same
// reason as JS7/JS10 above).
/** JS13 — `getPublicJourney`'s entry list: `SELECT je.* FROM journey_entries je WHERE je.journey_id=? AND je.type != 'skeleton' AND je.dismissed=0 ORDER BY je.entry_date, je.sort_order`. Skeletons never appear publicly. */
async listPublicEntries(journeyId: number): Promise<JourneyEntry[]> {
const rows = await this.kysely<JourneyPublicKyselyDB>()
.selectFrom('journey_entries as je')
.selectAll('je')
.where('je.journey_id', '=', journeyId)
.where('je.type', '!=', 'skeleton')
.where('je.dismissed', '=', 0)
.orderBy('je.entry_date', 'asc')
.orderBy('je.sort_order', 'asc')
.execute();
return rows as unknown as JourneyEntry[];
}
/**
* JS14 — `getPublicJourney`'s per-entry photo read. The select list is
* byte-for-byte the same columns, in the same order, as `JP_SELECT`
* (`journey-domain.service.ts`) — only the `trek_photos` join alias
* differs (`tkp` here, `tp` there). See task-3-report.md for why this
* stays a separate, hand-kept query rather than reusing Task 2's
* `JourneyEntryPhotosRepository` (not yet landed when this task ran).
*/
async listEntryPhotosForPublicJourney(journeyId: number): Promise<JourneyPublicEntryPhotoRow[]> {
return await this.kysely<JourneyPublicKyselyDB>()
.selectFrom('journey_entry_photos as jep')
.innerJoin('journey_photos as gp', 'gp.id', 'jep.journey_photo_id')
.innerJoin('trek_photos as tkp', 'tkp.id', 'gp.photo_id')
.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.taken_at', 'tkp.lat', 'tkp.lng',
])
.where('gp.journey_id', '=', journeyId)
.orderBy('jep.sort_order', 'asc')
.execute();
}
// JS14 — `getPublicJourney`'s per-entry photo read moved to
// `JourneyEntryPhotosRepository.listForPublicJourney` (Plan 4 Task 8b,
// dedupe against that repository's own `JP_COLUMNS`).
// JS15 — `getPublicJourney`'s gallery read moved to
// `JourneyPhotosRepository.galleryRead` (L2, above).
@@ -8,12 +8,16 @@ import { UnitOfWork } from '../database/unit-of-work';
import { Journeys } from '../../db/entities/Journeys.entity';
import type { JourneysRepository } from '../../db/repositories/Journeys.repository';
import { JourneyShareTokens } from '../../db/entities/JourneyShareTokens.entity';
import type {
JourneyPublicEntryPhotoRow,
JourneyShareTokensRepository,
} from '../../db/repositories/JourneyShareTokens.repository';
import type { JourneyShareTokensRepository } from '../../db/repositories/JourneyShareTokens.repository';
import { JourneyPhotos } from '../../db/entities/JourneyPhotos.entity';
import type { JourneyPhotosRepository } from '../../db/repositories/JourneyPhotos.repository';
import { JourneyEntries } from '../../db/entities/JourneyEntries.entity';
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
import { JourneyEntryPhotos } from '../../db/entities/JourneyEntryPhotos.entity';
import type {
JourneyEntryPhotosRepository,
JourneyPublicEntryPhotoRow,
} from '../../db/repositories/JourneyEntryPhotos.repository';
interface JourneySharePermissions {
share_timeline?: boolean;
@@ -61,6 +65,14 @@ export class JourneyShareService {
// byte-identical copy of the ORDER BY builder and the gallery query.
private readonly uow: UnitOfWork,
@InjectRepository(JourneyPhotos) private readonly photosRepo: JourneyPhotosRepository,
// Plan 4 Task 8b constructor-ripple: JS7/JS10 (`photosRepo`, above) and
// JS13/JS14 (`entriesRepo`/`entryPhotosRepo`, below) relocated off
// `JourneyShareTokensRepository`'s own fallback stub onto their natural
// home repositories — both already registered in `journey-domain.module
// .ts`'s `forFeature` array for `JourneyDomainService`'s own use, no
// module change needed.
@InjectRepository(JourneyEntries) private readonly entriesRepo: JourneyEntriesRepository,
@InjectRepository(JourneyEntryPhotos) private readonly entryPhotosRepo: JourneyEntryPhotosRepository,
) {}
async createOrUpdateJourneyShareLink(
@@ -171,8 +183,8 @@ export class JourneyShareService {
// already strips photos when it is off. Enumerable photo ids otherwise stay
// fetchable after the owner disables the gallery.
if (!row.share_gallery) return null;
// JS7.
const photo = await this.shareTokensRepo.findGalleryPhotoForValidation(photoId, row.journey_id);
// JS7 (Plan 4 Task 8b: relocated to `JourneyPhotosRepository`).
const photo = await this.photosRepo.findGalleryPhotoForValidation(photoId, row.journey_id);
if (!photo) return null;
// JS8 — reuses `JourneysRepository.findById` (JG5's dup group), no
// `canAccessJourney` involved: public means public.
@@ -187,10 +199,11 @@ export class JourneyShareService {
// Same as the unified photo proxy: no asset bytes leave the host unless the
// owner shared the gallery.
if (!row.share_gallery) return null;
// JS10 — security-critical: whose provider credentials get tried must
// never come from a number an anonymous caller put in the URL. Only this
// join resolves `ownerId`; a caller-supplied value never reaches it.
const photo = await this.shareTokensRepo.findAssetForValidation(assetId, row.journey_id);
// JS10 (Plan 4 Task 8b: relocated to `JourneyPhotosRepository`) —
// security-critical: whose provider credentials get tried must never
// come from a number an anonymous caller put in the URL. Only this join
// resolves `ownerId`; a caller-supplied value never reaches it.
const photo = await this.photosRepo.findAssetForValidation(assetId, row.journey_id);
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
// trek_photos.owner_id can be NULL. The journey's owner is the fallback, the
@@ -207,11 +220,11 @@ export class JourneyShareService {
const journey = await this.journeysRepo.findById(row.journey_id);
if (!journey) return null;
// Entries with photos — JS13.
const entries = await this.shareTokensRepo.listPublicEntries(row.journey_id);
// Entries with photos — JS13 (Plan 4 Task 8b: relocated to `JourneyEntriesRepository`).
const entries = await this.entriesRepo.listPublicEntries(row.journey_id);
// JS14.
const photos = await this.shareTokensRepo.listEntryPhotosForPublicJourney(row.journey_id);
// JS14 (Plan 4 Task 8b: relocated to `JourneyEntryPhotosRepository`, dedupe against `JP_COLUMNS`).
const photos = await this.entryPhotosRepo.listForPublicJourney(row.journey_id);
const photosByEntry: Record<number, JourneyPublicEntryPhotoRow[]> = {};
for (const p of photos) {
+7 -3
View File
@@ -139,7 +139,7 @@ import {
import { createTestOrm } from './test-orm';
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
import { budgetRepoArgs } from './budget-repos';
import { createTestShareTokensRepo, shareServiceRepoArgs } from './share-repos';
import { createTestShareTokensRepo, shareServiceRepoArgs, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from './share-repos';
import {
createTestCollabMessageReactionsRepo, createTestCollabNotesRepo, createTestCollabPollsRepo,
createTestCollabPollVotesRepo, createTestCollabLinksRepo, createTestCollabMessagesRepo,
@@ -223,7 +223,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
permissionsService,
new TripMembershipService(await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection)),
new WebauthnConfigService(appSettings),
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo, await createTestTripMembersRepo(dbService.connection), await createTestBudgetItemsRepo(dbService.connection), await createTestJourneyShareTokensRepo(dbService.connection), await createTestJourneysRepo(dbService.connection), await createTestJourneyEntriesRepo(dbService.connection), await createTestJourneyContributorsRepo(dbService.connection), await createTestShareTokensRepo(dbService.connection)),
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo, await createTestTripMembersRepo(dbService.connection), await createTestBudgetItemsRepo(dbService.connection), await createTestJourneyShareTokensRepo(dbService.connection), await createTestJourneysRepo(dbService.connection), await createTestJourneyEntriesRepo(dbService.connection), await createTestJourneyContributorsRepo(dbService.connection), await createTestShareTokensRepo(dbService.connection), await createTestPluginsRepo(dbService.connection), await createTestPluginUserErasureQueueRepo(dbService.connection)),
new MailerService(usersRepo, settingsRepo, appSettings),
new EphemeralTokenService(),
new AllowedFileTypesService(appSettings), await createTestUnitOfWork(dbService.connection),
@@ -322,7 +322,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(dbService.connection), await createTestReservationEndpointsRepo(dbService.connection), await createTestReservationTravelersRepo(dbService.connection)), accommodationsService, await createTestUnitOfWork(dbService.connection), await createTestReservationsRepo(dbService.connection), await createTestReservationEndpointsRepo(dbService.connection), await createTestReservationTravelersRepo(dbService.connection), await createTestReservationDayPositionsRepo(dbService.connection), await createTestDayAccommodationsRepo(dbService.connection), await createTestDaysRepo(dbService.connection), await createTestPlacesRepo(dbService.connection), await createTestDayAssignmentsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection), await createTestUsersRepo(dbService.connection), await createTestTripsRepo(dbService.connection), await createTestBudgetItemsRepo(dbService.connection));
const membersService = new TripMembersService(budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo, await createTestTripMembersRepo(dbService.connection), await createTestBudgetItemsRepo(dbService.connection), await createTestJourneyShareTokensRepo(dbService.connection), await createTestJourneysRepo(dbService.connection), await createTestJourneyEntriesRepo(dbService.connection), await createTestJourneyContributorsRepo(dbService.connection), await createTestShareTokensRepo(dbService.connection)), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection), await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection), usersRepo);
const membersService = new TripMembersService(budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo, await createTestTripMembersRepo(dbService.connection), await createTestBudgetItemsRepo(dbService.connection), await createTestJourneyShareTokensRepo(dbService.connection), await createTestJourneysRepo(dbService.connection), await createTestJourneyEntriesRepo(dbService.connection), await createTestJourneyContributorsRepo(dbService.connection), await createTestShareTokensRepo(dbService.connection), await createTestPluginsRepo(dbService.connection), await createTestPluginUserErasureQueueRepo(dbService.connection)), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection), await createTestTripsRepo(dbService.connection), await createTestTripMembersRepo(dbService.connection), usersRepo);
const tripsService = new TripsService(
dbService,
reservationsService,
@@ -484,6 +484,10 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
await createTestJourneyShareTokensRepo(dbService.connection), await createTestJourneysRepo(dbService.connection),
// task-5-fix-brief constructor-ripple: `UnitOfWork` (L1) + `JourneyPhotosRepository` (L2).
await createTestUnitOfWork(dbService.connection), await createTestJourneyPhotosRepo(dbService.connection),
// Plan 4 Task 8b constructor-ripple: `JourneyEntriesRepository` (JS13)
// + `JourneyEntryPhotosRepository` (JS14), relocated off
// `JourneyShareTokensRepository`'s own fallback stub.
await createTestJourneyEntriesRepo(dbService.connection), await createTestJourneyEntryPhotosRepo(dbService.connection),
), addonsService, authService, captureBackfill),
new MemoriesMcp(immichService, synologyService, addonsService, mcpOrm.repo(PhotoProviders)),
new NotificationsMcp(await makeNotificationsService(dbService, realtimeService), authService),
@@ -48,6 +48,8 @@ import {
} from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import type { JourneyPublicGalleryRow, JourneyShareTokensRepository } from '../../../src/db/repositories/JourneyShareTokens.repository';
import type { JourneyEntriesRepository } from '../../../src/db/repositories/JourneyEntries.repository';
import type { JourneyEntryPhotosRepository } from '../../../src/db/repositories/JourneyEntryPhotos.repository';
import { GALLERY_CHRONOLOGICAL_ORDER } from '../../../src/nest/journey/journey-gallery-order';
const dbs = new DatabaseService(dbConn);
@@ -79,6 +81,10 @@ beforeAll(async () => {
// task-5-fix-brief constructor-ripple: `UnitOfWork` (L1's transactional
// create/update) + `JourneyPhotosRepository` (L2's `galleryRead` reuse).
uow, await createTestJourneyPhotosRepo(testDb),
// Plan 4 Task 8b constructor-ripple: `JourneyEntriesRepository` (JS13) +
// `JourneyEntryPhotosRepository` (JS14), relocated off
// `JourneyShareTokensRepository`'s own fallback stub.
await createTestJourneyEntriesRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb),
);
});
@@ -774,10 +780,18 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme
// pins that method's output against the legacy statement instead of the
// (now-deleted) `JourneyShareTokensRepository.listGalleryForPublicJourney`.
let journeyPhotosRepo: Awaited<ReturnType<typeof createTestJourneyPhotosRepo>>;
// Plan 4 Task 8b — JS7/JS10/JS13/JS14 relocated the same way JS15 was: P04
// and P05 below now pin `JourneyEntriesRepository.listPublicEntries` and
// `JourneyEntryPhotosRepository.listForPublicJourney` instead of the
// (now-deleted) `JourneyShareTokensRepository` fallback stub methods.
let journeyEntriesRepo: JourneyEntriesRepository;
let journeyEntryPhotosRepo: JourneyEntryPhotosRepository;
beforeAll(async () => {
shareTokensRepo = await createTestJourneyShareTokensRepo(testDb);
journeyPhotosRepo = await createTestJourneyPhotosRepo(testDb);
journeyEntriesRepo = await createTestJourneyEntriesRepo(testDb);
journeyEntryPhotosRepo = await createTestJourneyEntryPhotosRepo(testDb);
});
it('JOURNEY-SHARE-P01: findFlagsByJourneyId (JS1) matches the legacy 5-column read', async () => {
@@ -837,10 +851,10 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme
`)
.all(journey.id);
expect(await shareTokensRepo.listPublicEntries(journey.id)).toEqual(legacy);
expect(await journeyEntriesRepo.listPublicEntries(journey.id)).toEqual(legacy);
});
it('JOURNEY-SHARE-P05: listEntryPhotosForPublicJourney (JS14) matches the legacy JP_SELECT-shaped statement', async () => {
it('JOURNEY-SHARE-P05: JourneyEntryPhotosRepository.listForPublicJourney (JS14) matches the legacy JP_SELECT-shaped statement', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', entry_date: '2026-01-01' });
@@ -859,7 +873,7 @@ describe('parity — JourneyShareTokensRepository reads match the legacy stateme
`)
.all(journey.id);
expect(await shareTokensRepo.listEntryPhotosForPublicJourney(journey.id)).toEqual(legacy);
expect(await journeyEntryPhotosRepo.listForPublicJourney(journey.id)).toEqual(legacy);
});
it('JOURNEY-SHARE-P06: JourneyPhotosRepository.galleryRead (JS15, GALLERY_CHRONOLOGICAL_ORDER) matches the legacy statement — a photo linked to an entry AND an unattached gallery photo', async () => {