refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories

This commit is contained in:
jubnl
2026-09-23 23:05:59 +02:00
parent 353edd7a48
commit 24ad08a014
15 changed files with 1321 additions and 396 deletions
@@ -42,6 +42,71 @@ interface JourneyEntryPhotosExistsKyselyDB {
journey_entry_photos: { entry_id: number };
}
/** JG64's narrow `journeyStats` projection — NOT the same text as JG14/JG71 (a wider `SELECT *`); a distinct, narrower column list read directly off the current source, not the inventory's (stale) "same text as JG14" annotation. */
export interface StatsEntryRow {
id: number;
title: string | null;
location_name: string | null;
location_lat: number | null;
location_lng: number | null;
entry_date: string | null;
source_trip_id: number | null;
source_place_id: number | null;
stats_excluded: number;
}
/** JG65's per-trip stats row (`journeyStats`). */
export interface StatsTripRow {
id: number;
title: string | null;
start: string | null;
end: string | null;
}
/** JG66's per-place stats row (`journeyStats`), one row per place at its earliest day. */
export interface StatsPlaceRow {
id: number;
name: string | null;
lat: number | null;
lng: number | null;
tripId: number | null;
day: string | null;
ord: number | null;
}
/** JG120/JG121's trip-picker row (`getSuggestions`/`listUserTrips`), the place-count-annotated trip summary. */
export interface TripPickerRow {
id: number;
title: string;
start_date: string | null;
end_date: string | null;
cover_image: string | null;
place_count: number;
}
/** The narrow `journey_entries`/`journey_trips`/`trips`/`places`/`day_assignments`/`days` shape `journeyStats`'s cross-domain reads (JG65-67) need. */
interface StatsKyselyDB {
journey_trips: { journey_id: number; trip_id: number };
trips: { id: number; title: string | null; start_date: string | null; end_date: string | null };
places: { id: number; trip_id: number; name: string | null; lat: number | null; lng: number | null };
day_assignments: { id: number; place_id: number; day_id: number; order_index: number | null };
days: { id: number; date: string | null };
}
/** JG70's chunked cached-country probe (`journeyStats`, `place_regions` — atlas-owned, 3f-DONE, read-only here). */
interface PlaceRegionsKyselyDB {
place_regions: { place_id: number; country_code: string | null };
}
/** The narrow `trips`/`trip_members`/`places`/`day_assignments`/`journey_trips` shape JG120/JG121's trip-picker reads need. */
interface TripPickerKyselyDB {
trips: { id: number; title: string; start_date: string | null; end_date: string | null; cover_image: string | null; user_id: number };
trip_members: { trip_id: number; user_id: number };
places: { id: number; trip_id: number };
day_assignments: { id: number; place_id: number };
journey_trips: { trip_id: number };
}
/**
* `journey_entries` — first cut (Plan 3g Task 1, R9's Part A): every
* insert/find/update/delete the sync engine (`syncTripPlaces`/
@@ -235,6 +300,288 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
return !!row;
}
// ── Plan 3g Task 2 (Part B) — appended-only below this line. Every method
// above this belongs to Task 1 (Part A) and is untouched by this task. ──
/**
* JG64 — `journeyStats`'s own entries read. Read directly off the current
* source rather than the inventory's "same text as JG14" annotation,
* which is stale at HEAD: the actual statement is a NARROWER, distinct
* column list (`id, title, location_name, location_lat, location_lng,
* entry_date, source_trip_id, source_place_id, stats_excluded`), not
* `SELECT *`. `stats_excluded` stays a plain column projection — filtered
* in the SERVICE, never the WHERE clause (plan3g-inputs.md correction #3:
* the caller needs the excluded rows too, for the response's `excluded`
* field).
*/
async listStatsRows(journeyId: number): Promise<StatsEntryRow[]> {
return await this.qb('je')
.select(['je.id', 'je.title', 'je.location_name', 'je.location_lat', 'je.location_lng', 'je.entry_date', 'je.sourceTrip', 'je.sourcePlace', 'je.stats_excluded'])
.where({ journey: journeyId, dismissed: 0 })
.orderBy({ entry_date: 'asc', sort_order: 'asc', id: 'asc' })
.execute<StatsEntryRow[]>('all', false);
}
/**
* JG65 — `journeyStats`'s per-trip read, undated trips sorted last (the
* `start_date IS NULL` ordering trick, recomputed as its own ORDER BY
* expression rather than referencing the SELECT list's own alias — Kysely
* has no typed "order by select alias" form, and recomputing the same
* `t.start_date`/`t.id` references is equivalent SQL). Housed on this
* repository (not `JourneyTripsRepository`, Task 1's own file, outside
* this task's named file set) the same way `JourneyTripsRepository`
* itself reaches `places`/`day_assignments`/`days` for the sync engine —
* a repository may query any table over `this.kysely()`.
*/
async listStatsTrips(journeyId: number): Promise<StatsTripRow[]> {
const rows = await this.kysely<StatsKyselyDB>()
.selectFrom('journey_trips as jt')
.innerJoin('trips as t', 't.id', 'jt.trip_id')
.select(['t.id', 't.title', 't.start_date as start', 't.end_date as end'])
.where('jt.journey_id', '=', journeyId)
.orderBy((eb) => eb('t.start_date', 'is', null), 'asc')
.orderBy('t.start_date', 'asc')
.orderBy('t.id', 'asc')
.execute();
return rows as StatsTripRow[];
}
/**
* JG66 — `journeyStats`'s per-place read: one row per place at its
* earliest day (`GROUP BY p.id`, aggregated so a hotel spanning several
* nights' assignments counts once, not once per night).
*/
async listStatsPlaces(journeyId: number): Promise<StatsPlaceRow[]> {
const rows = await this.kysely<StatsKyselyDB>()
.selectFrom('journey_trips as jt')
.innerJoin('places as p', 'p.trip_id', 'jt.trip_id')
.leftJoin('day_assignments as da', 'da.place_id', 'p.id')
.leftJoin('days as d', 'd.id', 'da.day_id')
.select((eb) => ['p.id', 'p.name', 'p.lat', 'p.lng', 'p.trip_id as tripId', eb.fn.min<string | null>('d.date').as('day'), eb.fn.min<number | null>('da.order_index').as('ord')])
.where('jt.journey_id', '=', journeyId)
.groupBy('p.id')
.orderBy((eb) => eb(eb.fn.min<string | null>('d.date'), 'is', null), 'asc')
.orderBy((eb) => eb.fn.min('d.date'), 'asc')
.orderBy((eb) => eb.fn.min('da.order_index'), 'asc')
.orderBy('p.id', 'asc')
.execute();
return rows as StatsPlaceRow[];
}
/** JG67 — `journeyStats`'s place count: `SELECT COUNT(*) AS n FROM journey_trips jt JOIN places p ON p.trip_id=jt.trip_id WHERE jt.journey_id=?`. */
async countStatsPlaces(journeyId: number): Promise<number> {
const row = await this.kysely<StatsKyselyDB>()
.selectFrom('journey_trips as jt')
.innerJoin('places as p', 'p.trip_id', 'jt.trip_id')
.select((eb) => eb.fn.countAll<number>().as('n'))
.where('jt.journey_id', '=', journeyId)
.executeTakeFirst();
return row?.n ?? 0;
}
/**
* JG70 — `journeyStats`'s cached-country probe, chunked in groups of 400
* (SQLite's bound-variable-count limit is why it's chunked at all — do
* not collapse into one unchunked `IN`). Returns every matching row
* unfiltered (including a null/empty `country_code`) — the `if
* (r.country_code)` filter and the `.toUpperCase()` call both stay in the
* SERVICE, matching the legacy code's own JS-side handling.
*/
async listCachedCountriesForPlaceIds(placeIds: number[]): Promise<{ place_id: number; country_code: string | null }[]> {
const out: { place_id: number; country_code: string | null }[] = [];
for (let i = 0; i < placeIds.length; i += 400) {
const chunk = placeIds.slice(i, i + 400);
if (!chunk.length) continue;
const rows = await this.kysely<PlaceRegionsKyselyDB>()
.selectFrom('place_regions')
.select(['place_id', 'country_code'])
.where('place_id', 'in', chunk)
.execute();
out.push(...rows);
}
return out;
}
/** JG76/JG77/JG80/JG84/JG94/JG95/JG97/JG103 — `SELECT * FROM journey_entries WHERE id = ?`, the widest dup group in this file. */
async findById(id: number): Promise<JourneyEntry | null> {
const row = await this.qb('je').select(['je.*']).where({ id }).execute<JourneyEntry | undefined>('get', false);
return row ?? null;
}
/** JG75 — `createEntry`'s INSERT (19 columns). `country_code` is resolved by the SERVICE's `countryFor` before this call, matching {@link insertSkeleton}'s own contract. */
async insertEntry(data: {
journey_id: number;
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;
country_code: string | null;
mood: string | null;
weather: string | null;
tags: string | null;
pros_cons: string | null;
visibility: string;
sort_order: number;
created_at: number;
updated_at: number;
}): Promise<number> {
return await this.insert({
journey: data.journey_id,
author: data.author_id,
type: data.type,
title: data.title,
story: data.story,
entry_date: data.entry_date,
entry_time: data.entry_time,
location_name: data.location_name,
location_lat: data.location_lat,
location_lng: data.location_lng,
country_code: data.country_code,
mood: data.mood,
weather: data.weather,
tags: data.tags,
pros_cons: data.pros_cons,
visibility: data.visibility,
sort_order: data.sort_order,
created_at: data.created_at,
updated_at: data.updated_at,
});
}
/**
* JG78 — `updateEntry`'s dynamic UPDATE (R6, `presenceSet`). The SERVICE
* resolves every field to its final bound value first (`tags`/`pros_cons`
* JSON-encoded, `stats_excluded`/`dismissed` boolean-coerced to 0/1,
* `country_code` recomputed whenever either `location_lat`/`location_lng`
* is present in the patch, the skeleton→entry promotion) — this writes
* exactly the patch it is handed, same contract every other
* `presenceSet`-fed repository method in this program follows.
*/
async updateFields(
id: number,
patch: Partial<{
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;
sort_order: number;
stats_excluded: number;
dismissed: number;
country_code: string | null;
updated_at: number;
}>,
): Promise<void> {
await this.nativeUpdate({ id }, patch);
}
/** JG81 — `reorderEntries`'s ownership-verification read: `SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`. */
async listIdsIn(journeyId: number, ids: number[]): Promise<number[]> {
const rows = await this.qb('je')
.select(['je.id'])
.where({ id: { $in: ids }, journey: journeyId })
.execute<{ id: number }[]>('all', false);
return rows.map((r) => r.id);
}
/**
* JG82 — `reorderEntries`'s per-id sort_order write, called in a loop
* inside the SERVICE's own `uow.transactional` block (the legacy
* statement prepared once and reused across the loop; this program's
* repository layer has no direct "one prepared statement, many binds"
* equivalent, so a `nativeUpdate` call per id is the accepted substitute
* per the task brief — still one write per id, still inside the SAME
* transaction, still in the caller's own `orderedIds` array order).
*/
async updateSortOrder(id: number, sortOrder: number, updatedAt: number): Promise<void> {
await this.nativeUpdate({ id }, { sort_order: sortOrder, updated_at: updatedAt });
}
/** JG85 — `deleteEntry`'s revert-to-skeleton write (a filled, trip-sourced entry is "deleted" by reverting it, not removed — {@link deleteById} (JG50/61/86) is the true hard delete, for entries with no trip origin). */
async revertToSkeleton(id: number, updatedAt: number): Promise<void> {
await this.nativeUpdate({ id }, { type: 'skeleton', story: null, mood: null, weather: null, pros_cons: null, visibility: 'private', updated_at: updatedAt });
}
/** JG87 — `promoteSkeletonIfNeeded`'s write: `UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?`, called only when the entry is still a skeleton (the SERVICE's own `if (entry.type !== 'skeleton') return;` guard, unchanged). */
async markAsEntry(id: number, updatedAt: number): Promise<void> {
await this.nativeUpdate({ id }, { type: 'entry', updated_at: updatedAt });
}
/**
* JG120 — `getSuggestions`'s trip-picker read: recently-ended trips not
* yet linked to any journey. `date('now')` is resolved in the SERVICE via
* `todayUtc()` (`@trek/shared`) and bound as a plain parameter, the SAME
* pattern Plan 3f's atlas conversion already established for a bare
* `date('now')` comparison (`Trips.repository.ts#lastStartedTrip`'s own
* docstring) — no new dialect helper. Housed here for the same
* cross-domain-reach-via-`this.kysely()` reason as {@link listStatsTrips}
* above (`TripsRepository`/`JourneyTripsRepository` are both outside this
* task's named file set).
*/
async listSuggestedTrips(userId: number, since: string, today: string): Promise<TripPickerRow[]> {
const rows = await this.kysely<TripPickerKyselyDB>()
.selectFrom('trips as t')
.leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', userId))
.select((eb) => [
't.id',
't.title',
't.start_date',
't.end_date',
't.cover_image',
eb
.selectFrom('places as p')
.innerJoin('day_assignments as da', 'da.place_id', 'p.id')
.select((eb2) => eb2.fn.countAll<number>().as('c'))
.whereRef('p.trip_id', '=', 't.id')
.as('place_count'),
])
.where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', '=', userId)]))
.where('t.end_date', 'is not', null)
.where('t.end_date', '>=', since)
.where('t.end_date', '<=', today)
.where((eb) => eb('t.id', 'not in', eb.selectFrom('journey_trips').select('trip_id')))
.orderBy('t.end_date', 'desc')
.execute();
return rows as TripPickerRow[];
}
/** JG121 — `listUserTrips`'s trip-picker source list: every trip the caller owns or is a member of, place-count-annotated, no date filter (unlike JG120). */
async listUserTripsPicker(userId: number): Promise<TripPickerRow[]> {
const rows = await this.kysely<TripPickerKyselyDB>()
.selectFrom('trips as t')
.leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', userId))
.select((eb) => [
't.id',
't.title',
't.start_date',
't.end_date',
't.cover_image',
eb
.selectFrom('places as p')
.innerJoin('day_assignments as da', 'da.place_id', 'p.id')
.select((eb2) => eb2.fn.countAll<number>().as('c'))
.whereRef('p.trip_id', '=', 't.id')
.as('place_count'),
])
.where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', '=', userId)]))
.orderBy('t.start_date', 'desc')
.execute();
return rows as TripPickerRow[];
}
/** JG63 — `journeyTracks`: every place with route geometry belonging to a trip this journey's entries reference. */
async listTracksSource(journeyId: number): Promise<JourneyTrackSourceRow[]> {
const rows = await this.kysely<JourneyTracksKyselyDB>()
@@ -1,4 +1,186 @@
import type { JourneyPhoto } from '../../types';
import type { JourneyEntryPhotos } from '../entities/JourneyEntryPhotos.entity';
import { TrekRepository } from './_shared/trek-repository';
export class JourneyEntryPhotosRepository extends TrekRepository<JourneyEntryPhotos> {}
/** The narrow `journey_entry_photos`/`journey_photos`/`trek_photos`/`journey_entries` shape the `JP_SELECT`/`JP_JOIN`-shaped reads need. */
interface JourneyPhotoJoinKyselyDB {
journey_entry_photos: { entry_id: number; journey_photo_id: number; sort_order: number | null; created_at: number };
journey_photos: { id: number; journey_id: number; photo_id: number; caption: string | null; shared: number | null; sort_order: number | null; created_at: 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;
};
journey_entries: { id: number; journey_id: number };
}
/** `JP_SELECT`'s exact column list (`journey-domain.service.ts`'s module const), aliased `jep`/`gp`/`tp` — shared by every `JP_SELECT`/`JP_JOIN`-shaped read below. */
const JP_COLUMNS = [
'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.taken_at', 'tp.lat', 'tp.lng',
] as const;
/**
* `journey_entry_photos` — the entry↔gallery-photo junction (Plan 3g Task 2,
* Part B). A genuine TWO-column composite primary key (`entry` +
* `journeyPhoto`, both `.primary()`, R3 — pinned against
* `Migration20200101020300_journey_gallery_refactor.ts:41-47`'s
* `PRIMARY KEY(entry_id, journey_photo_id)` by Task 0). {@link insertIgnore}
* (JG92) is checked against Task 1's `JourneyTripsRepository.insertIgnore`
* (the IGNORE-shaped composite-PK reference — `JourneyContributorsRepository
* .upsertContributor` is R3's OTHER pinned reference, but it is the
* MERGE/REPLACE-shaped one for JG117; JG92 is `INSERT OR IGNORE`, the same
* semantics as JG30, so it is checked against THAT reference, per
* `task-2-report.md`).
*/
export class JourneyEntryPhotosRepository extends TrekRepository<JourneyEntryPhotos> {
/**
* JG15/JG72 — `getJourneyFull`'s and `listEntries`'s per-entry photo join:
* `SELECT {JP_SELECT} FROM {JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM
* journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`. The
* legacy `entry_id IN (subquery)` becomes an inner join to
* `journey_entries` filtered by `journey_id` — `journey_entries.id` is a
* plain PK, so the join can never fan a `jep` row out, the same
* "join can't duplicate" reasoning `JourneysRepository.listForUser`'s
* docstring gives for its own subquery-shaped legacy WHERE.
*/
async listForJourney(journeyId: number): Promise<JourneyPhoto[]> {
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')
.innerJoin('journey_entries as je', 'je.id', 'jep.entry_id')
.select(JP_COLUMNS)
.where('je.journey_id', '=', journeyId)
.orderBy('jep.sort_order', 'asc')
.execute();
return rows as JourneyPhoto[];
}
/**
* JG93 — `linkGalleryPhotoToEntry`'s post-insert read: `SELECT {JP_SELECT}
* FROM {JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`.
* Named `findLink`, not `findOne` — `TrekRepository`/`EntityRepository`
* already declare `findOne` with MikroORM's own generic signature, the
* same collision `JourneyContributorsRepository.upsertContributor`'s own
* docstring names for `upsert`.
*/
async findLink(entryId: number, journeyPhotoId: number): Promise<JourneyPhoto | undefined> {
const row = 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('jep.entry_id', '=', entryId)
.where('jep.journey_photo_id', '=', journeyPhotoId)
.executeTakeFirst();
return row as JourneyPhoto | undefined;
}
/** JG113 — `updatePhoto`'s return read: `SELECT {JP_SELECT} FROM {JP_JOIN} WHERE gp.id = ? LIMIT 1`. */
async findOneByGalleryId(galleryId: number): Promise<JourneyPhoto | undefined> {
const row = 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.id', '=', galleryId)
.limit(1)
.executeTakeFirst();
return row as JourneyPhoto | undefined;
}
/** 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'>>()
.selectFrom('journey_entry_photos')
.select((eb) => eb.fn.max<number | null>('sort_order').as('m'))
.where('entry_id', '=', entryId)
.executeTakeFirst();
return row?.m ?? null;
}
/**
* JG92 — `linkGalleryPhotoToEntry`'s `INSERT OR IGNORE INTO
* journey_entry_photos (entry_id, journey_photo_id, sort_order,
* created_at) VALUES (?,?,?,?)`, the composite-PK conflict target
* (`entry`+`journeyPhoto`). `INSERT OR IGNORE` never touches an existing
* row, so — like Task 1's `JourneyTripsRepository.insertIgnore` (the
* reference this is checked against) — there is no R3 reset-column trap
* here.
*/
async insertIgnore(entryId: number, journeyPhotoId: number, sortOrder: number, createdAt: number): Promise<void> {
await this.upsert(
{ entry: entryId, journeyPhoto: journeyPhotoId, sort_order: sortOrder, created_at: createdAt },
{ onConflictFields: ['entry', 'journeyPhoto'], onConflictAction: 'ignore' },
);
}
/** JG96 — `addProviderPhoto`'s idempotency guard: `SELECT 1 FROM journey_entry_photos jep JOIN journey_photos gp ON gp.id=jep.journey_photo_id WHERE jep.entry_id=? AND gp.photo_id=?` (`gp.photo_id` is the `trek_photos` id, not the gallery row's own id). */
async existsLink(entryId: number, trekPhotoId: number): Promise<boolean> {
const row = await this.kysely<Pick<JourneyPhotoJoinKyselyDB, 'journey_entry_photos' | 'journey_photos'>>()
.selectFrom('journey_entry_photos as jep')
.innerJoin('journey_photos as gp', 'gp.id', 'jep.journey_photo_id')
.select('jep.entry_id')
.where('jep.entry_id', '=', entryId)
.where('gp.photo_id', '=', trekPhotoId)
.executeTakeFirst();
return !!row;
}
/**
* JG112 — `updatePhoto`'s sort-order write: `UPDATE journey_entry_photos
* SET sort_order = ? WHERE journey_photo_id = ?`. **The gotcha the file's
* own doc comment calls out**: `sort_order` for the entry↔photo junction
* lives on THIS table, not `journey_photos` (`JourneyPhotosRepository`'s
* `sort_order` column is a different value, the gallery's own display
* order) — `updatePhoto`'s caption write (JG111) and this one target two
* DIFFERENT tables for two DIFFERENT columns both named "sort order".
* Unscoped by `entry_id` on purpose (matching the legacy statement
* exactly): a gallery photo linked to more than one entry has every link
* row's `sort_order` updated together.
*/
async updateSortOrder(journeyPhotoId: number, sortOrder: number): Promise<void> {
await this.nativeUpdate({ journeyPhoto: journeyPhotoId }, { sort_order: sortOrder });
}
/** JG104 — `unlinkPhotoFromEntry`: `DELETE FROM journey_entry_photos WHERE entry_id=? AND journey_photo_id=?`. Returns the affected-row count — the SERVICE branches on `changes > 0`, unchanged. */
async deleteLink(entryId: number, journeyPhotoId: number): Promise<number> {
return await this.nativeDelete({ entry: entryId, journeyPhoto: journeyPhotoId });
}
/**
* JG69 — `journeyStats`'s "a photograph per stop" source: the entry's own
* linked photos, earliest first, videos excluded (a video poster inside a
* four-millimetre map marker "is not a photograph" per the doc comment).
* The SERVICE keeps only the FIRST row per `entryId` (a `Map` that only
* sets on first-seen, relying on this method's own ordering) — this
* method returns every row, unchanged.
*/
async listFirstPhotoPerEntry(journeyId: number): Promise<{ entryId: number; photoId: number }[]> {
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(['jep.entry_id as entryId', 'gp.photo_id as photoId'])
.where('gp.journey_id', '=', journeyId)
.where((eb) => eb.or([eb('tp.media_type', 'is', null), eb('tp.media_type', '=', 'image')]))
.orderBy('jep.entry_id', 'asc')
.orderBy('jep.sort_order', 'asc')
.orderBy('gp.sort_order', 'asc')
.orderBy('gp.id', 'asc')
.execute();
return rows as { entryId: number; photoId: number }[];
}
}
@@ -1,4 +1,285 @@
import type { Platform } from '@mikro-orm/core';
import type { ExpressionBuilder } from 'kysely';
import type { GalleryPhoto } from '../../types';
import type { JourneyPhotos } from '../entities/JourneyPhotos.entity';
import { concatKysely, unixEpochToIsoKysely } from '../dialect/sql-functions';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {}
/** `SELECT * FROM journey_photos WHERE id = ?`'s row (JG105) — every scalar column of the entity. */
export interface JourneyPhotoFullRow {
id: number;
journey_id: number;
photo_id: number;
caption: string | null;
shared: number | null;
sort_order: number | null;
provider: string | null;
asset_id: string | null;
owner_id: number | null;
created_at: number;
}
const _journeyPhotoFullRowKeys: AssertRowKeys<JourneyPhotoFullRow, JourneyPhotos> = true;
/** The narrow `journey_photos`/`trek_photos`/`journey_entry_photos`/`journey_entries` shape the gallery reads (JG19/JG101/JG102, `galleryChronologicalOrderExpr`) need. */
interface GalleryKyselyDB {
journey_photos: {
id: number;
journey_id: number;
photo_id: number;
caption: string | null;
shared: number | null;
sort_order: number | null;
created_at: 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;
};
journey_entry_photos: { entry_id: number; journey_photo_id: number };
journey_entries: { id: number; entry_date: string; entry_time: string | null };
}
/** `GALLERY_SELECT`'s exact column list (`journey-domain.service.ts`'s module const), aliased `gp`/`tp` — shared by every gallery-shaped read below. */
const GALLERY_COLUMNS = [
'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.taken_at', 'tp.lat', 'tp.lng',
] as const;
/**
* `GALLERY_CHRONOLOGICAL_ORDER` (R1), the Kysely rebuild Task 0 proved
* row-order-identical to the legacy SQL text against a seeded gallery
* (SQLF-070, `sql-functions.test.ts`) — pasted verbatim from Task 0's report
* (renaming its `GalleryOrderTestDB` to this file's own `GalleryKyselyDB`,
* a structural superset that still satisfies the fixed `gp`/`tp` alias
* contract). NOT exported: Task 0 deliberately did not add a shared,
* exported builder ("re-verify it typechecks against your own repository's
* DB interface" — a naive `ExpressionBuilder<DB, 'journey_photos' |
* 'trek_photos'>` signature compiles under vitest's SWC transform but fails
* `tsc`, the exact trap this fixed-alias intersection typing avoids). Task
* 3 builds its own copy for JS15 (`getPublicJourney`'s gallery read) in
* `JourneyShareTokens.repository.ts`, tailored to THAT file's own DB
* interface, not a second caller of this one.
*/
function galleryChronologicalOrderExpr(
platform: Platform,
eb: ExpressionBuilder<GalleryKyselyDB & { gp: GalleryKyselyDB['journey_photos']; tp: GalleryKyselyDB['trek_photos'] }, 'gp' | 'tp'>,
) {
return eb.fn.coalesce(
eb.fn<string | null>('nullif', [eb.ref('tp.taken_at'), eb.val('')]),
eb
.selectFrom('journey_entry_photos as jep')
.innerJoin('journey_entries as je', 'je.id', 'jep.entry_id')
.select((eb2) =>
eb2.fn
.min<string | null>(
concatKysely(
platform,
eb2,
{ column: 'je.entry_date' },
{ value: 'T' },
{ expression: eb2.fn.coalesce(eb2.fn<string | null>('nullif', [eb2.ref('je.entry_time'), eb2.val('')]), eb2.val('00:00')) },
),
)
.as('min_dt'),
)
.whereRef('jep.journey_photo_id', '=', 'gp.id'),
unixEpochToIsoKysely(platform, eb, 'gp.created_at'),
);
}
/**
* `journey_photos` — the gallery table (Plan 3g Task 2, Part B). A plain
* single-column `id` PK with a `uniques: [{ properties: ['journey', 'photo'] }]`
* constraint — R3's composite-PK upsert ruling does NOT apply here (that
* ruling covers `JourneyTrips`/`JourneyEntryPhotos`/`JourneyContributors`
* only, all genuine composite PKs; this table's `INSERT OR IGNORE` conflict
* target is a UNIQUE index instead, the same `onConflictFields` shape
* `TripMembers.repository.ts`'s own `UNIQUE(trip_id, user_id)` precedent
* uses over a plain `id` PK).
*/
export class JourneyPhotosRepository extends TrekRepository<JourneyPhotos> {
/** JG68 — `journeyStats`'s photo count: `SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?`. */
async countForJourney(journeyId: number): Promise<number> {
return await this.count({ journey: journeyId });
}
/** 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>()
.selectFrom('journey_photos')
.select((eb) => eb.fn.max<number | null>('sort_order').as('m'))
.where('journey_id', '=', journeyId)
.executeTakeFirst();
return row?.m ?? null;
}
/**
* JG89/JG100 — the two `INSERT OR IGNORE INTO journey_photos` column-set
* variants (`ensureInGallery`'s 6-column form with `caption`,
* `uploadGalleryPhotos`'s 5-column form without it — caller passes
* `caption: null` for that one) collapse into ONE method: `INSERT OR
* IGNORE` never touches an existing row (unlike `INSERT OR REPLACE`'s
* delete+reinsert), so there is no R3 reset-column trap here the way
* `JourneyContributorsRepository.upsertContributor` has for
* `hide_skeletons` — checked against this table's own DDL, confirmed no
* such risk (Task 1's report, deviation 5).
*/
async insertIgnore(data: {
journey_id: number;
photo_id: number;
caption: string | null;
shared: number;
sort_order: number;
created_at: number;
}): Promise<void> {
await this.upsert(
{
journey: data.journey_id,
photo: data.photo_id,
caption: data.caption,
shared: data.shared,
sort_order: data.sort_order,
created_at: data.created_at,
},
{ onConflictFields: ['journey', 'photo'], onConflictAction: 'ignore' },
);
}
/** JG90 — `ensureInGallery`'s post-insert id resolution: `SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?`. */
async findIdByJourneyAndPhoto(journeyId: number, photoId: number): Promise<number | undefined> {
const row = await this.qb('gp')
.select(['gp.id'])
.where({ journey: journeyId, photo: photoId })
.execute<{ id: number } | undefined>('get', false);
return row?.id;
}
/**
* JG98/JG110 — `linkPhotoToEntry`'s and `updatePhoto`'s scope-check read:
* `SELECT id, journey_id FROM journey_photos WHERE id = ?`, one statement
* text. Selects the RELATION property (`gp.journey`), not the
* `persist(false)` shadow column name (`gp.journey_id`) directly — the
* QueryBuilder only resolves a shadow FK column through its owning
* relation property (§7's own trap: "read through the relation, not the
* shadow column"); selecting the shadow name itself either throws ("No
* fields selected") or silently drops the key from the row, both caught
* live by this file's own test run.
*/
async findScopeById(id: number): Promise<{ id: number; journey_id: number } | undefined> {
return await this.qb('gp')
.select(['gp.id', 'gp.journey'])
.where({ id })
.execute<{ id: number; journey_id: number } | undefined>('get', false);
}
/** JG114 — `deletePhoto`'s wider scope-check read: `SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?` — a DIFFERENT column set from {@link findScopeById} (JG98/JG110), not a dup. Same relation-property-not-shadow-column rule as {@link findScopeById}. */
async findScopeWithPhotoId(id: number): Promise<{ id: number; journey_id: number; photo_id: number } | undefined> {
return await this.qb('gp')
.select(['gp.id', 'gp.journey', 'gp.photo'])
.where({ id })
.execute<{ id: number; journey_id: number; photo_id: number } | undefined>('get', false);
}
/** JG105 — `deleteGalleryPhoto`'s pre-delete read: `SELECT * FROM journey_photos WHERE id = ?`. */
async findFull(id: number): Promise<JourneyPhotoFullRow | undefined> {
return await this.qb('gp')
.select(['gp.*'])
.where({ id })
.execute<JourneyPhotoFullRow | undefined>('get', false);
}
/** JG108 — `setPhotoProvider`'s `trek_photos.id` resolution: `SELECT photo_id FROM journey_photos WHERE id = ?`. Relation property (`gp.photo`), not the shadow column — see {@link findScopeById}'s docstring. */
async findPhotoIdById(id: number): Promise<number | undefined> {
const row = await this.qb('gp')
.select(['gp.photo'])
.where({ id })
.execute<{ photo_id: number } | undefined>('get', false);
return row?.photo_id;
}
/**
* JG109 — `setPhotoProvider`'s denormalized-cache write. §7: `provider`/
* `asset_id`/`owner_id` are a DELIBERATE cache of the same columns on
* `trek_photos` ("also denorm on gallery row for fast reads"), preserved
* exactly — never "fixed" into reading through the `photo` relation.
*/
async updateProvider(id: number, provider: string, assetId: string, ownerId: number): Promise<void> {
await this.nativeUpdate({ id }, { provider, asset_id: assetId, owner_id: ownerId });
}
/** JG111 — `updatePhoto`'s caption write: `UPDATE journey_photos SET caption = ? WHERE id = ?`. */
async updateCaption(id: number, caption: string): Promise<void> {
await this.nativeUpdate({ id }, { caption });
}
/**
* JG107/JG116 — `deleteGalleryPhoto`'s and `deletePhoto`'s hard delete,
* one statement text (`deletePhoto`'s own comment: "backwards-compat name
* used by old route"). Cascades `journey_entry_photos` via the entity's
* `deleteRule('cascade')`.
*/
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/** JG101 — `uploadGalleryPhotos`'s post-insert gallery-shaped read: `SELECT {GALLERY_SELECT} FROM {GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`. */
async galleryReadByJourneyAndPhoto(journeyId: number, photoId: number): Promise<GalleryPhoto | undefined> {
const row = await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tp', 'tp.id', 'gp.photo_id')
.select(GALLERY_COLUMNS)
.where('gp.journey_id', '=', journeyId)
.where('gp.photo_id', '=', photoId)
.executeTakeFirst();
return row as GalleryPhoto | undefined;
}
/** JG102 — `addProviderPhotoToGallery`'s post-insert gallery-shaped read by gallery row id: `SELECT {GALLERY_SELECT} FROM {GALLERY_JOIN} WHERE gp.id = ?`. */
async galleryReadOne(id: number): Promise<GalleryPhoto | undefined> {
const row = await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tp', 'tp.id', 'gp.photo_id')
.select(GALLERY_COLUMNS)
.where('gp.id', '=', id)
.executeTakeFirst();
return row as GalleryPhoto | undefined;
}
/**
* JG19 — `getJourneyFull`'s gallery read, folding in `GALLERY_CHRONOLOGICAL_ORDER`
* (R1). Task 1's report left this raw for this task to finish (its own
* named file set did not include this repository); this is the FIRST real
* consumer of Task 0's Kysely rebuild in the codebase. Unbounded (no
* `LIMIT`/`OFFSET` — Task 0 confirmed neither consuming site paginates),
* so the RS20 pagination rule does not independently force this ruling —
* R1 holds anyway (a full-table-read-then-sort-in-JS is exactly what RS20
* exists to prevent in general).
*/
async galleryRead(journeyId: number): Promise<GalleryPhoto[]> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.kysely<GalleryKyselyDB>()
.selectFrom('journey_photos as gp')
.innerJoin('trek_photos as tp', 'tp.id', 'gp.photo_id')
.select(GALLERY_COLUMNS)
.where('gp.journey_id', '=', journeyId)
.orderBy((eb) => galleryChronologicalOrderExpr(platform, eb), 'asc')
.orderBy('gp.sort_order', 'asc')
.orderBy('gp.id', 'asc')
.execute();
return rows as GalleryPhoto[];
}
}
@@ -234,6 +234,21 @@ export class PlacesRepository extends TrekRepository<Places> {
.execute<PlaceRow | undefined>('get', false);
}
/**
* Plan 3g Task 2 (JG44) — `journey-domain.service.ts#onPlaceUpdated`'s
* `SELECT * FROM places WHERE id = ?`, unscoped (no `trip_id` — the sync
* engine already resolved the place from its own `journey_entries.source_place_id`
* column, so the id alone is trusted here the same way `TripsRepository
* .findRaw` trusts a bare trip id). ONE append to this file for JG44 per
* the 3g plan's task split — every other method here belongs to Plan 3c.
*/
async findRaw(id: number): Promise<PlaceRow | undefined> {
return this.qb('p')
.select(['p.*'])
.where('p.id = ?', [id])
.execute<PlaceRow | undefined>('get', false);
}
/**
* PL17/PL20 — `SELECT google_place_id, image_url FROM places WHERE id = ?
* AND trip_id = ?`, the reclaim-candidate projection `remove`/`removeMany`
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ import {
import { createTestBudgetItemsRepo } from './files-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from './journey-repos';
/**
@@ -55,6 +56,8 @@ export async function accommodationsOver(dbs: DatabaseService): Promise<Accommod
dbs, realtime, new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection),
await createTestJourneysRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
await createTestJourneyTripsRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestTripsRepo(dbs.connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs.connection), await createTestJourneyEntryPhotosRepo(dbs.connection), await createTestPlacesRepo(dbs.connection),
),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
+18
View File
@@ -8,6 +8,10 @@ import { JourneyTrips } from '../../src/db/entities/JourneyTrips.entity';
import type { JourneyTripsRepository } from '../../src/db/repositories/JourneyTrips.repository';
import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity';
import type { JourneyEntriesRepository } from '../../src/db/repositories/JourneyEntries.repository';
import { JourneyPhotos } from '../../src/db/entities/JourneyPhotos.entity';
import type { JourneyPhotosRepository } from '../../src/db/repositories/JourneyPhotos.repository';
import { JourneyEntryPhotos } from '../../src/db/entities/JourneyEntryPhotos.entity';
import type { JourneyEntryPhotosRepository } from '../../src/db/repositories/JourneyEntryPhotos.repository';
/**
* Plan 3g Task 1 (`JourneyDomainService` Part A) test-only repository
@@ -42,3 +46,17 @@ export function createTestJourneyTripsRepo(db: Database.Database): Promise<Journ
export function createTestJourneyEntriesRepo(db: Database.Database): Promise<JourneyEntriesRepository> {
return sharedTestOrm(db).then((t) => t.repo(JourneyEntries));
}
/**
* Plan 3g Task 2 (`JourneyDomainService` Part B) additions — the two
* repositories Task 1 left as empty stubs for this task to populate
* (`JourneyPhotos.repository.ts`/`JourneyEntryPhotos.repository.ts`), same
* `sharedTestOrm` binding as the four factories above.
*/
export function createTestJourneyPhotosRepo(db: Database.Database): Promise<JourneyPhotosRepository> {
return sharedTestOrm(db).then((t) => t.repo(JourneyPhotos));
}
export function createTestJourneyEntryPhotosRepo(db: Database.Database): Promise<JourneyEntryPhotosRepository> {
return sharedTestOrm(db).then((t) => t.repo(JourneyEntryPhotos));
}
@@ -64,6 +64,7 @@ import {
} from '../../helpers/test-uow';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
let svc: AssignmentsService;
@@ -86,6 +87,8 @@ beforeAll(async () => {
dbs, realtime, new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection),
await createTestJourneysRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
await createTestJourneyTripsRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestTripsRepo(dbs.connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs.connection), await createTestJourneyEntryPhotosRepo(dbs.connection), await createTestPlacesRepo(dbs.connection),
),
await createTestUnitOfWork(dbs.connection),
await createTestDayAssignmentsRepo(dbs.connection),
@@ -2,7 +2,7 @@
* Unit tests for JourneyDomainService (JOURNEY-SVC-001 through JOURNEY-SVC-038).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
// -- DB setup -----------------------------------------------------------------
@@ -55,9 +55,10 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
let dbs: DatabaseService;
@@ -70,6 +71,12 @@ let contributorsRepoDirect: Awaited<ReturnType<typeof createTestJourneyContribut
let journeyTripsRepoDirect: Awaited<ReturnType<typeof createTestJourneyTripsRepo>>;
let entriesRepoDirect: Awaited<ReturnType<typeof createTestJourneyEntriesRepo>>;
let tripsRepoDirect: Awaited<ReturnType<typeof createTestTripsRepo>>;
// Plan 3g Task 2's own additions — the two repositories Task 1 left as
// empty stubs, plus `PlacesRepository` (JG44's `findRaw`), held here for
// the same reason as the five above.
let photosRepoDirect: Awaited<ReturnType<typeof createTestJourneyPhotosRepo>>;
let entryPhotosRepoDirect: Awaited<ReturnType<typeof createTestJourneyEntryPhotosRepo>>;
let placesRepoDirect: Awaited<ReturnType<typeof createTestPlacesRepo>>;
// Plan 3g Task 1 (Part A, R9's construction/wrapping pattern): the SAME
// direct-construction shape every converted service in this program uses for
@@ -95,9 +102,17 @@ beforeAll(async () => {
journeyTripsRepoDirect = await createTestJourneyTripsRepo(testDb);
entriesRepoDirect = await createTestJourneyEntriesRepo(testDb);
tripsRepoDirect = await createTestTripsRepo(testDb);
photosRepoDirect = await createTestJourneyPhotosRepo(testDb);
entryPhotosRepoDirect = await createTestJourneyEntryPhotosRepo(testDb);
placesRepoDirect = await createTestPlacesRepo(testDb);
svc = new JourneyDomainService(
dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(testDb),
journeysRepoDirect, contributorsRepoDirect, journeyTripsRepoDirect, entriesRepoDirect, tripsRepoDirect,
// Plan 3g Task 2's own append to this SAME construction call — the two
// repositories this task builds, plus `PlacesRepository` (JG44) — per
// R9, Task 2 only appends to what Task 1's construction already
// established, never re-touches the wrapping mechanism itself.
photosRepoDirect, entryPhotosRepoDirect, placesRepoDirect,
);
});
@@ -3070,3 +3085,105 @@ describe('Plan 3g Task 1 — reconcileTripSkeletons: all three branches in one c
expect(entries.some((e) => e.source_place_id === keepPlace.id)).toBe(true);
});
});
// Plan 3g Task 2 — the four `uow.transactional` blocks (JG-TX1..4) and
// JG112's mutation proof. Appended per R9 (Task 1 owns the construction/
// wrapping pattern above; this only adds new coverage).
describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('JG-TX1 (addPhoto): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
const { user } = createUser(testDb);
const journey = await svc.createJourney(user.id, { title: 'J' });
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
await expect(svc.addPhoto(entry!.id, user.id, 'journey/tx1.jpg')).rejects.toThrow('boom');
const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id);
expect(rows).toHaveLength(0);
});
it('JG-TX2 (addProviderPhoto): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
const { user } = createUser(testDb);
const journey = await svc.createJourney(user.id, { title: 'J' });
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
await expect(svc.addProviderPhoto(entry!.id, user.id, 'immich', 'asset-tx2')).rejects.toThrow('boom');
const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id);
expect(rows).toHaveLength(0);
});
it('JG-TX3 (addProviderPhotoToGallery): a failure after ensureInGallery\'s insert rolls the insert back — no orphan journey_photos row', async () => {
const { user } = createUser(testDb);
const journey = await svc.createJourney(user.id, { title: 'J' });
vi.spyOn(photosRepoDirect, 'findIdByJourneyAndPhoto').mockRejectedValueOnce(new Error('boom'));
await expect(svc.addProviderPhotoToGallery(journey.id, user.id, 'immich', 'asset-tx3')).rejects.toThrow('boom');
const rows = testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id);
expect(rows).toHaveLength(0);
});
it('JG-TX4 (reorderEntries): a failure touching the journey after the sort_order loop rolls EVERY sort_order write back, not just the last one', async () => {
const { user } = createUser(testDb);
const journey = await svc.createJourney(user.id, { title: 'J' });
const e1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
const e2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
const e3 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-01-01' });
testDb.prepare('UPDATE journey_entries SET sort_order = 0 WHERE id IN (?, ?, ?)').run(e1.id, e2.id, e3.id);
vi.spyOn(journeysRepoDirect, 'updateFields').mockRejectedValueOnce(new Error('boom'));
await expect(svc.reorderEntries(journey.id, user.id, [e3.id, e1.id, e2.id])).rejects.toThrow('boom');
// None of the three sort_order writes the loop made before the journey
// touch failed should have survived the rollback — every entry is still
// at its pre-call sort_order (0), not just the last one in the loop.
const rows = testDb.prepare('SELECT id, sort_order FROM journey_entries WHERE journey_id = ? ORDER BY id').all(journey.id) as {
id: number;
sort_order: number;
}[];
for (const r of rows) expect(r.sort_order).toBe(0);
});
});
describe('Plan 3g Task 2 — JG112 mutation proof (sort_order table targeting)', () => {
it('updatePhoto\'s sort_order write lands on journey_entry_photos (entry-scoped), never journey_photos (the gallery row\'s own, unrelated sort_order)', async () => {
const { user } = createUser(testDb);
const journey = await svc.createJourney(user.id, { title: 'J' });
const entry = await svc.createEntry(journey.id, user.id, { entry_date: '2026-01-01' });
const [photo] = await svc.uploadGalleryPhotos(journey.id, user.id, [{ path: 'journey/jg112.jpg' }]);
await svc.linkPhotoToEntry(entry!.id, photo.id, user.id);
const galleryBefore = (
testDb.prepare('SELECT sort_order FROM journey_photos WHERE id = ?').get(photo.id) as { sort_order: number }
).sort_order;
const updated = await svc.updatePhoto(photo.id, user.id, { sort_order: 7 });
// The junction row (this entry's own view of the photo's position) moved.
const junctionRow = testDb
.prepare('SELECT sort_order FROM journey_entry_photos WHERE journey_photo_id = ?')
.get(photo.id) as { sort_order: number };
expect(junctionRow.sort_order).toBe(7);
expect(updated!.sort_order).toBe(7);
// A copy-paste bug swapping JG111/JG112's target tables would leave the
// junction row untouched and instead bump `journey_photos.sort_order`
// (the gallery's OWN, unrelated ordering column, same name, different
// table) to 7 — asserting it is STILL the pre-call value is what a
// table-swap regression would fail.
const galleryAfter = (
testDb.prepare('SELECT sort_order FROM journey_photos WHERE id = ?').get(photo.id) as { sort_order: number }
).sort_order;
expect(galleryAfter).toBe(galleryBefore);
});
});
@@ -68,9 +68,10 @@ import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
const dbs = new DatabaseService(dbConn);
@@ -99,6 +100,8 @@ beforeAll(async () => {
dbs, new RealtimeService(), new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb), await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestTripsRepo(testDb),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(testDb), await createTestJourneyEntryPhotosRepo(testDb), await createTestPlacesRepo(testDb),
);
});
@@ -60,6 +60,7 @@ import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, c
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
const dbs = new DatabaseService(testDb);
@@ -99,6 +100,8 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise<Pla
dbs, new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection),
await createTestJourneysRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
await createTestJourneyTripsRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestTripsRepo(dbs.connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs.connection), await createTestJourneyEntryPhotosRepo(dbs.connection), await createTestPlacesRepo(dbs.connection),
),
storageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
@@ -83,6 +83,7 @@ import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, c
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
@@ -141,6 +142,8 @@ async function makePlacesService(
dbs, new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(dbs.connection)).repo(TrekPhotos), (await sharedTestOrm(dbs.connection)).repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection),
await createTestJourneysRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
await createTestJourneyTripsRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestTripsRepo(dbs.connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs.connection), await createTestJourneyEntryPhotosRepo(dbs.connection), await createTestPlacesRepo(dbs.connection),
),
placesStorageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
@@ -106,6 +106,7 @@ import {
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
// Real sibling services over the same in-memory DB — the aggregation runs the
@@ -157,6 +158,8 @@ beforeAll(async () => {
dbs(), new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), dbs()), await createTestUnitOfWork(dbs().connection),
await createTestJourneysRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection),
await createTestJourneyTripsRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestTripsRepo(dbs().connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs().connection), await createTestJourneyEntryPhotosRepo(dbs().connection), await createTestPlacesRepo(dbs().connection),
),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
@@ -121,6 +121,7 @@ import {
import { createTestVacayHolidayCalendarsRepo, createTestSchoolHolidayRegionsRepo } from '../../helpers/school-holidays-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
@@ -200,6 +201,8 @@ beforeAll(async () => {
dbs(), new RealtimeService(), new TrekPhotoRegistrationService(dbsEm!.getRepository(TrekPhotos), dbsEm!.getRepository(TripPhotos), dbs()), await createTestUnitOfWork(dbs().connection),
await createTestJourneysRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection),
await createTestJourneyTripsRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestTripsRepo(dbs().connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs().connection), await createTestJourneyEntryPhotosRepo(dbs().connection), await createTestPlacesRepo(dbs().connection),
),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
@@ -74,6 +74,7 @@ import {
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import {
createTestPackingItemsRepo,
@@ -140,6 +141,8 @@ beforeAll(async () => {
dbs, realtime, new TrekPhotoRegistrationService(t.repo(TrekPhotos), t.repo(TripPhotos), dbs), await createTestUnitOfWork(dbs.connection),
await createTestJourneysRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection),
await createTestJourneyTripsRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestTripsRepo(dbs.connection),
// Plan 3g Task 2 constructor-ripple: JourneyPhotosRepository/JourneyEntryPhotosRepository/PlacesRepository.
await createTestJourneyPhotosRepo(dbs.connection), await createTestJourneyEntryPhotosRepo(dbs.connection), await createTestPlacesRepo(dbs.connection),
),
makeStorageFixture('').storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),