feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)

This commit is contained in:
jubnl
2026-09-21 09:39:40 +02:00
parent 479e2cb0a5
commit 229f4890df
10 changed files with 502 additions and 118 deletions
+13 -9
View File
@@ -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<Days>;
day_id!: number;
trip!: Ref<Trips>;
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<DayNotes> {}
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(),
},
});
+29 -28
View File
@@ -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<Trips>;
dayNumber!: number;
trip_id!: number;
day_number!: number;
date?: string | null;
notes?: string | null;
title?: string | null;
defaultTransportMode?: string | null;
dayAccommodationsCollection = new Collection<DayAccommodations>(this);
dayAccommodationsCollection1 = new Collection<DayAccommodations>(this);
dayAssignmentsCollection = new Collection<DayAssignments>(this);
dayNotesCollection = new Collection<DayNotes>(this);
photosCollection = new Collection<Photos>(this);
reservationDayPositionsCollection = new Collection<ReservationDayPositions>(this);
reservationsCollection = new Collection<Reservations>(this);
reservationsCollection1 = new Collection<Reservations>(this);
roadtripDayTracks: Ref<RoadtripDayTracks> | null = null;
roadtripViasCollection = new Collection<RoadtripVias>(this);
default_transport_mode?: string | null;
day_accommodations_collection = new Collection<DayAccommodations>(this);
day_accommodations_collection1 = new Collection<DayAccommodations>(this);
day_assignments_collection = new Collection<DayAssignments>(this);
day_notes_collection = new Collection<DayNotes>(this);
photos_collection = new Collection<Photos>(this);
reservation_day_positions_collection = new Collection<ReservationDayPositions>(this);
reservations_collection = new Collection<Reservations>(this);
reservations_collection1 = new Collection<Reservations>(this);
roadtrip_day_tracks: Ref<RoadtripDayTracks> | null = null;
roadtrip_vias_collection = new Collection<RoadtripVias>(this);
}
export class DaysRepository extends EntityRepository<Days> {}
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'),
},
});
+83 -81
View File
@@ -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<Users>;
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<BudgetCategoryOrder>(this);
budgetItemsCollection = new Collection<BudgetItems>(this);
budgetSettlementsCollection = new Collection<BudgetSettlements>(this);
collabLinksCollection = new Collection<CollabLinks>(this);
collabMessagesCollection = new Collection<CollabMessages>(this);
collabNotesCollection = new Collection<CollabNotes>(this);
collabPollsCollection = new Collection<CollabPolls>(this);
dawarichVisitSuggestionsCollection = new Collection<DawarichVisitSuggestions>(this);
dayAccommodationsCollection = new Collection<DayAccommodations>(this);
dayNotesCollection = new Collection<DayNotes>(this);
daysCollection = new Collection<Days>(this);
inviteTokensCollection = new Collection<InviteTokens>(this);
journeyEntriesCollection = new Collection<JourneyEntries>(this);
journeyTripsCollection = new Collection<JourneyTrips>(this);
packingBagsCollection = new Collection<PackingBags>(this);
packingCategoryAssigneesCollection = new Collection<PackingCategoryAssignees>(this);
packingItemsCollection = new Collection<PackingItems>(this);
photosCollection = new Collection<Photos>(this);
placesCollection = new Collection<Places>(this);
reservationsCollection = new Collection<Reservations>(this);
roadtripDayBoundariesCollection = new Collection<RoadtripDayBoundaries>(this);
roadtripPreferencesCollection = new Collection<RoadtripPreferences>(this);
shareTokensCollection = new Collection<ShareTokens>(this);
todoCategoryAssigneesCollection = new Collection<TodoCategoryAssignees>(this);
todoItemsCollection = new Collection<TodoItems>(this);
tripAlbumLinksCollection = new Collection<TripAlbumLinks>(this);
tripFilesCollection = new Collection<TripFiles>(this);
tripInviteTokensCollection = new Collection<TripInviteTokens>(this);
tripMembersCollection = new Collection<TripMembers>(this);
tripPhotosCollection = new Collection<TripPhotos>(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<BudgetCategoryOrder>(this);
budget_items_collection = new Collection<BudgetItems>(this);
budget_settlements_collection = new Collection<BudgetSettlements>(this);
collab_links_collection = new Collection<CollabLinks>(this);
collab_messages_collection = new Collection<CollabMessages>(this);
collab_notes_collection = new Collection<CollabNotes>(this);
collab_polls_collection = new Collection<CollabPolls>(this);
dawarich_visit_suggestions_collection = new Collection<DawarichVisitSuggestions>(this);
day_accommodations_collection = new Collection<DayAccommodations>(this);
day_notes_collection = new Collection<DayNotes>(this);
days_collection = new Collection<Days>(this);
invite_tokens_collection = new Collection<InviteTokens>(this);
journey_entries_collection = new Collection<JourneyEntries>(this);
journey_trips_collection = new Collection<JourneyTrips>(this);
packing_bags_collection = new Collection<PackingBags>(this);
packing_category_assignees_collection = new Collection<PackingCategoryAssignees>(this);
packing_items_collection = new Collection<PackingItems>(this);
photos_collection = new Collection<Photos>(this);
places_collection = new Collection<Places>(this);
reservations_collection = new Collection<Reservations>(this);
roadtrip_day_boundaries_collection = new Collection<RoadtripDayBoundaries>(this);
roadtrip_preferences_collection = new Collection<RoadtripPreferences>(this);
share_tokens_collection = new Collection<ShareTokens>(this);
todo_category_assignees_collection = new Collection<TodoCategoryAssignees>(this);
todo_items_collection = new Collection<TodoItems>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
trip_files_collection = new Collection<TripFiles>(this);
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
trip_members_collection = new Collection<TripMembers>(this);
trip_photos_collection = new Collection<TripPhotos>(this);
}
export class TripsRepository extends EntityRepository<Trips> {}
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'),
},
});
@@ -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<DayNotes> {
/**
* `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<DayNoteRow> {
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;
}
}
@@ -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<Days> {
/** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */
async listByTrip(trip_id: number): Promise<DayRow[]> {
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<DayRow | undefined> {
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<number> {
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<DayRow> {
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;
}
}
@@ -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<Trips> {
/**
* 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<TripAccess | undefined> {
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<boolean> {
return (await this.count({ id: trip_id, user: user_id })) > 0;
}
}
@@ -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<T extends object>(entity: T): EntityDTO<T> {
return wrap(entity).toObject() as EntityDTO<T>;
}
@@ -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}$/);
});
});
@@ -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);
});
});
@@ -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<string, unknown>;
const { wrap } = await import('@mikro-orm/core');
expect(wrap(entity).toObject()).toEqual(raw);
});
});