From 229f4890df4289811ca38835253693c0fa1312bd Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 21 Sep 2026 09:39:40 +0200 Subject: [PATCH] feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips) --- server/src/db/entities/DayNotes.entity.ts | 22 ++- server/src/db/entities/Days.entity.ts | 57 +++--- server/src/db/entities/Trips.entity.ts | 164 +++++++++--------- .../db/repositories/DayNotes.repository.ts | 53 ++++++ server/src/db/repositories/Days.repository.ts | 74 ++++++++ .../src/db/repositories/Trips.repository.ts | 38 ++++ server/src/db/repositories/_shared/rows.ts | 14 ++ .../repositories/DayNotes.repository.test.ts | 50 ++++++ .../db/repositories/Days.repository.test.ts | 75 ++++++++ .../db/repositories/Trips.repository.test.ts | 73 ++++++++ 10 files changed, 502 insertions(+), 118 deletions(-) create mode 100644 server/src/db/repositories/DayNotes.repository.ts create mode 100644 server/src/db/repositories/Days.repository.ts create mode 100644 server/src/db/repositories/Trips.repository.ts create mode 100644 server/src/db/repositories/_shared/rows.ts create mode 100644 server/tests/unit/db/repositories/DayNotes.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Days.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Trips.repository.test.ts diff --git a/server/src/db/entities/DayNotes.entity.ts b/server/src/db/entities/DayNotes.entity.ts index 2f93116c0..51c28f3fc 100644 --- a/server/src/db/entities/DayNotes.entity.ts +++ b/server/src/db/entities/DayNotes.entity.ts @@ -1,33 +1,37 @@ -import { type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core'; +import { type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayNotesRepository } from '../repositories/DayNotes.repository'; +import { DbTimestampType } from '../types'; import { Days } from './Days.entity'; import { Trips } from './Trips.entity'; export class DayNotes { id?: number | null; day!: Ref; + day_id!: number; trip!: Ref; + trip_id!: number; text!: string; time?: string | null; icon?: string | null = '📝'; - sortOrder?: unknown | null; - createdAt?: Date | null; + sort_order?: number | null; + created_at?: string | null; color?: string | null; } -export class DayNotesRepository extends EntityRepository {} - export const DayNotesSchema = defineEntity({ class: DayNotes, repository: () => DayNotesRepository, properties: { id: p.integer().primary().autoincrement(), - day: () => p.manyToOne(Days).ref().deleteRule('cascade').index('idx_day_notes_day_id'), - trip: () => p.manyToOne(Trips).ref().deleteRule('cascade'), + day: () => p.manyToOne(Days).ref().hidden().deleteRule('cascade').index('idx_day_notes_day_id'), + day_id: p.integer().persist(false), + trip: () => p.manyToOne(Trips).ref().hidden().deleteRule('cascade'), + trip_id: p.integer().persist(false), text: p.text(), time: p.text().nullable(), icon: p.text().nullable(), - sortOrder: p.double().nullable().default(0), - createdAt: p.datetime().nullable().onCreate(() => new Date()), + sort_order: p.double().nullable().default(0), + created_at: p.type(DbTimestampType).nullable().defaultRaw('CURRENT_TIMESTAMP'), color: p.text().nullable(), }, }); diff --git a/server/src/db/entities/Days.entity.ts b/server/src/db/entities/Days.entity.ts index 6798a419d..5939fddf8 100644 --- a/server/src/db/entities/Days.entity.ts +++ b/server/src/db/entities/Days.entity.ts @@ -1,4 +1,5 @@ -import { Collection, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core'; +import { Collection, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DaysRepository } from '../repositories/Days.repository'; import { DayAccommodations } from './DayAccommodations.entity'; import { DayAssignments } from './DayAssignments.entity'; import { DayNotes } from './DayNotes.entity'; @@ -12,45 +13,45 @@ import { Trips } from './Trips.entity'; export class Days { id?: number | null; trip!: Ref; - dayNumber!: number; + trip_id!: number; + day_number!: number; date?: string | null; notes?: string | null; title?: string | null; - defaultTransportMode?: string | null; - dayAccommodationsCollection = new Collection(this); - dayAccommodationsCollection1 = new Collection(this); - dayAssignmentsCollection = new Collection(this); - dayNotesCollection = new Collection(this); - photosCollection = new Collection(this); - reservationDayPositionsCollection = new Collection(this); - reservationsCollection = new Collection(this); - reservationsCollection1 = new Collection(this); - roadtripDayTracks: Ref | null = null; - roadtripViasCollection = new Collection(this); + default_transport_mode?: string | null; + day_accommodations_collection = new Collection(this); + day_accommodations_collection1 = new Collection(this); + day_assignments_collection = new Collection(this); + day_notes_collection = new Collection(this); + photos_collection = new Collection(this); + reservation_day_positions_collection = new Collection(this); + reservations_collection = new Collection(this); + reservations_collection1 = new Collection(this); + roadtrip_day_tracks: Ref | null = null; + roadtrip_vias_collection = new Collection(this); } -export class DaysRepository extends EntityRepository {} - export const DaysSchema = defineEntity({ class: Days, repository: () => DaysRepository, properties: { id: p.integer().primary().autoincrement(), - trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').index('idx_days_trip_id'), - dayNumber: p.integer(), + trip: () => p.manyToOne(Trips).ref().hidden().deleteRule('cascade').index('idx_days_trip_id'), + trip_id: p.integer().persist(false), + day_number: p.integer(), date: p.text().nullable(), notes: p.text().nullable(), title: p.text().nullable(), - defaultTransportMode: p.text().nullable(), - dayAccommodationsCollection: () => p.oneToMany(DayAccommodations).mappedBy('startDay'), - dayAccommodationsCollection1: () => p.oneToMany(DayAccommodations).mappedBy('endDay'), - dayAssignmentsCollection: () => p.oneToMany(DayAssignments).mappedBy('day'), - dayNotesCollection: () => p.oneToMany(DayNotes).mappedBy('day'), - photosCollection: () => p.oneToMany(Photos).mappedBy('day'), - reservationDayPositionsCollection: () => p.oneToMany(ReservationDayPositions).mappedBy('day'), - reservationsCollection: () => p.oneToMany(Reservations).mappedBy('day'), - reservationsCollection1: () => p.oneToMany(Reservations).mappedBy('endDay'), - roadtripDayTracks: () => p.oneToOne(RoadtripDayTracks).ref().mappedBy('day'), - roadtripViasCollection: () => p.oneToMany(RoadtripVias).mappedBy('day'), + default_transport_mode: p.text().nullable(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).hidden().mappedBy('startDay'), + day_accommodations_collection1: () => p.oneToMany(DayAccommodations).hidden().mappedBy('endDay'), + day_assignments_collection: () => p.oneToMany(DayAssignments).hidden().mappedBy('day'), + day_notes_collection: () => p.oneToMany(DayNotes).hidden().mappedBy('day'), + photos_collection: () => p.oneToMany(Photos).hidden().mappedBy('day'), + reservation_day_positions_collection: () => p.oneToMany(ReservationDayPositions).hidden().mappedBy('day'), + reservations_collection: () => p.oneToMany(Reservations).hidden().mappedBy('day'), + reservations_collection1: () => p.oneToMany(Reservations).hidden().mappedBy('endDay'), + roadtrip_day_tracks: () => p.oneToOne(RoadtripDayTracks).ref().hidden().mappedBy('day'), + roadtrip_vias_collection: () => p.oneToMany(RoadtripVias).hidden().mappedBy('day'), }, }); diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts index bad77423f..e7a9d196b 100644 --- a/server/src/db/entities/Trips.entity.ts +++ b/server/src/db/entities/Trips.entity.ts @@ -1,4 +1,6 @@ -import { Collection, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core'; +import { Collection, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripsRepository } from '../repositories/Trips.repository'; +import { DbTimestampType } from '../types'; import { BudgetCategoryOrder } from './BudgetCategoryOrder.entity'; import { BudgetItems } from './BudgetItems.entity'; import { BudgetSettlements } from './BudgetSettlements.entity'; @@ -34,51 +36,50 @@ import { Users } from './Users.entity'; export class Trips { id?: number | null; user!: Ref; + user_id!: number; title!: string; description?: string | null; - startDate?: string | null; - endDate?: string | null; + start_date?: string | null; + end_date?: string | null; currency?: string | null = 'EUR'; - coverImage?: string | null; - isArchived?: number | null = 0; - reminderDays?: number | null = 3; - feedToken?: string | null; - createdAt?: Date | null; - updatedAt?: Date | null; - budgetCategoryOrderCollection = new Collection(this); - budgetItemsCollection = new Collection(this); - budgetSettlementsCollection = new Collection(this); - collabLinksCollection = new Collection(this); - collabMessagesCollection = new Collection(this); - collabNotesCollection = new Collection(this); - collabPollsCollection = new Collection(this); - dawarichVisitSuggestionsCollection = new Collection(this); - dayAccommodationsCollection = new Collection(this); - dayNotesCollection = new Collection(this); - daysCollection = new Collection(this); - inviteTokensCollection = new Collection(this); - journeyEntriesCollection = new Collection(this); - journeyTripsCollection = new Collection(this); - packingBagsCollection = new Collection(this); - packingCategoryAssigneesCollection = new Collection(this); - packingItemsCollection = new Collection(this); - photosCollection = new Collection(this); - placesCollection = new Collection(this); - reservationsCollection = new Collection(this); - roadtripDayBoundariesCollection = new Collection(this); - roadtripPreferencesCollection = new Collection(this); - shareTokensCollection = new Collection(this); - todoCategoryAssigneesCollection = new Collection(this); - todoItemsCollection = new Collection(this); - tripAlbumLinksCollection = new Collection(this); - tripFilesCollection = new Collection(this); - tripInviteTokensCollection = new Collection(this); - tripMembersCollection = new Collection(this); - tripPhotosCollection = new Collection(this); + cover_image?: string | null; + is_archived?: number | null = 0; + reminder_days?: number | null = 3; + feed_token?: string | null; + created_at?: string | null; + updated_at?: string | null; + budget_category_order_collection = new Collection(this); + budget_items_collection = new Collection(this); + budget_settlements_collection = new Collection(this); + collab_links_collection = new Collection(this); + collab_messages_collection = new Collection(this); + collab_notes_collection = new Collection(this); + collab_polls_collection = new Collection(this); + dawarich_visit_suggestions_collection = new Collection(this); + day_accommodations_collection = new Collection(this); + day_notes_collection = new Collection(this); + days_collection = new Collection(this); + invite_tokens_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_trips_collection = new Collection(this); + packing_bags_collection = new Collection(this); + packing_category_assignees_collection = new Collection(this); + packing_items_collection = new Collection(this); + photos_collection = new Collection(this); + places_collection = new Collection(this); + reservations_collection = new Collection(this); + roadtrip_day_boundaries_collection = new Collection(this); + roadtrip_preferences_collection = new Collection(this); + share_tokens_collection = new Collection(this); + todo_category_assignees_collection = new Collection(this); + todo_items_collection = new Collection(this); + trip_album_links_collection = new Collection(this); + trip_files_collection = new Collection(this); + trip_invite_tokens_collection = new Collection(this); + trip_members_collection = new Collection(this); + trip_photos_collection = new Collection(this); } -export class TripsRepository extends EntityRepository {} - export const TripsSchema = defineEntity({ class: Trips, repository: () => TripsRepository, @@ -86,52 +87,53 @@ export const TripsSchema = defineEntity({ { name: 'idx_trips_feed_token', where: 'feed_token IS NOT NULL', - properties: ['feedToken'], + properties: ['feed_token'], }, ], properties: { id: p.integer().primary().autoincrement(), - user: () => p.manyToOne(Users).ref().deleteRule('cascade').index('idx_trips_user_id'), + user: () => p.manyToOne(Users).ref().hidden().deleteRule('cascade').index('idx_trips_user_id'), + user_id: p.integer().persist(false), title: p.text(), description: p.text().nullable(), - startDate: p.text().nullable(), - endDate: p.text().nullable(), + start_date: p.text().nullable(), + end_date: p.text().nullable(), currency: p.text().nullable(), - coverImage: p.text().nullable(), - isArchived: p.integer().nullable(), - reminderDays: p.integer().nullable(), - feedToken: p.text().nullable(), - createdAt: p.datetime().nullable().onCreate(() => new Date()).index('idx_trips_created_at'), - updatedAt: p.datetime().nullable().onCreate(() => new Date()), - budgetCategoryOrderCollection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip'), - budgetItemsCollection: () => p.oneToMany(BudgetItems).mappedBy('trip'), - budgetSettlementsCollection: () => p.oneToMany(BudgetSettlements).mappedBy('trip'), - collabLinksCollection: () => p.oneToMany(CollabLinks).mappedBy('trip'), - collabMessagesCollection: () => p.oneToMany(CollabMessages).mappedBy('trip'), - collabNotesCollection: () => p.oneToMany(CollabNotes).mappedBy('trip'), - collabPollsCollection: () => p.oneToMany(CollabPolls).mappedBy('trip'), - dawarichVisitSuggestionsCollection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('trip'), - dayAccommodationsCollection: () => p.oneToMany(DayAccommodations).mappedBy('trip'), - dayNotesCollection: () => p.oneToMany(DayNotes).mappedBy('trip'), - daysCollection: () => p.oneToMany(Days).mappedBy('trip'), - inviteTokensCollection: () => p.oneToMany(InviteTokens).mappedBy('trip'), - journeyEntriesCollection: () => p.oneToMany(JourneyEntries).mappedBy('sourceTrip'), - journeyTripsCollection: () => p.oneToMany(JourneyTrips).mappedBy('trip'), - packingBagsCollection: () => p.oneToMany(PackingBags).mappedBy('trip'), - packingCategoryAssigneesCollection: () => p.oneToMany(PackingCategoryAssignees).mappedBy('trip'), - packingItemsCollection: () => p.oneToMany(PackingItems).mappedBy('trip'), - photosCollection: () => p.oneToMany(Photos).mappedBy('trip'), - placesCollection: () => p.oneToMany(Places).mappedBy('trip'), - reservationsCollection: () => p.oneToMany(Reservations).mappedBy('trip'), - roadtripDayBoundariesCollection: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('trip'), - roadtripPreferencesCollection: () => p.oneToMany(RoadtripPreferences).mappedBy('trip'), - shareTokensCollection: () => p.oneToMany(ShareTokens).mappedBy('trip'), - todoCategoryAssigneesCollection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('trip'), - todoItemsCollection: () => p.oneToMany(TodoItems).mappedBy('trip'), - tripAlbumLinksCollection: () => p.oneToMany(TripAlbumLinks).mappedBy('trip'), - tripFilesCollection: () => p.oneToMany(TripFiles).mappedBy('trip'), - tripInviteTokensCollection: () => p.oneToMany(TripInviteTokens).mappedBy('trip'), - tripMembersCollection: () => p.oneToMany(TripMembers).mappedBy('trip'), - tripPhotosCollection: () => p.oneToMany(TripPhotos).mappedBy('trip'), + cover_image: p.text().nullable(), + is_archived: p.integer().nullable(), + reminder_days: p.integer().nullable(), + feed_token: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().index('idx_trips_created_at').defaultRaw('CURRENT_TIMESTAMP'), + updated_at: p.type(DbTimestampType).nullable().defaultRaw('CURRENT_TIMESTAMP'), + budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).hidden().mappedBy('trip'), + budget_items_collection: () => p.oneToMany(BudgetItems).hidden().mappedBy('trip'), + budget_settlements_collection: () => p.oneToMany(BudgetSettlements).hidden().mappedBy('trip'), + collab_links_collection: () => p.oneToMany(CollabLinks).hidden().mappedBy('trip'), + collab_messages_collection: () => p.oneToMany(CollabMessages).hidden().mappedBy('trip'), + collab_notes_collection: () => p.oneToMany(CollabNotes).hidden().mappedBy('trip'), + collab_polls_collection: () => p.oneToMany(CollabPolls).hidden().mappedBy('trip'), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).hidden().mappedBy('trip'), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).hidden().mappedBy('trip'), + day_notes_collection: () => p.oneToMany(DayNotes).hidden().mappedBy('trip'), + days_collection: () => p.oneToMany(Days).hidden().mappedBy('trip'), + invite_tokens_collection: () => p.oneToMany(InviteTokens).hidden().mappedBy('trip'), + journey_entries_collection: () => p.oneToMany(JourneyEntries).hidden().mappedBy('sourceTrip'), + journey_trips_collection: () => p.oneToMany(JourneyTrips).hidden().mappedBy('trip'), + packing_bags_collection: () => p.oneToMany(PackingBags).hidden().mappedBy('trip'), + packing_category_assignees_collection: () => p.oneToMany(PackingCategoryAssignees).hidden().mappedBy('trip'), + packing_items_collection: () => p.oneToMany(PackingItems).hidden().mappedBy('trip'), + photos_collection: () => p.oneToMany(Photos).hidden().mappedBy('trip'), + places_collection: () => p.oneToMany(Places).hidden().mappedBy('trip'), + reservations_collection: () => p.oneToMany(Reservations).hidden().mappedBy('trip'), + roadtrip_day_boundaries_collection: () => p.oneToMany(RoadtripDayBoundaries).hidden().mappedBy('trip'), + roadtrip_preferences_collection: () => p.oneToMany(RoadtripPreferences).hidden().mappedBy('trip'), + share_tokens_collection: () => p.oneToMany(ShareTokens).hidden().mappedBy('trip'), + todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).hidden().mappedBy('trip'), + todo_items_collection: () => p.oneToMany(TodoItems).hidden().mappedBy('trip'), + trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).hidden().mappedBy('trip'), + trip_files_collection: () => p.oneToMany(TripFiles).hidden().mappedBy('trip'), + trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).hidden().mappedBy('trip'), + trip_members_collection: () => p.oneToMany(TripMembers).hidden().mappedBy('trip'), + trip_photos_collection: () => p.oneToMany(TripPhotos).hidden().mappedBy('trip'), }, }); diff --git a/server/src/db/repositories/DayNotes.repository.ts b/server/src/db/repositories/DayNotes.repository.ts new file mode 100644 index 000000000..109c75549 --- /dev/null +++ b/server/src/db/repositories/DayNotes.repository.ts @@ -0,0 +1,53 @@ +import type { DayNotes } from '../entities/DayNotes.entity'; +import { toRow } from './_shared/rows'; +import { EntityRepository } from '@mikro-orm/sql'; + +export interface DayNoteRow { + id: number; + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + created_at: string | null; + color: string | null; +} + +export class DayNotesRepository extends EntityRepository { + /** + * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)` + * — `created_at` is left to `DEFAULT CURRENT_TIMESTAMP`, then the row is + * re-read so the returned row is the stored one. + * + * `created_at` alone would not need the re-read: a `defaultRaw` column the + * insert does not name is in its `returning` clause, so it is already the + * stored text by the time `flush()` resolves. The re-read is there for the + * columns that are not — a nullable column this insert never named would + * otherwise be `undefined` on the entity and missing from the row. + */ + async createNote(input: { + day_id: number; + trip_id: number; + text: string; + time?: string | null; + icon?: string | null; + sort_order?: number | null; + color?: string | null; + }): Promise { + const note = this.create({ + day: input.day_id, + day_id: input.day_id, + trip: input.trip_id, + trip_id: input.trip_id, + text: input.text, + time: input.time ?? null, + icon: input.icon ?? '📝', + sort_order: input.sort_order ?? 0, + color: input.color ?? null, + }); + await this.getEntityManager().persist(note).flush(); + await this.getEntityManager().refresh(note); + return toRow(note) as DayNoteRow; + } +} diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts new file mode 100644 index 000000000..5ff8c616e --- /dev/null +++ b/server/src/db/repositories/Days.repository.ts @@ -0,0 +1,74 @@ +import type { Days } from '../entities/Days.entity'; +import { toRow } from './_shared/rows'; +import { EntityRepository } from '@mikro-orm/sql'; + +/** A `days` row as the API emits it. */ +export interface DayRow { + id: number; + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + title: string | null; + default_transport_mode: string | null; +} + +export class DaysRepository extends EntityRepository { + /** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */ + async listByTrip(trip_id: number): Promise { + const days = await this.find({ trip: trip_id }, { orderBy: { day_number: 'asc' } }); + return days.map((d) => toRow(d) as DayRow); + } + + /** `SELECT * FROM days WHERE id = ? AND trip_id = ?` */ + async findInTrip(id: number, trip_id: number): Promise { + const day = await this.findOne({ id, trip: trip_id }); + return day ? (toRow(day) as DayRow) : undefined; + } + + /** + * The trip's highest `day_number`, 0 when it has no days. + * + * The legacy statement was `SELECT MAX(day_number) ... WHERE trip_id = ?`; + * this reads the same value as the first row of a descending order, which + * needs no SQL function spelled outside `db/dialect/sql-functions.ts` and no + * entity hydrated into the identity map. + */ + async maxDayNumber(trip_id: number): Promise { + const row = await this.qb('d') + .select('d.day_number') + .where({ trip: trip_id }) + .orderBy({ day_number: 'desc' }) + .limit(1) + .execute<{ day_number: number | null } | undefined>('get', false); + return row?.day_number ?? 0; + } + + /** + * `INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)` + * followed by the legacy re-select, so the caller gets the stored row (with + * every column default applied) and not what it asked to store. + * + * The re-select is not optional: the insert's `returning` clause carries only + * the generated and `defaultRaw` columns, so a column this insert never named + * (`title`, `default_transport_mode`) would still be `undefined` on the + * entity and would simply be missing from the row. + */ + async createDay(input: { + trip_id: number; + day_number: number; + date?: string | null; + notes?: string | null; + }): Promise { + const day = this.create({ + trip: input.trip_id, + trip_id: input.trip_id, + day_number: input.day_number, + date: input.date ?? null, + notes: input.notes ?? null, + }); + await this.getEntityManager().persist(day).flush(); + await this.getEntityManager().refresh(day); + return toRow(day) as DayRow; + } +} diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts new file mode 100644 index 000000000..254178690 --- /dev/null +++ b/server/src/db/repositories/Trips.repository.ts @@ -0,0 +1,38 @@ +import type { Trips } from '../entities/Trips.entity'; +import { EntityRepository } from '@mikro-orm/sql'; + +/** What a trip-scoped request learns about the trip once access is verified. */ +export interface TripAccess { + id: number; + user_id: number; + currency: string | null; +} + +export class TripsRepository extends EntityRepository { + /** + * The trip if the user owns it or is a member of it, else undefined. + * + * Byte-for-byte the legacy `canAccessTrip` statement: + * SELECT t.id, t.user_id, t.currency FROM trips t + * LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + * WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + */ + async findAccessible(trip_id: number, user_id: number): Promise { + const row = await this.qb('t') + .select(['t.id', 't.user', 't.currency']) + .leftJoin('t.trip_members_collection', 'm', { 'm.user': user_id }) + .where({ 't.id': trip_id }) + .andWhere({ $or: [{ 't.user': user_id }, { 'm.user': { $ne: null } }] }) + // `mapResults: false` leaves the driver's row alone: the keys are the + // column names, which is why the result type is spelled out here rather + // than inferred from the entity's properties. + .execute<{ id: number; user_id: number; currency: string | null } | undefined>('get', false); + if (!row) return undefined; + return { id: row.id, user_id: row.user_id, currency: row.currency ?? null }; + } + + /** `SELECT id FROM trips WHERE id = ? AND user_id = ?` */ + async isOwner(trip_id: number, user_id: number): Promise { + return (await this.count({ id: trip_id, user: user_id })) > 0; + } +} diff --git a/server/src/db/repositories/_shared/rows.ts b/server/src/db/repositories/_shared/rows.ts new file mode 100644 index 000000000..1bcc20a62 --- /dev/null +++ b/server/src/db/repositories/_shared/rows.ts @@ -0,0 +1,14 @@ +import { wrap, type EntityDTO } from '@mikro-orm/core'; + +/** + * The plain row a service (and, through it, the API) sees. + * + * `toObject()` drops the ORM machinery — `Ref` wrappers, hidden relations, + * collections that were never loaded — and leaves the column-named scalars, + * which is exactly the `SELECT *` row the legacy code returned. Repositories + * return this, never a live entity, so a service cannot lazy-load or mutate + * state behind the repository's back. + */ +export function toRow(entity: T): EntityDTO { + return wrap(entity).toObject() as EntityDTO; +} diff --git a/server/tests/unit/db/repositories/DayNotes.repository.test.ts b/server/tests/unit/db/repositories/DayNotes.repository.test.ts new file mode 100644 index 000000000..4ae35be76 --- /dev/null +++ b/server/tests/unit/db/repositories/DayNotes.repository.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createTestDb, resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createDay, createTrip, createUser } from '../../../helpers/factories'; +import { DayNotes } from '../../../../src/db/entities/DayNotes.entity'; +import type { DayNotesRepository } from '../../../../src/db/repositories/DayNotes.repository'; +import { DB_TIMESTAMP_RE } from '../../../../src/db/types'; + +const testDb = createTestDb(); +let t: TestOrm; +let notes: DayNotesRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + notes = t.repo(DayNotes) as DayNotesRepository; +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +describe('DayNotesRepository timestamps', () => { + it('NOTEREPO-001: an ORM insert leaves created_at to the column default, as text', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); + const stored = testDb.prepare('SELECT created_at, typeof(created_at) AS kind FROM day_notes WHERE id = ?').get(row.id) as { created_at: string; kind: string }; + expect(stored.kind).toBe('text'); + expect(stored.created_at).toMatch(DB_TIMESTAMP_RE); + expect(row.created_at).toBe(stored.created_at); + }); + + it('NOTEREPO-002: the returned row is the SELECT * row and the defaults are the legacy ones', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); + expect(row).toEqual(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(row.id)); + expect(row.icon).toBe('📝'); + expect(row.sort_order).toBe(0); + }); + + it('NOTEREPO-003: date() still works on a row the ORM wrote', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch' }); + const d = testDb.prepare('SELECT date(created_at) AS d FROM day_notes WHERE id = ?').get(row.id) as { d: string }; + expect(d.d).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); diff --git a/server/tests/unit/db/repositories/Days.repository.test.ts b/server/tests/unit/db/repositories/Days.repository.test.ts new file mode 100644 index 000000000..4c010b401 --- /dev/null +++ b/server/tests/unit/db/repositories/Days.repository.test.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createTestDb, resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { createDay, createTrip, createUser } from '../../../helpers/factories'; +import { Days } from '../../../../src/db/entities/Days.entity'; +import type { DaysRepository } from '../../../../src/db/repositories/Days.repository'; + +const testDb = createTestDb(); +let t: TestOrm; +let days: DaysRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + days = t.repo(Days) as DaysRepository; +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +function rawDay(id: number): unknown { + return testDb.prepare('SELECT * FROM days WHERE id = ?').get(id); +} + +describe('DaysRepository', () => { + it('DAYREPO-001: a row read through the ORM is the SELECT * row, key for key', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { date: '2026-07-01', title: 'Arrival' }); + const [row] = await days.listByTrip(trip.id); + expect(row).toEqual(rawDay(day.id)); + }); + + it('DAYREPO-002: the scalar FK hydrates without loading the trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + const entity = await t.repo(Days).findOne({ trip: trip.id }); + expect(entity?.trip_id).toBe(trip.id); + expect(entity?.trip.isInitialized()).toBe(false); + }); + + it('DAYREPO-003: listByTrip orders by day_number', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id, { day_number: 2 }); + createDay(testDb, trip.id, { day_number: 1 }); + const rows = await days.listByTrip(trip.id); + expect(rows.map((r) => r.day_number)).toEqual([1, 2]); + }); + + it('DAYREPO-004: findInTrip refuses a day from another trip', async () => { + const { user } = createUser(testDb); + const a = createTrip(testDb, user.id); + const b = createTrip(testDb, user.id); + const day = createDay(testDb, a.id); + expect(await days.findInTrip(day.id, b.id)).toBeUndefined(); + expect((await days.findInTrip(day.id, a.id))?.id).toBe(day.id); + }); + + it('DAYREPO-005: maxDayNumber is 0 for an empty trip and the max otherwise', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(await days.maxDayNumber(trip.id)).toBe(0); + createDay(testDb, trip.id, { day_number: 3 }); + expect(await days.maxDayNumber(trip.id)).toBe(3); + }); + + it('DAYREPO-006: createDay writes the legacy column set and returns the re-read row', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const row = await days.createDay({ trip_id: trip.id, day_number: 1, date: '2026-07-01' }); + expect(row).toEqual(rawDay(row.id)); + expect(row.notes).toBeNull(); + expect(row.trip_id).toBe(trip.id); + }); +}); diff --git a/server/tests/unit/db/repositories/Trips.repository.test.ts b/server/tests/unit/db/repositories/Trips.repository.test.ts new file mode 100644 index 000000000..858f1c265 --- /dev/null +++ b/server/tests/unit/db/repositories/Trips.repository.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { CAN_ACCESS_TRIP_SQL, createTestDb, resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { addTripMember, createTrip, createUser } from '../../../helpers/factories'; +import { Trips } from '../../../../src/db/entities/Trips.entity'; +import type { TripsRepository } from '../../../../src/db/repositories/Trips.repository'; + +const testDb = createTestDb(); +let t: TestOrm; +let trips: TripsRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + trips = t.repo(Trips) as TripsRepository; +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +const legacy = (tripId: number, userId: number) => testDb.prepare(CAN_ACCESS_TRIP_SQL).get(userId, tripId, userId); + +describe('TripsRepository.findAccessible — parity with canAccessTrip', () => { + it('TRIPREPO-001: the owner sees the trip, with the same three columns', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + expect(await trips.findAccessible(trip.id, user.id)).toEqual(legacy(trip.id, user.id)); + expect(await trips.findAccessible(trip.id, user.id)).toEqual({ id: trip.id, user_id: user.id, currency: 'EUR' }); + }); + + it('TRIPREPO-002: a member sees it, a stranger does not', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + expect(await trips.findAccessible(trip.id, member.id)).toEqual(legacy(trip.id, member.id)); + expect(await trips.findAccessible(trip.id, stranger.id)).toBeUndefined(); + expect(legacy(trip.id, stranger.id)).toBeUndefined(); + }); + + it('TRIPREPO-003: a member of another trip is still a stranger here', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const a = createTrip(testDb, owner.id); + const b = createTrip(testDb, owner.id); + addTripMember(testDb, a.id, member.id); + expect(await trips.findAccessible(b.id, member.id)).toBeUndefined(); + }); + + it('TRIPREPO-004: a null currency comes back null, not undefined', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id); + expect(await trips.findAccessible(trip.id, user.id)).toEqual({ id: trip.id, user_id: user.id, currency: null }); + }); + + it('TRIPREPO-005: isOwner is true for the owner only', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + expect(await trips.isOwner(trip.id, owner.id)).toBe(true); + expect(await trips.isOwner(trip.id, member.id)).toBe(false); + }); + + it('TRIPREPO-006: toObject on a trip omits the hidden relation and every unloaded collection', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const entity = await t.repo(Trips).findOneOrFail({ id: trip.id }); + const raw = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(trip.id) as Record; + const { wrap } = await import('@mikro-orm/core'); + expect(wrap(entity).toObject()).toEqual(raw); + }); +});