diff --git a/server/src/db/repositories/JourneyEntries.repository.ts b/server/src/db/repositories/JourneyEntries.repository.ts index 8f8310913..6af4d53ac 100644 --- a/server/src/db/repositories/JourneyEntries.repository.ts +++ b/server/src/db/repositories/JourneyEntries.repository.ts @@ -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 { 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 { + 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('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 { + const rows = await this.kysely() + .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 { + const rows = await this.kysely() + .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('d.date').as('day'), eb.fn.min('da.order_index').as('ord')]) + .where('jt.journey_id', '=', journeyId) + .groupBy('p.id') + .orderBy((eb) => eb(eb.fn.min('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 { + const row = await this.kysely() + .selectFrom('journey_trips as jt') + .innerJoin('places as p', 'p.trip_id', 'jt.trip_id') + .select((eb) => eb.fn.countAll().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() + .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 { + const row = await this.qb('je').select(['je.*']).where({ id }).execute('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const rows = await this.kysely() + .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().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 { + const rows = await this.kysely() + .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().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 { const rows = await this.kysely() diff --git a/server/src/db/repositories/JourneyEntryPhotos.repository.ts b/server/src/db/repositories/JourneyEntryPhotos.repository.ts index 3c0867b59..e8d487c42 100644 --- a/server/src/db/repositories/JourneyEntryPhotos.repository.ts +++ b/server/src/db/repositories/JourneyEntryPhotos.repository.ts @@ -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 {} +/** 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 { + /** + * 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 { + const rows = await this.kysely() + .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 { + const row = await this.kysely() + .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 { + const row = await this.kysely() + .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 { + const row = await this.kysely>() + .selectFrom('journey_entry_photos') + .select((eb) => eb.fn.max('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 { + 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 { + const row = await this.kysely>() + .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 { + 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 { + 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() + .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 }[]; + } +} diff --git a/server/src/db/repositories/JourneyPhotos.repository.ts b/server/src/db/repositories/JourneyPhotos.repository.ts index f03e6f949..85b0cdbab 100644 --- a/server/src/db/repositories/JourneyPhotos.repository.ts +++ b/server/src/db/repositories/JourneyPhotos.repository.ts @@ -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 {} +/** `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 = 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` 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, +) { + return eb.fn.coalesce( + eb.fn('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( + concatKysely( + platform, + eb2, + { column: 'je.entry_date' }, + { value: 'T' }, + { expression: eb2.fn.coalesce(eb2.fn('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 { + /** JG68 — `journeyStats`'s photo count: `SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?`. */ + async countForJourney(journeyId: number): Promise { + 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 { + const row = await this.kysely() + .selectFrom('journey_photos') + .select((eb) => eb.fn.max('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 { + 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 { + 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 { + return await this.qb('gp') + .select(['gp.*']) + .where({ id }) + .execute('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 { + 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 { + 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 { + 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 { + 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 { + const row = await this.kysely() + .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 { + const row = await this.kysely() + .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 { + const platform = this.getEntityManager().getPlatform(); + const rows = await this.kysely() + .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[]; + } +} diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index 81a9316e8..f3d289f6a 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -234,6 +234,21 @@ export class PlacesRepository extends TrekRepository { .execute('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 { + return this.qb('p') + .select(['p.*']) + .where('p.id = ?', [id]) + .execute('get', false); + } + /** * PL17/PL20 — `SELECT google_place_id, image_url FROM places WHERE id = ? * AND trip_id = ?`, the reclaim-candidate projection `remove`/`removeMany` diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 850120fc2..ca76e93d4 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@mikro-orm/nestjs'; import { avatarUrl } from '../common/avatarUrl'; -import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types'; +import type { GalleryPhoto, Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types'; import { decodeEntryRow, type JourneyEntryWire } from './journey-entry-row'; -import { GALLERY_CHRONOLOGICAL_ORDER } from './journey-gallery-order'; import { DatabaseService } from '../database/database.service'; import { UnitOfWork } from '../database/unit-of-work'; import { RealtimeService } from '../realtime/realtime.service'; import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared'; +import { todayUtc } from '@trek/shared'; import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository'; import { getCountryFromCoords } from '../atlas/atlas-geo'; import { computeJourneyStats, type StatsInputPoint } from './journey-stats'; @@ -19,8 +19,14 @@ import { JourneyTrips } from '../../db/entities/JourneyTrips.entity'; import type { JourneyTripsRepository } from '../../db/repositories/JourneyTrips.repository'; import { JourneyEntries } from '../../db/entities/JourneyEntries.entity'; import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository'; +import { JourneyPhotos } from '../../db/entities/JourneyPhotos.entity'; +import type { JourneyPhotosRepository } from '../../db/repositories/JourneyPhotos.repository'; +import { JourneyEntryPhotos } from '../../db/entities/JourneyEntryPhotos.entity'; +import type { JourneyEntryPhotosRepository } from '../../db/repositories/JourneyEntryPhotos.repository'; import { Trips } from '../../db/entities/Trips.entity'; import type { TripsRepository } from '../../db/repositories/Trips.repository'; +import { Places } from '../../db/entities/Places.entity'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { presenceSet } from '../../db/repositories/_shared/presence-set'; /** @@ -76,23 +82,17 @@ function countryNamesFor(points: { country: string | null }[]): Record = {}; @@ -254,15 +265,11 @@ export class JourneyDomainService { } // JG19 — the gallery read, folding in the dialect-hard - // GALLERY_CHRONOLOGICAL_ORDER constant. Stays raw for the same reason as - // JG15 above (its owning repository, JourneyPhotosRepository, is Task - // 2's build) — Task 0's Kysely rebuild of this ORDER BY is therefore not - // consumed here; flagged in task-1-report.md for Task 2. - const gallery = this.db - .prepare( - `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ${GALLERY_CHRONOLOGICAL_ORDER}`, - ) - .all(journeyId); + // GALLERY_CHRONOLOGICAL_ORDER constant (R1). Task 1 left this raw for + // the same reason as JG15 above — finished here, the FIRST real + // consumer of Task 0's Kysely rebuild (`JourneyPhotosRepository + // .galleryRead`). + const gallery = await this.photosRepo.galleryRead(journeyId); const enrichedEntries = await Promise.all( entries.map(async (e) => ({ @@ -287,7 +294,7 @@ export class JourneyDomainService { // stats const entryCount = entries.filter((e) => e.type === 'entry').length; - const photoCount = (gallery as any[]).length; + const photoCount = gallery.length; const places = [...new Set(entries.map((e) => e.location_name).filter(Boolean))]; // JG20 @@ -543,11 +550,10 @@ export class JourneyDomainService { const entries = await this.entriesRepo.listBySourcePlace(placeId); if (!entries.length) return; - // JG44 — `SELECT * FROM places WHERE id = ?`. Stays raw: `Places.repository.ts` - // is a 3c file outside this task's named file set, and no already-public - // method there covers this exact "one place by id, every column" shape — - // flagged in task-1-report.md. - const place = this.db.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as any; + // JG44 — `SELECT * FROM places WHERE id = ?`. Task 1 left this raw + // (`Places.repository.ts` was outside its named file set); this task's + // ONE append to that file (`PlacesRepository.findRaw`) finishes it. + const place = await this.placesRepo.findRaw(placeId); if (!place) return; // Every day this place stands on, so each entry can follow its own rather @@ -912,17 +918,10 @@ export class JourneyDomainService { async journeyStats(journeyId: number, userId: number): Promise { if (!(await this.canAccessJourney(journeyId, userId))) return null; - const entryRows = this.db.prepare(` - SELECT id, title, location_name, location_lat, location_lng, entry_date, source_trip_id, - source_place_id, stats_excluded - FROM journey_entries - WHERE journey_id = ? AND dismissed = 0 - ORDER BY entry_date ASC, sort_order ASC, id ASC - `).all(journeyId) as { - 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; - }[]; + // JG64 — a narrow projection, read directly off HEAD rather than the + // inventory's stale "same text as JG14" annotation (see + // `JourneyEntriesRepository.listStatsRows`'s own docstring). + const entryRows = await this.entriesRepo.listStatsRows(journeyId); /* * The trips themselves, named and dated. @@ -932,12 +931,7 @@ export class JourneyDomainService { * no order to read. Undated trips sort last rather than first, where an * empty string would otherwise put them. */ - const tripRows = this.db.prepare(` - SELECT t.id, t.title, t.start_date AS start, t.end_date AS end - FROM journey_trips jt JOIN trips t ON t.id = jt.trip_id - WHERE jt.journey_id = ? - ORDER BY t.start_date IS NULL, t.start_date ASC, t.id ASC - `).all(journeyId) as { id: number; title: string | null; start: string | null; end: string | null }[]; + const tripRows = await this.entriesRepo.listStatsTrips(journeyId); const tripDates = tripRows.map(t => ({ start: t.start, end: t.end })); @@ -946,31 +940,11 @@ export class JourneyDomainService { // is one place, three assignments), so the join is aggregated back down to // one row per place at its earliest day — otherwise the route would visit // the hotel three times and the distance would count those legs. - const placeRows = this.db.prepare(` - SELECT p.id, p.name, p.lat, p.lng, p.trip_id AS tripId, - MIN(d.date) AS day, - MIN(da.order_index) AS ord - FROM journey_trips jt - JOIN places p ON p.trip_id = jt.trip_id - LEFT JOIN day_assignments da ON da.place_id = p.id - LEFT JOIN days d ON d.id = da.day_id - WHERE jt.journey_id = ? - GROUP BY p.id - ORDER BY day IS NULL, day ASC, ord ASC, p.id ASC - `).all(journeyId) as { - id: number; name: string | null; lat: number | null; lng: number | null; tripId: number | null; - day: string | null; ord: number | null; - }[]; + const placeRows = await this.entriesRepo.listStatsPlaces(journeyId); - const placeCount = this.db.prepare(` - SELECT COUNT(*) AS n FROM journey_trips jt - JOIN places p ON p.trip_id = jt.trip_id - WHERE jt.journey_id = ? - `).get(journeyId) as { n: number }; + const placeCountN = await this.entriesRepo.countStatsPlaces(journeyId); - const photoCount = this.db - .prepare('SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { n: number }; + const photoCountN = await this.photosRepo.countForJourney(journeyId); /* * ── A photograph per stop, for a map that marks them with pictures ─── @@ -979,15 +953,7 @@ export class JourneyDomainService { * attached to that entry, earliest first, videos excluded because a video * poster inside a four-millimetre circle is not a photograph. */ - const entryPhotoRows = this.db.prepare(` - SELECT jep.entry_id AS entryId, gp.photo_id AS photoId - FROM journey_entry_photos jep - JOIN journey_photos gp ON gp.id = jep.journey_photo_id - JOIN trek_photos tp ON tp.id = gp.photo_id - WHERE gp.journey_id = ? - AND (tp.media_type IS NULL OR tp.media_type = 'image') - ORDER BY jep.entry_id ASC, jep.sort_order ASC, gp.sort_order ASC, gp.id ASC - `).all(journeyId) as { entryId: number; photoId: number }[]; + const entryPhotoRows = await this.entryPhotosRepo.listFirstPhotoPerEntry(journeyId); const photoByEntry = new Map(); for (const r of entryPhotoRows) if (!photoByEntry.has(r.entryId)) photoByEntry.set(r.entryId, r.photoId); @@ -1008,16 +974,12 @@ export class JourneyDomainService { */ // The cached country per place, for whichever of them Atlas has seen. + // JG70 — chunked in groups of 400 inside the repository method (SQLite's + // bound-variable-count limit is why it's chunked at all). const cachedCountry = new Map(); const placeIds = placeRows.map(p => p.id); - for (let i = 0; i < placeIds.length; i += 400) { - const chunk = placeIds.slice(i, i + 400); - if (!chunk.length) continue; - const rows = this.db - .prepare(`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${chunk.map(() => '?').join(',')})`) - .all(...chunk) as { place_id: number; country_code: string }[]; - for (const r of rows) if (r.country_code) cachedCountry.set(r.place_id, r.country_code.toUpperCase()); - } + const cachedCountryRows = await this.entriesRepo.listCachedCountriesForPlaceIds(placeIds); + for (const r of cachedCountryRows) if (r.country_code) cachedCountry.set(r.place_id, r.country_code.toUpperCase()); const countryAt = (lat: number, lng: number, placeId?: number): string | null => { if (placeId != null) { @@ -1112,10 +1074,10 @@ export class JourneyDomainService { journeyId, points, entries: counting.length, - photos: photoCount?.n ?? 0, + photos: photoCountN, // A place whose skeleton was switched off is not a place the journey // went to, whichever route drew it. - places: (placeCount?.n ?? 0) - placeRows.filter(p => excludedPlaceIds.has(p.id)).length, + places: placeCountN - placeRows.filter(p => excludedPlaceIds.has(p.id)).length, tripDates, countryNames: countryNamesFor(points), trips: tripRows.map(t => ({ @@ -1132,29 +1094,25 @@ export class JourneyDomainService { async listEntries(journeyId: number, userId: number) { if (!(await this.canAccessJourney(journeyId, userId))) return null; - const entries = this.db - .prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND dismissed = 0 ORDER BY entry_date ASC, sort_order ASC, id ASC') - .all(journeyId) as JourneyEntry[]; + // JG71 — same text as JG14, reused via `listForJourney`. + const entries = await this.entriesRepo.listForJourney(journeyId); - const photos = this.db - .prepare( - `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`, - ) - .all(journeyId) as JourneyPhoto[]; + // JG72 — same JP_SELECT/JP_JOIN composite as JG15 (`getJourneyFull`). + const photos = await this.entryPhotosRepo.listForJourney(journeyId); const photosByEntry: Record = {}; for (const p of photos) { (photosByEntry[p.entry_id] ||= []).push(p); } - return entries.map((e) => ({ - ...decodeEntryRow(e), - photos: photosByEntry[e.id] || [], - source_trip_name: e.source_trip_id - ? (this.db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined) - ?.title || null - : null, - })); + // JG73 — same N+1-by-design shape as JG16, reusing `TripsRepository.getTitle`. + return await Promise.all( + entries.map(async (e) => ({ + ...decodeEntryRow(e), + photos: photosByEntry[e.id] || [], + source_trip_name: e.source_trip_id ? await this.tripsRepo.getTitle(e.source_trip_id) : null, + })), + ); } async createEntry( @@ -1181,49 +1139,37 @@ export class JourneyDomainService { if (!(await this.canEdit(journeyId, userId))) return null; const now = this.ts(); - const maxOrder = this.db - .prepare('SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?') - .get(journeyId, data.entry_date) as { m: number | null }; + // JG74 — same text as JG42 (`maxSortOrderForDate`). + const maxOrder = await this.entriesRepo.maxSortOrderForDate(journeyId, data.entry_date); const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length) ? JSON.stringify(data.pros_cons) : null; - const res = this.db - .prepare( - ` - INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, country_code, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - journeyId, - userId, - data.type || 'entry', - data.title || null, - data.story || null, - data.entry_date, - data.entry_time || null, - data.location_name || null, - data.location_lat ?? null, - data.location_lng ?? null, - this.countryFor(data.location_lat, data.location_lng), - data.mood || null, - data.weather || null, - data.tags?.length ? JSON.stringify(data.tags) : null, - prosConsJson, - data.visibility || 'private', - (maxOrder?.m ?? -1) + 1, - now, - now, - ); + const insertedId = await this.entriesRepo.insertEntry({ + journey_id: journeyId, + author_id: userId, + type: data.type || 'entry', + title: data.title || null, + story: data.story || null, + entry_date: data.entry_date, + entry_time: data.entry_time || null, + location_name: data.location_name || null, + location_lat: data.location_lat ?? null, + location_lng: data.location_lng ?? null, + country_code: this.countryFor(data.location_lat, data.location_lng), + mood: data.mood || null, + weather: data.weather || null, + tags: data.tags?.length ? JSON.stringify(data.tags) : null, + pros_cons: prosConsJson, + visibility: data.visibility || 'private', + sort_order: (maxOrder ?? -1) + 1, + created_at: now, + updated_at: now, + }); - const created = decodeEntryRow( - this.db - .prepare('SELECT * FROM journey_entries WHERE id = ?') - .get(Number(res.lastInsertRowid)) as JourneyEntry, - ); + const created = decodeEntryRow((await this.entriesRepo.findById(insertedId)) as JourneyEntry); await this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid); return created; } @@ -1251,84 +1197,97 @@ export class JourneyDomainService { }>, sid?: string, ): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG77 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return null; if (!(await this.canEdit(entry.journey_id, userId))) return null; - const fields: string[] = []; - const values: unknown[] = []; + // JG78 — R6's `presenceSet` conversion, the SECOND consumer of this + // pattern (JG24's `updateJourney`, Task 1's, is the first — same shape, + // not re-derived). The SERVICE resolves every field to its final bound + // value first: `tags`/`pros_cons` JSON-encoded, `stats_excluded`/ + // `dismissed` boolean-coerced to 0/1 (better-sqlite3 refuses a JS + // boolean bind), `country_code` recomputed whenever EITHER half of the + // lat/lng pair is present in the patch — including when explicitly + // `null`, which means "take this entry off the map" and must clear the + // country rather than fall back to the old one, hence `!== undefined`, + // never `??` — before handing any of it to the repository. + // + // The skeleton→entry promotion preserves a real legacy nuance: the + // original dynamic-SET-list pushed a SECOND `type = ?` binding when a + // story was added to a skeleton, even when the caller's own `data.type` + // had already pushed one — SQLite's `SET col = a, col = b` keeps the + // LAST assignment, so the promotion always won over an explicit + // `data.type` whenever both applied. `presenceSet` takes one value per + // key, so that "last one wins" resolution happens here instead, in the + // same effective order the legacy statement's own field list built it. + const promoteToEntry = entry.type === 'skeleton' && !!data.story && data.story.trim().length > 0; + const patch = presenceSet<{ + 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; + }>({ + type: [data.type !== undefined || promoteToEntry, promoteToEntry ? 'entry' : (data.type as string)], + title: [data.title !== undefined, data.title as string], + story: [data.story !== undefined, data.story as string], + entry_date: [data.entry_date !== undefined, data.entry_date as string], + entry_time: [data.entry_time !== undefined, data.entry_time as string], + location_name: [data.location_name !== undefined, data.location_name as string], + location_lat: [data.location_lat !== undefined, data.location_lat as number | null], + location_lng: [data.location_lng !== undefined, data.location_lng as number | null], + mood: [data.mood !== undefined, data.mood as string], + weather: [data.weather !== undefined, data.weather as string], + // `Array.isArray(val) ? JSON.stringify(val) : val` (legacy) — a caller + // sending `tags: null`/`pros_cons: null` explicitly (to CLEAR the + // field, not touch it) must write SQL NULL, not the JSON string + // `"null"` (`JSON.stringify(null)`) — caught live by + // `tools-journey.test.ts`'s own "clears a field on null" case. + tags: [data.tags !== undefined, Array.isArray(data.tags) ? JSON.stringify(data.tags) : null], + pros_cons: [data.pros_cons !== undefined, data.pros_cons && typeof data.pros_cons === 'object' ? JSON.stringify(data.pros_cons) : null], + visibility: [data.visibility !== undefined, data.visibility as string], + sort_order: [data.sort_order !== undefined, data.sort_order as number], + stats_excluded: [data.stats_excluded !== undefined, data.stats_excluded ? 1 : 0], + dismissed: [data.dismissed !== undefined, data.dismissed ? 1 : 0], + country_code: [ + data.location_lat !== undefined || data.location_lng !== undefined, + this.countryFor( + data.location_lat !== undefined ? data.location_lat : entry.location_lat, + data.location_lng !== undefined ? data.location_lng : entry.location_lng, + ), + ], + updated_at: [true, this.ts()], + }); - // Allow-list the columns a client may set: keys come from the request body - // and are interpolated as SQL column names, so restrict them to the known - // entry fields. Keep this in sync with the data type above. - const allowed = new Set([ - 'type', - 'title', - 'story', - 'entry_date', - 'entry_time', - 'location_name', - 'location_lat', - 'location_lng', - 'mood', - 'weather', - 'tags', - 'pros_cons', - 'visibility', - 'sort_order', - 'stats_excluded', - 'dismissed', - ]); + // The legacy no-op check (`fields.length === 0`) fires exactly when NO + // allow-listed key of `data` carries a defined value — the country + // recompute and the type-promotion push are both themselves gated on + // keys already in that same set (`location_lat`/`location_lng`/`story`), + // so "any key of `data` is defined" is the identical condition. + const anyFieldProvided = Object.values(data).some((v) => v !== undefined); + if (!anyFieldProvided) return decodeEntryRow(entry); - for (const [key, val] of Object.entries(data)) { - if (val === undefined) continue; - if (!allowed.has(key)) continue; - if (key === 'tags') { - fields.push('tags = ?'); - values.push(Array.isArray(val) ? JSON.stringify(val) : val); - } else if (key === 'pros_cons') { - fields.push('pros_cons = ?'); - values.push(val && typeof val === 'object' ? JSON.stringify(val) : val); - } else if (key === 'stats_excluded' || key === 'dismissed') { - // INTEGER columns, and better-sqlite3 refuses to bind a boolean. - fields.push(`${key} = ?`); - values.push(val ? 1 : 0); - } else { - fields.push(`${key} = ?`); - values.push(val); - } - } - - // The pin moved, so the flag has to follow it. Either half of the pair may be - // the one being changed, so the other is read off the stored row — and the - // test is `!== undefined`, not `??`: a null here means "take this entry off the - // map", which has to clear the country rather than fall back to the old one. - if (data.location_lat !== undefined || data.location_lng !== undefined) { - const lat = data.location_lat !== undefined ? data.location_lat : entry.location_lat; - const lng = data.location_lng !== undefined ? data.location_lng : entry.location_lng; - fields.push('country_code = ?'); - values.push(this.countryFor(lat, lng)); - } - - // if adding story to a skeleton, promote to entry - if (entry.type === 'skeleton' && data.story && data.story.trim()) { - fields.push('type = ?'); - values.push('entry'); - } - - if (fields.length === 0) return decodeEntryRow(entry); - - fields.push('updated_at = ?'); - values.push(this.ts()); - values.push(entryId); - this.db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values); + await this.entriesRepo.updateFields(entryId, patch); // touch the journey - this.db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(this.ts(), entry.journey_id); + await this.journeysRepo.updateFields(entry.journey_id, { updated_at: this.ts() }); - const updated = decodeEntryRow( - this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry, - ); + // JG80 — same text as JG76. + const updated = decodeEntryRow((await this.entriesRepo.findById(entryId)) as JourneyEntry); await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid); return updated; } @@ -1340,17 +1299,24 @@ export class JourneyDomainService { if (!(await this.canEdit(journeyId, userId))) return false; if (!orderedIds.length) return true; - const placeholders = orderedIds.map(() => '?').join(','); - const rows = this.db - .prepare(`SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`) - .all(...orderedIds, journeyId) as { id: number }[]; - if (rows.length !== orderedIds.length) return false; + // JG81 — ownership verification. + const matchedIds = await this.entriesRepo.listIdsIn(journeyId, orderedIds); + if (matchedIds.length !== orderedIds.length) return false; const now = this.ts(); - const update = this.db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?'); + // JG82/JG83 (the fourth `uow.transactional` block) — the legacy + // statement prepared ONCE and reused across the loop; the repository + // layer has no direct "one prepared statement, many binds" equivalent, + // so this issues one `nativeUpdate` per id instead (a judgment call per + // the task brief) — still one write per id, still inside the SAME + // transaction, still walked in the caller's own `orderedIds` array + // order (which has no OBSERVABLE effect on the final row state, only on + // the write order/SQL trace). await this.uow.transactional(async () => { - orderedIds.forEach((id, index) => update.run(index, now, id)); - this.db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId); + for (const [index, id] of orderedIds.entries()) { + await this.entriesRepo.updateSortOrder(id, index, now); + } + await this.journeysRepo.updateFields(journeyId, { updated_at: now }); }); await this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); @@ -1358,23 +1324,18 @@ export class JourneyDomainService { } async deleteEntry(entryId: number, userId: number, sid?: string): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG84 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return false; if (!(await this.canEdit(entry.journey_id, userId))) return false; if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { - // Revert filled entry back to skeleton instead of deleting - this.db.prepare( - ` - UPDATE journey_entries - SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL, - visibility = 'private', updated_at = ? - WHERE id = ? - `, - ).run(this.ts(), entryId); + // JG85 — revert filled entry back to skeleton instead of deleting. + await this.entriesRepo.revertToSkeleton(entryId, this.ts()); await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); } else { - this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + // JG86 — same text as JG50/JG61, the true hard delete. + await this.entriesRepo.deleteById(entryId); await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); } @@ -1388,42 +1349,41 @@ export class JourneyDomainService { // with photos is no longer just a suggestion. private async promoteSkeletonIfNeeded(entry: JourneyEntry): Promise { if (entry.type !== 'skeleton') return; - this.db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', this.ts(), entry.id); + // JG87 + await this.entriesRepo.markAsEntry(entry.id, this.ts()); } // Ensure a trek_photo_id is in the journey gallery; return its gallery row id. private async ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): Promise { const now = this.ts(); - const maxOrderRow = this.db - .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { m: number | null }; - this.db.prepare( - ` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, - ).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now); - const row = this.db - .prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?') - .get(journeyId, trekPhotoId) as { id: number }; - return row.id; + // JG88 — same text as JG99. + const maxOrder = await this.photosRepo.maxSortOrder(journeyId); + // JG89 + await this.photosRepo.insertIgnore({ + journey_id: journeyId, + photo_id: trekPhotoId, + caption: caption || null, + shared: shared ?? 0, + sort_order: (maxOrder ?? -1) + 1, + created_at: now, + }); + // JG90 + const id = await this.photosRepo.findIdByJourneyAndPhoto(journeyId, trekPhotoId); + return id as number; } // Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row. private async linkGalleryPhotoToEntry(galleryId: number, entryId: number): Promise { const now = this.ts(); - const maxOrderRow = this.db - .prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?') - .get(entryId) as { m: number | null }; - this.db.prepare( - ` - INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) - VALUES (?, ?, ?, ?) - `, - ).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now); - return this.db - .prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`) - .get(entryId, galleryId) as JourneyPhoto | null; + // JG91 + const maxOrder = await this.entryPhotosRepo.maxSortOrderForEntry(entryId); + // JG92 — R3's composite-PK upsert, checked against Task 1's + // `JourneyTripsRepository.insertIgnore` reference (the IGNORE-shaped + // one — `JourneyContributorsRepository.upsertContributor` is R3's OTHER + // reference, but it is MERGE-shaped, for JG117; see `task-2-report.md`). + await this.entryPhotosRepo.insertIgnore(entryId, galleryId, (maxOrder ?? -1) + 1, now); + // JG93 + return (await this.entryPhotosRepo.findLink(entryId, galleryId)) ?? null; } /** @@ -1443,7 +1403,8 @@ export class JourneyDomainService { caption?: string, media?: { mediaType?: string; durationMs?: number | null }, ): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG94 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return null; if (!(await this.canEdit(entry.journey_id, userId))) return null; @@ -1455,6 +1416,10 @@ export class JourneyDomainService { media?.mediaType || 'image', media?.durationMs ?? null, ); + // JG-TX1 — wraps ONLY the gallery-ensure step; `linkGalleryPhotoToEntry` + // and `promoteSkeletonIfNeeded` run OUTSIDE the transaction, exactly as + // the legacy code shipped it (the boundary looks inconsistent but + // parity is law — not widened here, same for JG-TX2/JG-TX3 below). const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(entry.journey_id, trekPhotoId, caption)); const result = await this.linkGalleryPhotoToEntry(galleryId, entryId); await this.promoteSkeletonIfNeeded(entry); @@ -1470,24 +1435,18 @@ export class JourneyDomainService { passphrase?: string, mediaType: string = 'image', ): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG95 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return null; if (!(await this.canEdit(entry.journey_id, userId))) return null; const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); - // skip if this photo is already linked to this entry - const alreadyLinked = this.db - .prepare( - ` - 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 = ? - `, - ) - .get(entryId, trekPhotoId); + // JG96 — skip if this photo is already linked to this entry. + const alreadyLinked = await this.entryPhotosRepo.existsLink(entryId, trekPhotoId); if (alreadyLinked) return null; + // JG-TX2 const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(entry.journey_id, trekPhotoId, caption)); const result = await this.linkGalleryPhotoToEntry(galleryId, entryId); await this.promoteSkeletonIfNeeded(entry); @@ -1496,14 +1455,13 @@ export class JourneyDomainService { // Link a gallery photo (by its journey_photos.id) to an entry — idempotent. async linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG97 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return null; if (!(await this.canEdit(entry.journey_id, userId))) return null; - // Verify the gallery photo belongs to this journey - const galleryRow = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as - | { id: number; journey_id: number } - | undefined; + // JG98 — verify the gallery photo belongs to this journey. + const galleryRow = await this.photosRepo.findScopeById(journeyPhotoId); if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null; const result = await this.linkGalleryPhotoToEntry(galleryRow.id, entryId); @@ -1516,26 +1474,30 @@ export class JourneyDomainService { journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[], - ): Promise { + ): Promise { if (!(await this.canEdit(journeyId, userId))) return []; - const results: any[] = []; + const results: GalleryPhoto[] = []; const now = this.ts(); - const maxOrderRow = this.db - .prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?') - .get(journeyId) as { m: number | null }; - let nextOrder = (maxOrderRow?.m ?? -1) + 1; + // JG99 — same text as JG88. + const maxOrder = await this.photosRepo.maxSortOrder(journeyId); + let nextOrder = (maxOrder ?? -1) + 1; for (const f of filePaths) { const trekPhotoId = await this.photos.getOrCreateLocal(f.path, f.thumbnail, null, null, f.mediaType || 'image', f.durationMs ?? null); - this.db.prepare( - ` - INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) - VALUES (?, ?, 0, ?, ?) - `, - ).run(journeyId, trekPhotoId, nextOrder++, now); - const row = this.db - .prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`) - .get(journeyId, trekPhotoId); + // JG100 — the 5-column `INSERT OR IGNORE` variant (no `caption`), + // distinct text from JG89 but the SAME repository method (`INSERT OR + // IGNORE` never touches an existing row, so passing `caption: null` + // here is equivalent). + await this.photosRepo.insertIgnore({ + journey_id: journeyId, + photo_id: trekPhotoId, + caption: null, + shared: 0, + sort_order: nextOrder++, + created_at: now, + }); + // JG101 + const row = await this.photosRepo.galleryReadByJourneyAndPhoto(journeyId, trekPhotoId); if (row) results.push(row); } return results; @@ -1550,23 +1512,25 @@ export class JourneyDomainService { caption?: string, passphrase?: string, mediaType: string = 'image', - ): Promise { + ): Promise { if (!(await this.canEdit(journeyId, userId))) return null; const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType); + // JG-TX3 const galleryId = await this.uow.transactional(async () => await this.ensureInGallery(journeyId, trekPhotoId, caption)); - return this.db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null; + // JG102 + return (await this.photosRepo.galleryReadOne(galleryId)) ?? null; } // Unlink a photo from a specific entry; gallery row is preserved. async unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): Promise { - const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; + // JG103 — same text as JG76. + const entry = await this.entriesRepo.findById(entryId); if (!entry) return false; if (!(await this.canEdit(entry.journey_id, userId))) return false; - const result = this.db - .prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?') - .run(entryId, journeyPhotoId); - return result.changes > 0; + // JG104 + const changed = await this.entryPhotosRepo.deleteLink(entryId, journeyPhotoId); + return changed > 0; } // Hard-delete a gallery photo (removes from all entries and the gallery). @@ -1574,37 +1538,32 @@ export class JourneyDomainService { journeyPhotoId: number, userId: number, ): Promise<{ photo_id: number; file_path?: string | null; thumbnail_path?: string | null } | null> { - const row = this.db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as - | { id: number; journey_id: number; photo_id: number } - | undefined; + // JG105 + const row = await this.photosRepo.findFull(journeyPhotoId); if (!row) return null; if (!(await this.canEdit(row.journey_id, userId))) return null; - const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as - | { file_path?: string; thumbnail_path?: string; provider?: string } - | undefined; + // JG106 — reuses `TrekPhotoRegistrationService.resolve` (PH6, already + // 3e-converted), the full `trek_photos` row this method only reads three + // columns off. + const trekRow = await this.photos.resolve(row.photo_id); - // cascade on journey_entry_photos.journey_photo_id handles junction cleanup - this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId); + // JG107 — cascade on journey_entry_photos.journey_photo_id handles junction cleanup. + await this.photosRepo.deleteById(journeyPhotoId); await this.photos.deleteIfOrphan(row.photo_id); return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null }; } async setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { - // photoId = journey_photos.id (gallery row); look up the trek_photo_id - const jp = this.db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as - | { photo_id: number } - | undefined; - if (!jp) return; - await this.photos.setProvider(jp.photo_id, provider, assetId, ownerId); - // also denorm on gallery row for fast reads - this.db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run( - provider, - assetId, - ownerId, - photoId, - ); + // photoId = journey_photos.id (gallery row); look up the trek_photo_id. + // JG108 + const trekPhotoId = await this.photosRepo.findPhotoIdById(photoId); + if (trekPhotoId === undefined) return; + await this.photos.setProvider(trekPhotoId, provider, assetId, ownerId); + // JG109 — also denorm on gallery row for fast reads. §7: a DELIBERATE + // cache of `trek_photos`'s own columns, preserved exactly. + await this.photosRepo.updateProvider(photoId, provider, assetId, ownerId); } async updatePhoto( @@ -1613,25 +1572,27 @@ export class JourneyDomainService { data: { caption?: string; sort_order?: number }, ): Promise { // photoId = journey_photos.id (gallery row) - const row = this.db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as - | { id: number; journey_id: number } - | undefined; + // JG110 — same text as JG98. + const row = await this.photosRepo.findScopeById(photoId); if (!row) return null; if (!(await this.canEdit(row.journey_id, userId))) return null; - // caption lives on the gallery row; sort_order lives on the junction table - // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order - // would not be reflected in the returned row). + // JG111/JG112 — caption lives on the gallery row (`journey_photos`); + // sort_order lives on the JUNCTION table (`journey_entry_photos`, NOT + // `journey_photos` — the JP_SELECT the caller reads back projects + // `jep.sort_order`, so writing `journey_photos.sort_order` would never + // be reflected in the returned row). Two DIFFERENT tables for two + // DIFFERENT columns both named-ish "sort order" — the file's own doc + // comment calls this out explicitly, and it gets its own mutation-proof + // test (`journey-domain.service.test.ts`). if (data.caption !== undefined) { - this.db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId); + await this.photosRepo.updateCaption(photoId, data.caption); } if (data.sort_order !== undefined) { - this.db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run( - data.sort_order, - photoId, - ); + await this.entryPhotosRepo.updateSortOrder(photoId, data.sort_order); } - return this.db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; + // JG113 + return (await this.entryPhotosRepo.findOneByGalleryId(photoId)) ?? null; } // deletePhoto: hard-delete (backwards compat name used by old route). @@ -1639,17 +1600,16 @@ export class JourneyDomainService { photoId: number, userId: number, ): Promise<{ id: number; photo_id: number; file_path?: string | null; thumbnail_path?: string | null; journey_id: number } | null> { - const row = this.db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as - | { id: number; journey_id: number; photo_id: number } - | undefined; + // JG114 + const row = await this.photosRepo.findScopeWithPhotoId(photoId); if (!row) return null; if (!(await this.canEdit(row.journey_id, userId))) return null; - const trekRow = this.db.prepare('SELECT file_path, thumbnail_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as - | { file_path?: string; thumbnail_path?: string; provider?: string } - | undefined; + // JG115 — reuses `TrekPhotoRegistrationService.resolve`, same as JG106. + const trekRow = await this.photos.resolve(row.photo_id); - this.db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); + // JG116 — same text as JG107. + await this.photosRepo.deleteById(photoId); await this.photos.deleteIfOrphan(row.photo_id); return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, thumbnail_path: trekRow?.thumbnail_path ?? null, journey_id: row.journey_id }; @@ -1666,9 +1626,10 @@ export class JourneyDomainService { if (!(await this.isOwner(journeyId, userId))) return false; if (targetUserId === userId) return false; try { - this.db.prepare( - 'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', - ).run(journeyId, targetUserId, role, this.ts()); + // JG117 — R3's composite-PK upsert, Task 1's own pinned reference + // (`JourneyContributorsRepository.upsertContributor`) — this task's + // first real caller of it. + await this.contributorsRepo.upsertContributor(journeyId, targetUserId, role, this.ts()); await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); return true; } catch { @@ -1683,21 +1644,21 @@ export class JourneyDomainService { role: 'editor' | 'viewer', ): Promise { if (!(await this.isOwner(journeyId, userId))) return false; - this.db.prepare('UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?').run( - role, - journeyId, - targetUserId, - ); + // JG118 — Task 1's `updateRole`. R5's pre-existing gap is preserved, not + // fixed: nothing here (or in SQL) stops a caller from writing `role = + // 'owner'` through this path — only the TypeScript parameter type + // discourages it, and that boundary is bypassable from a raw HTTP body. + await this.contributorsRepo.updateRole(journeyId, targetUserId, role); await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role }); return true; } async removeContributor(journeyId: number, userId: number, targetUserId: number): Promise { if (!(await this.isOwner(journeyId, userId))) return false; - this.db.prepare("DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'").run( - journeyId, - targetUserId, - ); + // JG119 — R5: the owner-protection guard stays INSIDE the repository's + // own statement (`deleteNonOwner`'s `role != 'owner'` condition, Task + // 1's build), never split into a find-then-conditional-delete. + await this.contributorsRepo.deleteNonOwner(journeyId, targetUserId); return true; } @@ -1705,38 +1666,18 @@ export class JourneyDomainService { async getSuggestions(userId: number) { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - return this.db - .prepare( - ` - SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, - (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count - FROM trips t - LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? - WHERE (t.user_id = ? OR tm.user_id = ?) - AND t.end_date IS NOT NULL - AND t.end_date >= ? - AND t.end_date <= date('now') - AND t.id NOT IN (SELECT trip_id FROM journey_trips) - ORDER BY t.end_date DESC - `, - ) - .all(userId, userId, userId, thirtyDaysAgo); + // JG120 — `date('now')` resolved in JS via `todayUtc()` (`@trek/shared`) + // and bound as a plain parameter, the same pattern Plan 3f's atlas + // conversion established for a bare `date('now')` comparison + // (`Trips.repository.ts#lastStartedTrip`'s own docstring) — no new + // dialect helper. + return await this.entriesRepo.listSuggestedTrips(userId, thirtyDaysAgo, todayUtc()); } // ── User trips (for trip picker) ───────────────────────────────────────── async listUserTrips(userId: number) { - return this.db - .prepare( - ` - SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image, - (SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count - FROM trips t - LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ? - WHERE t.user_id = ? OR tm.user_id = ? - ORDER BY t.start_date DESC - `, - ) - .all(userId, userId, userId); + // JG121 + return await this.entriesRepo.listUserTripsPicker(userId); } } diff --git a/server/tests/helpers/accommodations-service.ts b/server/tests/helpers/accommodations-service.ts index eb14fb063..1ab790716 100644 --- a/server/tests/helpers/accommodations-service.ts +++ b/server/tests/helpers/accommodations-service.ts @@ -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 { 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 { + return sharedTestOrm(db).then((t) => t.repo(JourneyPhotos)); +} + +export function createTestJourneyEntryPhotosRepo(db: Database.Database): Promise { + return sharedTestOrm(db).then((t) => t.repo(JourneyEntryPhotos)); +} diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 65428c667..cb74d9c54 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -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), diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index c9f006bfc..efb5e4078 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -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>; let entriesRepoDirect: Awaited>; let tripsRepoDirect: Awaited>; +// 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>; +let entryPhotosRepoDirect: Awaited>; +let placesRepoDirect: Awaited>; // 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); + }); +}); diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 8272e102e..45a5860ad 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -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), ); }); diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index 9a86e09c2..1318778eb 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -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 { 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), diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 499f64a46..88a3334c3 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -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), diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index 369631c1a..77038603d 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -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),