mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user