refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 04:16:07 +02:00
parent ad6e1ce8f0
commit 845320e0a4
45 changed files with 1043 additions and 1026 deletions
@@ -84,7 +84,11 @@ export class AssignmentsService {
* the journey stays in sync. Non-fatal, like the route's try/catch.
*/
reconcile(tripId: string | number, socketId?: string): void {
try { this.journey.reconcileTripSkeletons(Number(tripId), socketId); } catch { /* non-fatal */ }
// R1.5: reconcile() is called fire-and-forget from a dozen sync call sites across
// this domain; reconcileTripSkeletons is now async, so the rejection is caught here
// instead of escaping as an unhandled promise rejection — same "non-fatal" contract
// the try/catch gave it before the sweep.
void this.journey.reconcileTripSkeletons(Number(tripId), socketId).catch(() => { /* non-fatal */ });
}
/**
@@ -291,18 +291,18 @@ export class DawarichSuggestionsService {
* arrival time fills the entry's time — the two fields a person would
* otherwise copy off the suggestion by hand.
*/
private acceptAsJournalEntry(
private async acceptAsJournalEntry(
userId: number,
row: SuggestionRow,
body: DawarichAccept,
sid?: string,
): DawarichAcceptResult {
): Promise<DawarichAcceptResult> {
if (body.journalId === undefined) {
throw new AcceptError('journal_required', 'A journey is required to create an entry', 400);
}
// canEdit is the journey domain's own answer, and createEntry asks it again.
// Asking here too is what turns "silently did nothing" into a 403.
if (!this.journey.canEdit(body.journalId, userId)) {
if (!(await this.journey.canEdit(body.journalId, userId))) {
throw new AcceptError('journal_forbidden', 'Journey not found', 404);
}
@@ -314,8 +314,8 @@ export class DawarichSuggestionsService {
// that one notification can still outrun a rollback. Moving it out means
// changing that domain's contract, which this integration has no business
// doing; the write itself is what had to stop being two.
const entry = this.db.transaction(() => {
const created = this.journey.createEntry(
const entry = await this.uow.transactional(async () => {
const created = await this.journey.createEntry(
body.journalId,
userId,
{
+5 -5
View File
@@ -63,7 +63,7 @@ export class JournalRpc {
await this.requireJourneyAddon();
// listEntries self-gates via canAccessJourney and returns null when the user
// cannot see it.
const entries = this.journey.listEntries(journeyId, userId);
const entries = await this.journey.listEntries(journeyId, userId);
if (entries === null) throw new ForbiddenResource(`no access to journey ${journeyId}`);
return entries;
}
@@ -75,7 +75,7 @@ export class JournalRpc {
if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required');
const journeyId = num(params.journeyId, 'journeyId');
await this.requireJourneyAddon();
const entry = this.journey.createEntry(journeyId, userId, input as never);
const entry = await this.journey.createEntry(journeyId, userId, input as never);
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
return entry;
}
@@ -85,7 +85,7 @@ export class JournalRpc {
const userId = this.requireJournalUser(ctx, 'writes');
const entryId = num(params.entryId, 'entryId');
await this.requireJourneyAddon();
const entry = this.journey.updateEntry(entryId, userId, asPayload(params.input) as never);
const entry = await this.journey.updateEntry(entryId, userId, asPayload(params.input) as never);
if (!entry) throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
return entry;
}
@@ -95,7 +95,7 @@ export class JournalRpc {
const userId = this.requireJournalUser(ctx, 'writes');
const entryId = num(params.entryId, 'entryId');
await this.requireJourneyAddon();
if (!this.journey.deleteEntry(entryId, userId)) {
if (!(await this.journey.deleteEntry(entryId, userId))) {
throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
}
return { deleted: true };
@@ -192,7 +192,7 @@ export class JournalRpc {
const userId = this.requireJournalUser(ctx, 'writes');
const journeyId = num(params.journeyId, 'journeyId');
await this.requireJourneyAddon();
if (!this.journey.deleteJourney(journeyId, userId)) {
if (!(await this.journey.deleteJourney(journeyId, userId))) {
throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
}
return { deleted: true };
+19 -19
View File
@@ -34,8 +34,8 @@ export class JourneyBookService {
) {}
/** Null when the journey does not exist or the user cannot reach it. */
private canAccess(journeyId: number, userId: number): boolean {
return !!this.journey.canAccessJourney(journeyId, userId);
private async canAccess(journeyId: number, userId: number): Promise<boolean> {
return !!(await this.journey.canAccessJourney(journeyId, userId));
}
/**
@@ -43,7 +43,7 @@ export class JourneyBookService {
* same owner-or-editor check the entry and photo writes take. canAccess also
* covers role 'viewer', who may read the book but must not overwrite it.
*/
private canWrite(journeyId: number, userId: number): boolean {
private async canWrite(journeyId: number, userId: number): Promise<boolean> {
return this.journey.canEdit(journeyId, userId);
}
@@ -53,7 +53,7 @@ export class JourneyBookService {
* Public so the controller can tell "no book yet" from "no journey" without
* running a query built for something else.
*/
canOpen(journeyId: number, userId: number): boolean {
async canOpen(journeyId: number, userId: number): Promise<boolean> {
return this.canAccess(journeyId, userId);
}
@@ -72,8 +72,8 @@ export class JourneyBookService {
};
}
listBooks(journeyId: number, userId: number): BookSummary[] | null {
if (!this.canAccess(journeyId, userId)) return null;
async listBooks(journeyId: number, userId: number): Promise<BookSummary[] | null> {
if (!(await this.canAccess(journeyId, userId))) return null;
const rows = this.db
.prepare(`
SELECT id, journey_id, title, version, updated_at, updated_by
@@ -99,8 +99,8 @@ export class JourneyBookService {
* the same trip is an obvious thing to want and adding a column later is
* harder than not needing to.
*/
getBook(journeyId: number, userId: number): BookRecord | null {
if (!this.canAccess(journeyId, userId)) return null;
async getBook(journeyId: number, userId: number): Promise<BookRecord | null> {
if (!(await this.canAccess(journeyId, userId))) return null;
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
@@ -120,12 +120,12 @@ export class JourneyBookService {
* moved. Throwing would be the obvious shape and the wrong one: a conflict is
* an ordinary outcome of two people working, not an exception.
*/
saveBook(
async saveBook(
journeyId: number,
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): { record: BookRecord } | { conflict: BookRecord } | null {
if (!this.canWrite(journeyId, userId)) return null;
): Promise<{ record: BookRecord } | { conflict: BookRecord } | null> {
if (!(await this.canWrite(journeyId, userId))) return null;
const document = JSON.stringify(normalizeBookDocument(input.document));
const existing = this.db
@@ -139,7 +139,7 @@ export class JourneyBookService {
VALUES (?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)
`)
.run(journeyId, input.title, document, userId, userId);
return { record: this.byId(Number(result.lastInsertRowid))! };
return { record: (await this.byId(Number(result.lastInsertRowid)))! };
}
/*
@@ -164,13 +164,13 @@ export class JourneyBookService {
.run(input.title, document, userId, existing.id, base);
if (result.changes === 0) {
return { conflict: this.byId(existing.id)! };
return { conflict: (await this.byId(existing.id))! };
}
return { record: this.byId(existing.id)! };
return { record: (await this.byId(existing.id))! };
}
deleteBook(journeyId: number, userId: number): boolean | null {
if (!this.canWrite(journeyId, userId)) return null;
async deleteBook(journeyId: number, userId: number): Promise<boolean | null> {
if (!(await this.canWrite(journeyId, userId))) return null;
const result = this.db
.prepare('DELETE FROM journey_books WHERE journey_id = ?')
.run(journeyId);
@@ -188,8 +188,8 @@ export class JourneyBookService {
* The saver is excluded by socket id, the same way every other TREK mutation
* does it, so the client that just saved does not process its own change.
*/
broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
this.journey.broadcastJourneyEvent(
async broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
await this.journey.broadcastJourneyEvent(
journeyId,
'journey:book:saved',
{ version: record.version, savedBy: userId },
@@ -197,7 +197,7 @@ export class JourneyBookService {
);
}
private byId(id: number): BookRecord | null {
private async byId(id: number): Promise<BookRecord | null> {
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
@@ -4,6 +4,7 @@ import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '..
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 { TrekPhotosRepository } from '../photos/trek-photos.repository';
@@ -94,6 +95,7 @@ export class JourneyDomainService {
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly photos: TrekPhotosRepository,
private readonly uow: UnitOfWork,
) {}
private ts(): number {
@@ -109,7 +111,7 @@ export class JourneyDomainService {
* journey is one question with one answer, and a second copy of this walk
* over contributors and owner would be a second answer waiting to disagree.
*/
broadcastJourneyEvent(
async broadcastJourneyEvent(
journeyId: number,
// Typed against the shared WS registry rather than left as `string`: the
// legacy broadcastToUser took anything, so a typo'd event name shipped as a
@@ -117,7 +119,7 @@ export class JourneyDomainService {
event: TrekWsUserEventName,
data: Record<string, unknown>,
excludeSocketId?: string | number,
) {
): Promise<void> {
const contributors = this.db.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ?').all(journeyId) as {
user_id: number;
}[];
@@ -135,7 +137,7 @@ export class JourneyDomainService {
// ── Access control ───────────────────────────────────────────────────────
canAccessJourney(journeyId: number, userId: number): Journey | null {
async canAccessJourney(journeyId: number, userId: number): Promise<Journey | null> {
const own = this.db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as
| Journey
| undefined;
@@ -147,12 +149,12 @@ export class JourneyDomainService {
return null;
}
isOwner(journeyId: number, userId: number): boolean {
async isOwner(journeyId: number, userId: number): Promise<boolean> {
return !!this.db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
}
canEdit(journeyId: number, userId: number): boolean {
if (this.isOwner(journeyId, userId)) return true;
async canEdit(journeyId: number, userId: number): Promise<boolean> {
if (await this.isOwner(journeyId, userId)) return true;
const c = this.db
.prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journeyId, userId) as { role: string } | undefined;
@@ -161,7 +163,7 @@ export class JourneyDomainService {
// ── Journey CRUD ─────────────────────────────────────────────────────────
listJourneys(userId: number) {
async listJourneys(userId: number) {
return this.db
.prepare(
`
@@ -186,14 +188,14 @@ export class JourneyDomainService {
})[];
}
createJourney(
async createJourney(
userId: number,
data: {
title: string;
subtitle?: string;
trip_ids?: number[];
},
): Journey {
): Promise<Journey> {
const now = this.ts();
const res = this.db
.prepare(
@@ -221,7 +223,7 @@ export class JourneyDomainService {
// trip_ids[0] would otherwise leak an arbitrary trip's cover image cross-tenant.
let coverTripId: number | undefined;
for (const tripId of data.trip_ids) {
if (this.addTripToJourney(journeyId, tripId, userId) && coverTripId === undefined) coverTripId = tripId;
if ((await this.addTripToJourney(journeyId, tripId, userId)) && coverTripId === undefined) coverTripId = tripId;
}
if (coverTripId !== undefined) {
@@ -239,8 +241,8 @@ export class JourneyDomainService {
return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
getJourneyFull(journeyId: number, userId: number) {
const journey = this.canAccessJourney(journeyId, userId);
async getJourneyFull(journeyId: number, userId: number) {
const journey = await this.canAccessJourney(journeyId, userId);
if (!journey) return null;
const entries = this.db
@@ -342,7 +344,7 @@ export class JourneyDomainService {
};
}
updateJourney(
async updateJourney(
journeyId: number,
userId: number,
data: Partial<{
@@ -356,10 +358,10 @@ export class JourneyDomainService {
show_mood: boolean | number;
show_weather: boolean | number;
}>,
): Journey | null {
): Promise<Journey | null> {
// Journey-level settings (title, cover, status) are owner-only — editors
// may only edit entries and photos, not reshape the journey itself.
if (!this.isOwner(journeyId, userId)) return null;
if (!(await this.isOwner(journeyId, userId))) return null;
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
const allowed = [
@@ -394,8 +396,8 @@ export class JourneyDomainService {
return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
if (!this.canAccessJourney(journeyId, userId)) return null;
async updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
if (!(await this.canAccessJourney(journeyId, userId))) return null;
if (data.hide_skeletons !== undefined) {
this.db.prepare('UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?').run(
data.hide_skeletons ? 1 : 0,
@@ -417,24 +419,24 @@ export class JourneyDomainService {
* things you have already said you did not want is not a screen worth
* building. The count comes back so the caller can say what happened.
*/
restoreDismissedSuggestions(journeyId: number, userId: number): { restored: number } | null {
if (!this.canEdit(journeyId, userId)) return null;
async restoreDismissedSuggestions(journeyId: number, userId: number): Promise<{ restored: number } | null> {
if (!(await this.canEdit(journeyId, userId))) return null;
const res = this.db
.prepare('UPDATE journey_entries SET dismissed = 0 WHERE journey_id = ? AND dismissed = 1')
.run(journeyId);
if (res.changes > 0) this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes });
if (res.changes > 0) await this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored: res.changes });
return { restored: res.changes };
}
deleteJourney(journeyId: number, userId: number): boolean {
if (!this.isOwner(journeyId, userId)) return false;
async deleteJourney(journeyId: number, userId: number): Promise<boolean> {
if (!(await this.isOwner(journeyId, userId))) return false;
this.db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
return true;
}
// ── Trip management ──────────────────────────────────────────────────────
addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
async addTripToJourney(journeyId: number, tripId: number, userId: number): Promise<boolean> {
// Only attach a trip the caller can actually access — otherwise a journey
// owner could pull an arbitrary trip's places + photos into their journey
// (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped
@@ -443,7 +445,7 @@ export class JourneyDomainService {
// And a journey the caller can actually reach. Without this, any logged-in user
// could link a trip of theirs into a stranger's journey and seed entries and
// photos there — the MCP tool has always checked this, the REST route never did.
if (!this.canAccessJourney(journeyId, userId)) return false;
if (!(await this.canAccessJourney(journeyId, userId))) return false;
const now = this.ts();
try {
this.db.prepare('INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(
@@ -456,19 +458,19 @@ export class JourneyDomainService {
}
// sync skeleton entries for all places in this trip
this.syncTripPlaces(journeyId, tripId, userId);
await this.syncTripPlaces(journeyId, tripId, userId);
// Trip photos are deliberately NOT pulled in any more (#1614). Photos live in
// journeys now: the trip-photo surface lost its UI in 3.1.0, nothing writes to
// it on an install newer than that, and copying rows between the two was what
// let a photo one member had chosen not to share reach a journey at all. The
// table and its (unreferenced) routes stay for one more release rather than
// being dropped in an append-only migration.
this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
await this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
if (!this.isOwner(journeyId, userId)) return false;
async removeTripFromJourney(journeyId: number, tripId: number, userId: number): Promise<boolean> {
if (!(await this.isOwner(journeyId, userId))) return false;
// remove skeleton entries that haven't been filled in
this.db.prepare(
@@ -492,7 +494,7 @@ export class JourneyDomainService {
// ── Sync engine ──────────────────────────────────────────────────────────
syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
async syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
const places = this.db
.prepare(
`
@@ -531,7 +533,7 @@ export class JourneyDomainService {
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
dateMaxOrder.set(entryDate, nextOrder);
this.insertSkeletonEntry({
await this.insertSkeletonEntry({
journeyId,
tripId,
placeId: place.id,
@@ -550,7 +552,7 @@ export class JourneyDomainService {
}
// called when a trip place is created
onPlaceCreated(tripId: number, placeId: number) {
async onPlaceCreated(tripId: number, placeId: number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
journey_id: number;
}[];
@@ -589,7 +591,7 @@ export class JourneyDomainService {
.get(link.journey_id, entryDate) as { m: number | null };
const nextOrder = (maxOrder?.m ?? -1) + 1;
this.insertSkeletonEntry({
await this.insertSkeletonEntry({
journeyId: link.journey_id,
tripId,
placeId,
@@ -609,7 +611,7 @@ export class JourneyDomainService {
}
// called when a trip place is updated
onPlaceUpdated(placeId: number) {
async onPlaceUpdated(placeId: number) {
const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[];
if (!entries.length) return;
@@ -673,7 +675,7 @@ export class JourneyDomainService {
}
// called when a trip place is deleted
onPlaceDeleted(placeId: number) {
async onPlaceDeleted(placeId: number) {
const entries = this.db.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(placeId) as JourneyEntry[];
for (const entry of entries) {
@@ -711,7 +713,7 @@ export class JourneyDomainService {
}
}
private insertSkeletonEntry(p: {
private async insertSkeletonEntry(p: {
journeyId: number;
tripId: number;
placeId: number;
@@ -757,7 +759,7 @@ export class JourneyDomainService {
// only detached + annotated, mirroring onPlaceDeleted. Idempotent: a second call with
// no underlying change is a no-op (no writes, no broadcast). Called from every
// assignment mutation path.
reconcileTripSkeletons(tripId: number, sid?: string | number) {
async reconcileTripSkeletons(tripId: number, sid?: string | number) {
const links = this.db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as {
journey_id: number;
}[];
@@ -857,7 +859,7 @@ export class JourneyDomainService {
if (!found) {
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
dateMaxOrder.set(entryDate, nextOrder);
this.insertSkeletonEntry({
await this.insertSkeletonEntry({
journeyId: journey_id,
tripId,
placeId: place.id,
@@ -926,7 +928,7 @@ export class JourneyDomainService {
changed = true;
}
if (changed) this.broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid);
if (changed) await this.broadcastJourneyEvent(journey_id, 'journey:trip:synced', { tripId }, sid);
}
}
@@ -941,8 +943,8 @@ export class JourneyDomainService {
* Only places that actually carry geometry are returned, so a journey whose trips
* have no tracks answers with an empty list rather than a wall of null points.
*/
journeyTracks(journeyId: number, userId: number): JourneyTrack[] | null {
if (!this.canAccessJourney(journeyId, userId)) return null;
async journeyTracks(journeyId: number, userId: number): Promise<JourneyTrack[] | null> {
if (!(await this.canAccessJourney(journeyId, userId))) return null;
const rows = this.db.prepare(`
SELECT DISTINCT p.id AS place_id, p.trip_id, p.name, p.route_color, p.route_geometry
@@ -1025,8 +1027,8 @@ export class JourneyDomainService {
* (discussion #2064). It is still named, under `excluded`, because the only
* way to switch a stop back on is to be able to see it.
*/
journeyStats(journeyId: number, userId: number): JourneyStats | null {
if (!this.canAccessJourney(journeyId, userId)) return null;
async journeyStats(journeyId: number, userId: number): Promise<JourneyStats | null> {
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,
@@ -1245,8 +1247,8 @@ export class JourneyDomainService {
});
}
listEntries(journeyId: number, userId: number) {
if (!this.canAccessJourney(journeyId, userId)) return null;
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')
@@ -1273,7 +1275,7 @@ export class JourneyDomainService {
}));
}
createEntry(
async createEntry(
journeyId: number,
userId: number,
data: {
@@ -1293,8 +1295,8 @@ export class JourneyDomainService {
sort_order?: number;
},
sid?: string,
): JourneyEntryWire | null {
if (!this.canEdit(journeyId, userId)) return null;
): Promise<JourneyEntryWire | null> {
if (!(await this.canEdit(journeyId, userId))) return null;
const now = this.ts();
const maxOrder = this.db
@@ -1340,11 +1342,11 @@ export class JourneyDomainService {
.prepare('SELECT * FROM journey_entries WHERE id = ?')
.get(Number(res.lastInsertRowid)) as JourneyEntry,
);
this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
await this.broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
return created;
}
updateEntry(
async updateEntry(
entryId: number,
userId: number,
data: Partial<{
@@ -1366,10 +1368,10 @@ export class JourneyDomainService {
dismissed: boolean;
}>,
sid?: string,
): JourneyEntryWire | null {
): Promise<JourneyEntryWire | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
if (!(await this.canEdit(entry.journey_id, userId))) return null;
const fields: string[] = [];
const values: unknown[] = [];
@@ -1445,15 +1447,15 @@ export class JourneyDomainService {
const updated = decodeEntryRow(
this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry,
);
this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
return updated;
}
// Reorder entries (typically within a single day). Caller passes the new
// desired order of ids; each entry's sort_order is set to its index in the
// array. Only entries owned by this journey are accepted.
reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean {
if (!this.canEdit(journeyId, userId)) return false;
async reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): Promise<boolean> {
if (!(await this.canEdit(journeyId, userId))) return false;
if (!orderedIds.length) return true;
const placeholders = orderedIds.map(() => '?').join(',');
@@ -1464,20 +1466,19 @@ export class JourneyDomainService {
const now = this.ts();
const update = this.db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?');
const tx = this.db.connection.transaction(() => {
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);
});
tx();
this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid);
await this.broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid);
return true;
}
deleteEntry(entryId: number, userId: number, sid?: string): boolean {
async deleteEntry(entryId: number, userId: number, sid?: string): Promise<boolean> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!this.canEdit(entry.journey_id, userId)) 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
@@ -1489,10 +1490,10 @@ export class JourneyDomainService {
WHERE id = ?
`,
).run(this.ts(), entryId);
this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
} else {
this.db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
await this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
}
return true;
@@ -1503,13 +1504,13 @@ export class JourneyDomainService {
// Promote a skeleton suggestion to a concrete entry. Called whenever the user
// adds content (photo upload, provider photo, gallery link) — a suggestion
// with photos is no longer just a suggestion.
private promoteSkeletonIfNeeded(entry: JourneyEntry): void {
private async promoteSkeletonIfNeeded(entry: JourneyEntry): Promise<void> {
if (entry.type !== 'skeleton') return;
this.db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', this.ts(), entry.id);
}
// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
private ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
private async ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): Promise<number> {
const now = this.ts();
const maxOrderRow = this.db
.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?')
@@ -1527,7 +1528,7 @@ export class JourneyDomainService {
}
// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
private linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
private async linkGalleryPhotoToEntry(galleryId: number, entryId: number): Promise<JourneyPhoto | null> {
const now = this.ts();
const maxOrderRow = this.db
.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?')
@@ -1562,7 +1563,7 @@ export class JourneyDomainService {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
if (!(await this.canEdit(entry.journey_id, userId))) return null;
const trekPhotoId = await this.photos.getOrCreateLocal(
filePath,
@@ -1572,9 +1573,9 @@ export class JourneyDomainService {
media?.mediaType || 'image',
media?.durationMs ?? null,
);
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = this.linkGalleryPhotoToEntry(galleryId, entryId);
this.promoteSkeletonIfNeeded(entry);
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);
return result;
}
@@ -1589,7 +1590,7 @@ export class JourneyDomainService {
): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) return null;
if (!(await this.canEdit(entry.journey_id, userId))) return null;
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
@@ -1605,17 +1606,17 @@ export class JourneyDomainService {
.get(entryId, trekPhotoId);
if (alreadyLinked) return null;
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(entry.journey_id, trekPhotoId, caption))();
const result = this.linkGalleryPhotoToEntry(galleryId, entryId);
this.promoteSkeletonIfNeeded(entry);
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);
return result;
}
// Link a gallery photo (by its journey_photos.id) to an entry — idempotent.
linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
async linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): Promise<JourneyPhoto | null> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!this.canEdit(entry.journey_id, userId)) 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
@@ -1623,8 +1624,8 @@ export class JourneyDomainService {
| undefined;
if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null;
const result = this.linkGalleryPhotoToEntry(galleryRow.id, entryId);
this.promoteSkeletonIfNeeded(entry);
const result = await this.linkGalleryPhotoToEntry(galleryRow.id, entryId);
await this.promoteSkeletonIfNeeded(entry);
return result;
}
@@ -1634,7 +1635,7 @@ export class JourneyDomainService {
userId: number,
filePaths: { path: string; thumbnail?: string; mediaType?: string; durationMs?: number | null }[],
): Promise<JourneyPhoto[]> {
if (!this.canEdit(journeyId, userId)) return [];
if (!(await this.canEdit(journeyId, userId))) return [];
const results: any[] = [];
const now = this.ts();
const maxOrderRow = this.db
@@ -1668,17 +1669,17 @@ export class JourneyDomainService {
passphrase?: string,
mediaType: string = 'image',
): Promise<any | null> {
if (!this.canEdit(journeyId, userId)) return null;
if (!(await this.canEdit(journeyId, userId))) return null;
const trekPhotoId = await this.photos.getOrCreate(provider, assetId, userId, passphrase, mediaType);
const galleryId = this.db.connection.transaction(() => this.ensureInGallery(journeyId, trekPhotoId, caption))();
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;
}
// Unlink a photo from a specific entry; gallery row is preserved.
unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean {
async unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): Promise<boolean> {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!this.canEdit(entry.journey_id, userId)) 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 = ?')
@@ -1695,7 +1696,7 @@ export class JourneyDomainService {
| { id: number; journey_id: number; photo_id: number }
| undefined;
if (!row) return null;
if (!this.canEdit(row.journey_id, userId)) 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 }
@@ -1724,17 +1725,17 @@ export class JourneyDomainService {
);
}
updatePhoto(
async updatePhoto(
photoId: number,
userId: number,
data: { caption?: string; sort_order?: number },
): JourneyPhoto | null {
): Promise<JourneyPhoto | null> {
// 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;
if (!row) return null;
if (!this.canEdit(row.journey_id, userId)) 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
@@ -1760,7 +1761,7 @@ export class JourneyDomainService {
| { id: number; journey_id: number; photo_id: number }
| undefined;
if (!row) return null;
if (!this.canEdit(row.journey_id, userId)) 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 }
@@ -1774,43 +1775,43 @@ export class JourneyDomainService {
// ── Contributors ─────────────────────────────────────────────────────────
addContributor(
async addContributor(
journeyId: number,
userId: number,
targetUserId: number,
role: 'editor' | 'viewer',
): boolean {
if (!this.isOwner(journeyId, userId)) return false;
): Promise<boolean> {
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());
this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
} catch {
return false;
}
}
updateContributorRole(
async updateContributorRole(
journeyId: number,
userId: number,
targetUserId: number,
role: 'editor' | 'viewer',
): boolean {
if (!this.isOwner(journeyId, userId)) return false;
): Promise<boolean> {
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,
);
this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
}
removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
if (!this.isOwner(journeyId, userId)) return false;
async removeContributor(journeyId: number, userId: number, targetUserId: number): Promise<boolean> {
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,
@@ -1820,7 +1821,7 @@ export class JourneyDomainService {
// ── Suggestions ──────────────────────────────────────────────────────────
getSuggestions(userId: number) {
async getSuggestions(userId: number) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
return this.db
.prepare(
@@ -1842,7 +1843,7 @@ export class JourneyDomainService {
// ── User trips (for trip picker) ─────────────────────────────────────────
listUserTrips(userId: number) {
async listUserTrips(userId: number) {
return this.db
.prepare(
`
@@ -43,7 +43,7 @@ export class JourneyPublicController {
@Get(':token/photos/:photoId/:kind')
async photo(@Param('token') token: string, @Param('photoId') photoId: string, @Param('kind') kind: string, @Res() res: Response): Promise<void> {
const valid = this.journey.validateShareTokenForPhoto(token, Number(photoId));
const valid = await this.journey.validateShareTokenForPhoto(token, Number(photoId));
if (!valid) {
throw new HttpException({ error: 'Not found' }, 404);
}
@@ -58,7 +58,7 @@ export class JourneyPublicController {
@Param('kind') kind: string,
@Res() res: Response,
): Promise<void> {
const valid = this.journey.validateShareTokenForAsset(token, assetId);
const valid = await this.journey.validateShareTokenForAsset(token, assetId);
if (!valid) {
throw new HttpException({ error: 'Not found' }, 404);
}
@@ -39,14 +39,14 @@ export class JourneyShareService {
private readonly settings: SettingsService,
) {}
createOrUpdateJourneyShareLink(
async createOrUpdateJourneyShareLink(
journeyId: number,
createdBy: number,
permissions: JourneySharePermissions
): { token: string; created: boolean } | null {
): Promise<{ token: string; created: boolean } | null> {
// Public sharing is an owner-only action — editors/viewers must not be
// able to publish the journey or change which screens are shared.
if (!this.journey.isOwner(journeyId, createdBy)) return null;
if (!(await this.journey.isOwner(journeyId, createdBy))) return null;
const existing = this.db.prepare('SELECT token, share_timeline, share_gallery, share_map, newest_first FROM journey_share_tokens WHERE journey_id = ?')
.get(journeyId) as { token: string; share_timeline: number; share_gallery: number; share_map: number; newest_first: number } | undefined;
@@ -87,15 +87,15 @@ export class JourneyShareService {
* unpublished puts a "create link" button in front of somebody it will then
* refuse. Both the REST route and the MCP tool read the link through here.
*/
readJourneyShareLink(
async readJourneyShareLink(
journeyId: number,
userId: number,
): { allowed: false } | { allowed: true; link: JourneyShareTokenInfo | null } {
if (!this.journey.isOwner(journeyId, userId)) return { allowed: false };
return { allowed: true, link: this.getJourneyShareLink(journeyId) };
): Promise<{ allowed: false } | { allowed: true; link: JourneyShareTokenInfo | null }> {
if (!(await this.journey.isOwner(journeyId, userId))) return { allowed: false };
return { allowed: true, link: await this.getJourneyShareLink(journeyId) };
}
getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
async getJourneyShareLink(journeyId: number): Promise<JourneyShareTokenInfo | null> {
const row = this.db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
if (!row) return null;
return {
@@ -108,13 +108,13 @@ export class JourneyShareService {
};
}
deleteJourneyShareLink(journeyId: number, userId: number): boolean {
if (!this.journey.isOwner(journeyId, userId)) return false;
async deleteJourneyShareLink(journeyId: number, userId: number): Promise<boolean> {
if (!(await this.journey.isOwner(journeyId, userId))) return false;
this.db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
return true;
}
validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
async validateShareTokenForPhoto(token: string, photoId: number): Promise<{ journeyId: number; ownerId: number } | null> {
const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Photos only ever surface (inline or in the gallery) when share_gallery is on,
@@ -133,7 +133,7 @@ export class JourneyShareService {
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}
validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
async validateShareTokenForAsset(token: string, assetId: string): Promise<{ ownerId: number } | null> {
const row = this.db.prepare('SELECT journey_id, share_gallery FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Same as the unified photo proxy: no asset bytes leave the host unless the
+66 -66
View File
@@ -192,8 +192,8 @@ export class JourneyController {
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
list(@CurrentUser() user: User) {
return { journeys: this.journey.listJourneys(user.id) };
async list(@CurrentUser() user: User) {
return { journeys: await this.journey.listJourneys(user.id) };
}
@Post()
@@ -211,19 +211,19 @@ export class JourneyController {
}
@Get('suggestions')
suggestions(@CurrentUser() user: User) {
return { trips: this.journey.getSuggestions(user.id) };
async suggestions(@CurrentUser() user: User) {
return { trips: await this.journey.getSuggestions(user.id) };
}
@Get('available-trips')
availableTrips(@CurrentUser() user: User) {
return { trips: this.journey.listUserTrips(user.id) };
async availableTrips(@CurrentUser() user: User) {
return { trips: await this.journey.listUserTrips(user.id) };
}
// ── Entries (prefix /entries — before /:id) ─────────────────────────────
@Patch('entries/:entryId')
updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyEntryUpdateDto, @Headers('x-socket-id') socketId?: string) {
const result = this.journey.updateEntry(Number(entryId), user.id, body, socketId);
async updateEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyEntryUpdateDto, @Headers('x-socket-id') socketId?: string) {
const result = await this.journey.updateEntry(Number(entryId), user.id, body, socketId);
if (!result) {
throw new HttpException({ error: 'Entry not found' }, 404);
}
@@ -231,8 +231,8 @@ export class JourneyController {
}
@Delete('entries/:entryId')
deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) {
async deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.journey.deleteEntry(Number(entryId), user.id, socketId))) {
throw new HttpException({ error: 'Entry not found' }, 404);
}
return { success: true };
@@ -258,7 +258,7 @@ export class JourneyController {
continue;
}
// Mirror to Immich only when the user explicitly opted in (#730).
if (this.journey.immichAutoUploadEnabled(user.id)) {
if (await this.journey.immichAutoUploadEnabled(user.id)) {
try {
const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname);
if (immichId) {
@@ -357,12 +357,12 @@ export class JourneyController {
}
@Post('entries/:entryId/link-photo')
linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyLinkPhotoDto) {
async linkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyLinkPhotoDto) {
const journeyPhotoId = body.journey_photo_id ?? body.photo_id;
if (!journeyPhotoId) {
throw new HttpException({ error: 'journey_photo_id required' }, 400);
}
const result = this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id);
const result = await this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
@@ -371,15 +371,15 @@ export class JourneyController {
@Delete('entries/:entryId/photos/:journeyPhotoId')
@HttpCode(204)
unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): void {
if (!this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id)) {
async unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): Promise<void> {
if (!(await this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id))) {
throw new HttpException({ error: 'Not found or not allowed' }, 404);
}
}
@Patch('photos/:photoId')
updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: JourneyPhotoUpdateDto) {
const result = this.journey.updatePhoto(Number(photoId), user.id, body);
async updatePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string, @Body() body: JourneyPhotoUpdateDto) {
const result = await this.journey.updatePhoto(Number(photoId), user.id, body);
if (!result) {
throw new HttpException({ error: 'Photo not found' }, 404);
}
@@ -527,8 +527,8 @@ export class JourneyController {
// ── Journeys /:id ───────────────────────────────────────────────────────
@Get(':id')
get(@CurrentUser() user: User, @Param('id') id: string) {
const data = this.journey.getJourneyFull(Number(id), user.id);
async get(@CurrentUser() user: User, @Param('id') id: string) {
const data = await this.journey.getJourneyFull(Number(id), user.id);
if (!data) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -536,8 +536,8 @@ export class JourneyController {
}
@Patch(':id')
update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto) {
const result = this.journey.updateJourney(Number(id), user.id, body);
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto) {
const result = await this.journey.updateJourney(Number(id), user.id, body);
if (!result) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -552,7 +552,7 @@ export class JourneyController {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
await this.commitJourneyUploads([file]);
const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` });
const result = await this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` });
if (!result) {
await this.discardJourneyUploads([file]);
throw new HttpException({ error: 'Journey not found' }, 404);
@@ -561,8 +561,8 @@ export class JourneyController {
}
@Delete(':id')
remove(@CurrentUser() user: User, @Param('id') id: string) {
if (!this.journey.deleteJourney(Number(id), user.id)) {
async remove(@CurrentUser() user: User, @Param('id') id: string) {
if (!(await this.journey.deleteJourney(Number(id), user.id))) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return { success: true };
@@ -571,19 +571,19 @@ export class JourneyController {
// ── Journey trips ───────────────────────────────────────────────────────
@Post(':id/trips')
@HttpCode(200) // Express answers with res.json (200).
addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyAddTripDto) {
async addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyAddTripDto) {
if (!body.trip_id) {
throw new HttpException({ error: 'trip_id required' }, 400);
}
if (!this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id)) {
if (!(await this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Delete(':id/trips/:tripId')
removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
if (!this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id)) {
async removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
if (!(await this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
@@ -591,8 +591,8 @@ export class JourneyController {
// ── Entries under journey ───────────────────────────────────────────────
@Get(':id/entries')
listEntries(@CurrentUser() user: User, @Param('id') id: string) {
const entries = this.journey.listEntries(Number(id), user.id);
async listEntries(@CurrentUser() user: User, @Param('id') id: string) {
const entries = await this.journey.listEntries(Number(id), user.id);
if (!entries) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -605,8 +605,8 @@ export class JourneyController {
* planner is all it takes for it to show up here.
*/
@Get(':id/tracks')
listTracks(@CurrentUser() user: User, @Param('id') id: string) {
const tracks = this.journey.journeyTracks(Number(id), user.id);
async listTracks(@CurrentUser() user: User, @Param('id') id: string) {
const tracks = await this.journey.journeyTracks(Number(id), user.id);
if (!tracks) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -623,8 +623,8 @@ export class JourneyController {
* designing and never while printing.
*/
@Get(':id/stats')
stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = this.journey.journeyStats(Number(id), user.id);
async stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = await this.journey.journeyStats(Number(id), user.id);
if (!stats) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -640,9 +640,9 @@ export class JourneyController {
*/
@Get(':id/book')
getBook(@CurrentUser() user: User, @Param('id') id: string) {
const book = this.books.getBook(Number(id), user.id);
if (book === null && !this.books.canOpen(Number(id), user.id)) {
async getBook(@CurrentUser() user: User, @Param('id') id: string) {
const book = await this.books.getBook(Number(id), user.id);
if (book === null && !(await this.books.canOpen(Number(id), user.id))) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
// A journey with no book yet is not an error: Studio opens, lays one out
@@ -653,19 +653,19 @@ export class JourneyController {
@Put(':id/book')
@HttpCode(200)
saveBook(
async saveBook(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: BookSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
const result = this.books.saveBook(Number(id), user.id, {
const result = await this.books.saveBook(Number(id), user.id, {
title: body.title ?? '',
document: body.document,
baseVersion: body.baseVersion,
});
if (result === null) {
throw this.bookRefusal(Number(id), user.id);
throw await this.bookRefusal(Number(id), user.id);
}
if ('conflict' in result) {
/*
@@ -681,16 +681,16 @@ export class JourneyController {
409,
);
}
this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
await this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
return result.record;
}
@Delete(':id/book')
@HttpCode(204)
deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = this.books.deleteBook(Number(id), user.id);
async deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = await this.books.deleteBook(Number(id), user.id);
if (removed === null) {
throw this.bookRefusal(Number(id), user.id);
throw await this.bookRefusal(Number(id), user.id);
}
}
@@ -702,18 +702,18 @@ export class JourneyController {
* not exist. Someone who cannot see the journey at all still gets the 404 —
* the status is not an existence oracle for them.
*/
private bookRefusal(journeyId: number, userId: number): HttpException {
return this.books.canOpen(journeyId, userId)
private async bookRefusal(journeyId: number, userId: number): Promise<HttpException> {
return (await this.books.canOpen(journeyId, userId))
? new HttpException({ error: 'Not allowed' }, 403)
: new HttpException({ error: 'Journey not found' }, 404);
}
@Post(':id/entries')
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
async createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
if (!body.entry_date) {
throw new HttpException({ error: 'entry_date is required' }, 400);
}
const entry = this.journey.createEntry(Number(id), user.id, body, socketId);
const entry = await this.journey.createEntry(Number(id), user.id, body, socketId);
if (!entry) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
@@ -721,12 +721,12 @@ export class JourneyController {
}
@Put(':id/entries/reorder')
reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyReorderEntriesDto, @Headers('x-socket-id') socketId?: string) {
async reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyReorderEntriesDto, @Headers('x-socket-id') socketId?: string) {
const orderedIds = body.orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) {
throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400);
}
if (!this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId)) {
if (!(await this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
@@ -734,27 +734,27 @@ export class JourneyController {
// ── Contributors ────────────────────────────────────────────────────────
@Post(':id/contributors')
addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyContributorAddDto) {
async addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyContributorAddDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
if (!this.journey.addContributor(Number(id), user.id, Number(body.user_id), (body.role as 'editor' | 'viewer') || 'viewer')) {
if (!(await this.journey.addContributor(Number(id), user.id, Number(body.user_id), (body.role as 'editor' | 'viewer') || 'viewer'))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Patch(':id/contributors/:userId')
updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: JourneyContributorUpdateDto) {
if (!this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer')) {
async updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: JourneyContributorUpdateDto) {
if (!(await this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer'))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
}
@Delete(':id/contributors/:userId')
removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
if (!this.journey.removeContributor(Number(id), user.id, Number(userId))) {
async removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
if (!(await this.journey.removeContributor(Number(id), user.id, Number(userId)))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
@@ -763,8 +763,8 @@ export class JourneyController {
// ── Suggestions ─────────────────────────────────────────────────────────
@Post(':id/suggestions/restore')
@HttpCode(200)
restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) {
const result = this.journey.restoreDismissedSuggestions(Number(id), user.id);
async restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) {
const result = await this.journey.restoreDismissedSuggestions(Number(id), user.id);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
@@ -773,8 +773,8 @@ export class JourneyController {
// ── User Preferences ────────────────────────────────────────────────────
@Patch(':id/preferences')
preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) {
const result = this.journey.updateJourneyPreferences(Number(id), user.id, body);
async preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) {
const result = await this.journey.updateJourneyPreferences(Number(id), user.id, body);
if (!result) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
@@ -783,12 +783,12 @@ export class JourneyController {
// ── Share Link ──────────────────────────────────────────────────────────
@Get(':id/share-link')
getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
async getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
// Reading the token is owner-only. It refuses like its siblings rather than
// answering with a null link: "not published" and "not yours to see" are
// different answers, and a client told the first one offers a create button
// that the POST below then refuses.
const result = this.journey.getJourneyShareLink(Number(id), user.id);
const result = await this.journey.getJourneyShareLink(Number(id), user.id);
if (!result.allowed) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
@@ -797,8 +797,8 @@ export class JourneyController {
@Post(':id/share-link')
@HttpCode(200) // Express answers with res.json (200).
setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyShareLinkDto) {
const result = this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, {
async setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyShareLinkDto) {
const result = await this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, {
share_timeline: body.share_timeline as boolean | undefined,
share_gallery: body.share_gallery as boolean | undefined,
share_map: body.share_map as boolean | undefined,
@@ -811,8 +811,8 @@ export class JourneyController {
}
@Delete(':id/share-link')
deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) {
if (!this.journey.deleteJourneyShareLink(Number(id), user.id)) {
async deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) {
if (!(await this.journey.deleteJourneyShareLink(Number(id), user.id))) {
throw new HttpException({ error: 'Not allowed' }, 403);
}
return { success: true };
+44 -44
View File
@@ -123,8 +123,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneys(_args: unknown, ctx: McpContext) {
return ok({ journeys: this.journey.listJourneys(ctx.userId) });
async listJourneys(_args: unknown, ctx: McpContext) {
return ok({ journeys: await this.journey.listJourneys(ctx.userId) });
}
@Tool({
@@ -135,8 +135,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
getJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = this.journey.getJourneyFull(journeyId, ctx.userId);
async getJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = await this.journey.getJourneyFull(journeyId, ctx.userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
@@ -152,8 +152,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
getJourneyStats({ journeyId, include_route }: { journeyId: number; include_route?: boolean }, ctx: McpContext) {
const stats = this.journey.journeyStats(journeyId, ctx.userId);
async getJourneyStats({ journeyId, include_route }: { journeyId: number; include_route?: boolean }, ctx: McpContext) {
const stats = await this.journey.journeyStats(journeyId, ctx.userId);
if (!stats) return notFound('Journey not found or access denied.');
// Same payload as GET /api/journeys/:id/stats. The route is left out unless
// it was asked for: 400 coordinate pairs answer a different question than
@@ -171,9 +171,9 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ entries: this.journey.listEntries(journeyId, ctx.userId) });
async listJourneyEntries({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (!(await this.journey.canAccessJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ entries: await this.journey.listEntries(journeyId, ctx.userId) });
}
@Tool({
@@ -184,8 +184,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyContributors({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = this.journey.getJourneyFull(journeyId, ctx.userId);
async listJourneyContributors({ journeyId }: { journeyId: number }, ctx: McpContext) {
const journey = await this.journey.getJourneyFull(journeyId, ctx.userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as { contributors?: JourneyContributor[] }).contributors ?? [] });
}
@@ -198,8 +198,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
getJourneySuggestions(_args: unknown, ctx: McpContext) {
return ok({ trips: this.journey.getSuggestions(ctx.userId) });
async getJourneySuggestions(_args: unknown, ctx: McpContext) {
return ok({ trips: await this.journey.getSuggestions(ctx.userId) });
}
@Tool({
@@ -210,8 +210,8 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'read' },
})
listJourneyAvailableTrips(_args: unknown, ctx: McpContext) {
return ok({ trips: this.journey.listUserTrips(ctx.userId) });
async listJourneyAvailableTrips(_args: unknown, ctx: McpContext) {
return ok({ trips: await this.journey.listUserTrips(ctx.userId) });
}
// ── Write ───────────────────────────────────────────────────────────────
@@ -233,10 +233,10 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids });
const journey = await this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
return ok({ journey: this.journey.getJourneyFull(journey.id, ctx.userId) ?? journey });
return ok({ journey: (await this.journey.getJourneyFull(journey.id, ctx.userId)) ?? journey });
}
@Tool({
@@ -263,7 +263,7 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = this.journey.updateJourney(journeyId, ctx.userId, data);
const journey = await this.journey.updateJourney(journeyId, ctx.userId, data);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
@@ -280,7 +280,7 @@ export class JourneyMcp {
})
async restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
const result = await this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
if (!result) return notFound('Journey not found or access denied.');
return ok(result);
}
@@ -295,7 +295,7 @@ export class JourneyMcp {
})
async deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
if (!(await this.journey.deleteJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -309,8 +309,8 @@ export class JourneyMcp {
})
async addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
return ok({ success: this.journey.addTripToJourney(journeyId, tripId, ctx.userId) });
if (!(await this.journey.canAccessJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: await this.journey.addTripToJourney(journeyId, tripId, ctx.userId) });
}
@Tool({
@@ -323,7 +323,7 @@ export class JourneyMcp {
})
async removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId);
const success = await this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
}
@@ -362,10 +362,10 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.createEntry(journeyId, ctx.userId, data);
const entry = await this.journey.createEntry(journeyId, ctx.userId, data);
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
const enriched = this.journey.listEntries(journeyId, ctx.userId)?.find(e => e.id === entry.id) ?? entry;
const enriched = (await this.journey.listEntries(journeyId, ctx.userId))?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
@@ -407,10 +407,10 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = this.journey.updateEntry(entryId, ctx.userId, data, undefined);
const entry = await this.journey.updateEntry(entryId, ctx.userId, data, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
const enriched = this.journey.listEntries(entry.journey_id, ctx.userId)?.find(e => e.id === entry.id) ?? entry;
const enriched = (await this.journey.listEntries(entry.journey_id, ctx.userId))?.find(e => e.id === entry.id) ?? entry;
return ok({ entry: enriched });
}
@@ -424,7 +424,7 @@ export class JourneyMcp {
})
async deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.deleteEntry(entryId, ctx.userId, undefined)) return notFound('Entry not found or access denied.');
if (!(await this.journey.deleteEntry(entryId, ctx.userId, undefined))) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
@@ -441,7 +441,7 @@ export class JourneyMcp {
})
async reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined);
const success = await this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
}
@@ -463,7 +463,7 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.addContributor(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
if (!(await this.journey.addContributor(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -484,7 +484,7 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.');
if (!(await this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -501,7 +501,7 @@ export class JourneyMcp {
})
async removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.removeContributor(journeyId, ctx.userId, targetUserId)) return notFound('Journey not found or access denied.');
if (!(await this.journey.removeContributor(journeyId, ctx.userId, targetUserId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -521,7 +521,7 @@ export class JourneyMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons });
const result = await this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
return ok(result);
@@ -556,8 +556,8 @@ export class JourneyMcp {
// access gets an empty 200. Checking up front instead turns that silence
// into a sentence, and is what makes journeyId worth asking for: the entry
// has to be shown to belong to it before anything is written.
if (!this.journey.canEdit(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
if (entryId !== undefined && !this.journey.listEntries(journeyId, ctx.userId)?.some(e => e.id === entryId)) {
if (!(await this.journey.canEdit(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
if (entryId !== undefined && !(await this.journey.listEntries(journeyId, ctx.userId))?.some(e => e.id === entryId)) {
return notFound('Entry not found in this journey.');
}
@@ -593,10 +593,10 @@ export class JourneyMcp {
when: journeyAddonOn,
access: { group: 'journey', mode: 'share' },
})
getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
async getJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
// Same read the REST route uses, so the owner check cannot drift apart
// between the two surfaces: handing out the token is handing out the journey.
const result = this.share.readJourneyShareLink(journeyId, ctx.userId);
const result = await this.share.readJourneyShareLink(journeyId, ctx.userId);
if (!result.allowed) return notFound('Journey not found or access denied.');
return ok({ shareLink: result.link });
}
@@ -617,7 +617,7 @@ export class JourneyMcp {
})
async createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions);
const shareLink = await this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions);
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
}
@@ -632,7 +632,7 @@ export class JourneyMcp {
})
async deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.deleteJourneyShareLink(journeyId, ctx.userId)) return notFound('Journey not found or access denied.');
if (!(await this.share.deleteJourneyShareLink(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -651,7 +651,7 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'read' },
})
async journeysResource(uri: URL, ctx: McpContext) {
return jsonContent(uri.href, this.journey.listJourneys(ctx.userId));
return jsonContent(uri.href, await this.journey.listJourneys(ctx.userId));
}
@ResourceTemplate({
@@ -665,7 +665,7 @@ export class JourneyMcp {
async journeyDetailResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) {
const id = parseId(journeyId);
if (id === null) return accessDenied(uri.href);
const journey = this.journey.getJourneyFull(id, ctx.userId);
const journey = await this.journey.getJourneyFull(id, ctx.userId);
if (!journey) return accessDenied(uri.href);
return jsonContent(uri.href, journey);
}
@@ -681,8 +681,8 @@ export class JourneyMcp {
async journeyEntriesResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) {
const id = parseId(journeyId);
if (id === null) return accessDenied(uri.href);
if (!this.journey.canAccessJourney(id, ctx.userId)) return accessDenied(uri.href);
return jsonContent(uri.href, this.journey.listEntries(id, ctx.userId));
if (!(await this.journey.canAccessJourney(id, ctx.userId))) return accessDenied(uri.href);
return jsonContent(uri.href, await this.journey.listEntries(id, ctx.userId));
}
@ResourceTemplate({
@@ -696,7 +696,7 @@ export class JourneyMcp {
async journeyContributorsResource(uri: URL, { journeyId }: { journeyId: string | string[] }, ctx: McpContext) {
const id = parseId(journeyId);
if (id === null) return accessDenied(uri.href);
const journey = this.journey.getJourneyFull(id, ctx.userId);
const journey = await this.journey.getJourneyFull(id, ctx.userId);
if (!journey) return accessDenied(uri.href);
return jsonContent(uri.href, (journey as { contributors?: JourneyContributor[] }).contributors ?? []);
}
+1 -1
View File
@@ -87,7 +87,7 @@ export class JourneyService {
deleteJourneyShareLink(id: number, userId: number) { return this.share.deleteJourneyShareLink(id, userId); }
// Immich mirror (only when the user opted in via integration settings)
immichAutoUploadEnabled(userId: number): boolean {
async immichAutoUploadEnabled(userId: number): Promise<boolean> {
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
return !!prefs?.immich_auto_upload;
}
+4 -4
View File
@@ -45,7 +45,7 @@ export class OidcController {
res.status(403).json({ error: 'SSO login is disabled.' });
return;
}
const config = this.oidc.getOidcConfig();
const config = await this.oidc.getOidcConfig();
if (!config) {
res.status(400).json({ error: 'OIDC not configured' });
return;
@@ -120,7 +120,7 @@ export class OidcController {
const pending = this.oidc.consumeState(state);
if (!pending) return f('/login?oidc_error=invalid_state');
const config = this.oidc.getOidcConfig();
const config = await this.oidc.getOidcConfig();
if (!config) return f('/login?oidc_error=not_configured');
if (config.issuer && !config.issuer.startsWith('https://') && readEnv().app.isProduction) {
return f('/login?oidc_error=issuer_not_https');
@@ -181,11 +181,11 @@ export class OidcController {
});
}
this.oidc.touchLastLogin(result.user.id);
await this.oidc.touchLastLogin(result.user.id);
// Pass the flag through untouched: `undefined` must reach the token as
// "absent", not `false`, or the sliding renewal would later downgrade the
// default persistent cookie to a browser-session one (remember-me, #1927).
const jwtToken = this.oidc.generateToken(result.user, pending.remember);
const jwtToken = await this.oidc.generateToken(result.user, pending.remember);
const { code: authCode, binding } = this.oidc.createAuthCode(jwtToken, pending.remember);
// Bind the code to THIS browser, the way the state cookie binds the callback.
// The code rides home in a URL, so it is readable from history, from a
+5 -5
View File
@@ -306,7 +306,7 @@ export class OidcService implements OnModuleDestroy {
// OIDC configuration (env + DB)
// -------------------------------------------------------------------------
getOidcConfig(): OidcConfig | null {
async getOidcConfig(): Promise<OidcConfig | null> {
const get = (key: string) =>
(this.db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
@@ -445,7 +445,7 @@ export class OidcService implements OnModuleDestroy {
return base + path;
}
generateToken(user: { id: number }, remember?: boolean): string {
async generateToken(user: { id: number }, remember?: boolean): Promise<string> {
// Embed the current password_version so an OIDC-issued session is invalidated
// by a password change/reset exactly like a password-login session (the auth
// middleware compares this `pv` against users.password_version).
@@ -768,7 +768,7 @@ export class OidcService implements OnModuleDestroy {
// Update last_login timestamp
// -------------------------------------------------------------------------
touchLastLogin(userId: number): void {
async touchLastLogin(userId: number): Promise<void> {
this.db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(userId);
}
@@ -778,7 +778,7 @@ export class OidcService implements OnModuleDestroy {
// settings write: removing the SSO config while password login is off would lock
// every user out of the instance.
getOidcSettings() {
async getOidcSettings() {
const get = (key: string) =>
this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value || '';
const secret = decrypt_api_key(get('oidc_client_secret'));
@@ -811,7 +811,7 @@ export class OidcService implements OnModuleDestroy {
this.db.run('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)', key, val || '');
// All five writes are one SSO config — a partial apply would leave the
// instance with an issuer but no client id (or vice versa).
this.db.transaction(() => {
await this.uow.transactional(async () => {
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
@@ -318,13 +318,13 @@ export class PlaceEnrichmentService {
* would mean backfilling a row for every existing install just to keep them
* working, and there is nothing here that warrants a migration.
*/
enrichDisabled(): boolean {
async enrichDisabled(): Promise<boolean> {
const row = this.database.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', 'places_enrich_enabled');
return row?.value === 'false';
}
async enrich(userId: number, req: MapsPlaceEnrichmentRequest): Promise<MapsPlaceEnrichmentResult> {
if (this.enrichDisabled()) return { photos: [], description: null, facts: [], disabled: true };
if (await this.enrichDisabled()) return { photos: [], description: null, facts: [], disabled: true };
const placeId = req.placeId?.trim() || `coords:${req.lat}:${req.lng}`;
const lang = req.lang;
@@ -378,7 +378,7 @@ export class PlaceEnrichmentService {
// A summary or a link the lookup here fetched itself is the map's and keeps.
const fromCaller = req.details != null
&& (description?.source === 'osm' || ownFacts.some((fact) => fact.url != null));
if (!fromCaller) this.writeCache(placeId, lang, result);
if (!fromCaller) await this.writeCache(placeId, lang, result);
return result;
}
@@ -842,7 +842,7 @@ export class PlaceEnrichmentService {
}
}
private writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): void {
private async writeCache(placeId: string, lang: string | undefined, value: CachedEnrichment): Promise<void> {
try {
this.database.run(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)',
+3 -3
View File
@@ -153,7 +153,7 @@ export class PlacesMcp {
});
this.guards.safeBroadcast(tripId, 'place:created', { place: result.place });
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
try { this.journey.reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
try { await this.journey.reconcileTripSkeletons(tripId); } catch { /* non-fatal */ }
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
@@ -257,7 +257,7 @@ export class PlacesMcp {
if (!this.places.get(String(tripId), String(placeId))) {
return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
}
try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
try { await this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } // sync journeys before the row is gone
// The link is gone once the place is, so read it first (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, [placeId]);
const { deleted, cancelled } = await this.places.remove(String(tripId), String(placeId));
@@ -433,7 +433,7 @@ export class PlacesMcp {
// detach and left the entries as orphans.
const scoped = this.places.scopedIds(String(tripId), placeIds);
for (const id of scoped) {
try { this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ }
try { await this.journey.onPlaceDeleted(id); } catch { /* non-fatal */ }
}
// The link is gone once the places are, so read it first (#1298).
const expenseIds = this.places.linkedExpenseIds(tripId, scoped);
+9 -3
View File
@@ -119,10 +119,16 @@ export class PlacesRpc {
return { deleted: true };
}
/** Journey mirroring never fails a write that already succeeded. */
private mirrorJourneys(run: () => void): void {
/**
* Journey mirroring never fails a write that already succeeded.
*
* R1.5: `run` wraps a now-async `this.journey.onPlace*` call; its rejection is
* swallowed here (void + catch) rather than left unhandled — same "non-fatal"
* contract the synchronous try/catch gave it before the sweep.
*/
private mirrorJourneys(run: () => Promise<void>): void {
try {
run();
void run().catch(() => { /* non-fatal */ });
} catch {
/* non-fatal */
}
+6 -3
View File
@@ -1594,7 +1594,10 @@ export class PlacesService {
}
// Journey hooks — non-fatal, mirroring the route's try/catch wrappers.
onCreated(tripId: string, placeId: number): void { try { this.journey.onPlaceCreated(Number(tripId), placeId); } catch { /* non-fatal */ } }
onUpdated(placeId: number): void { try { this.journey.onPlaceUpdated(placeId); } catch { /* non-fatal */ } }
onDeleted(placeId: number): void { try { this.journey.onPlaceDeleted(placeId); } catch { /* non-fatal */ } }
// R1.5: called fire-and-forget from several sync call sites; onPlace* are now async,
// so the rejection is swallowed here (void + catch) rather than left unhandled —
// same "non-fatal" contract the try/catch gave it before the sweep.
onCreated(tripId: string, placeId: number): void { void this.journey.onPlaceCreated(Number(tripId), placeId).catch(() => { /* non-fatal */ }); }
onUpdated(placeId: number): void { void this.journey.onPlaceUpdated(placeId).catch(() => { /* non-fatal */ }); }
onDeleted(placeId: number): void { void this.journey.onPlaceDeleted(placeId).catch(() => { /* non-fatal */ }); }
}
@@ -85,7 +85,7 @@ export class JournalEntryRowsController {
// The entry's journey must be one the caller can access — same gate as a read.
const row = this.dbs.connection.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number } | undefined;
if (!row || !this.journey.canAccessJourney(row.journey_id, userId)) return { providers: [] };
if (!row || !(await this.journey.canAccessJourney(row.journey_id, userId))) return { providers: [] };
const ids = this.hooks.providersOf('journalEntryProvider');
const results = await Promise.all(
+1 -1
View File
@@ -192,7 +192,7 @@ export class RealtimeGateway
if (!user || !message?.journeyId) return undefined;
const journeyId = Number(message.journeyId);
if (!Number.isFinite(journeyId) || !this.journeys.canAccessJourney(journeyId, user.id)) {
if (!Number.isFinite(journeyId) || !(await this.journeys.canAccessJourney(journeyId, user.id))) {
return { type: 'error', message: 'Access denied' };
}
@@ -47,12 +47,12 @@ export class TripMembersController {
constructor(private readonly roster: TripMembersService, private readonly audit: AuditService) {}
@Get(':id/members')
members(@CurrentUser() user: User, @Param('id') id: string) {
const access = this.roster.canAccessTrip(id, user.id);
async members(@CurrentUser() user: User, @Param('id') id: string) {
const access = await this.roster.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
const { owner, members } = this.roster.listMembers(id, access.user_id);
const { owner, members } = await this.roster.listMembers(id, access.user_id);
return { owner, members, current_user_id: user.id };
}
@@ -60,7 +60,7 @@ export class TripMembersController {
@HttpCode(201)
async addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) {
const { identifier } = body;
const access = this.roster.canAccessTrip(id, user.id);
const access = await this.roster.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -68,7 +68,7 @@ export class TripMembersController {
throw new HttpException({ error: 'No permission to manage members' }, 403);
}
try {
const result = this.roster.addMember(id, identifier, access.user_id, user.id);
const result = await this.roster.addMember(id, identifier, access.user_id, user.id);
this.roster.notifyInvite(id, user, result.targetUserId, result.tripTitle, result.member.email);
return { member: result.member };
} catch (e: unknown) {
@@ -80,7 +80,7 @@ export class TripMembersController {
@Delete(':id/members/:userId')
async removeMember(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
const access = this.roster.canAccessTrip(id, user.id);
const access = await this.roster.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
@@ -88,7 +88,7 @@ export class TripMembersController {
if (targetId !== user.id && !(await this.roster.can('member_manage', user.role, access.user_id, user.id, access.user_id !== user.id))) {
throw new HttpException({ error: 'No permission to remove members' }, 403);
}
this.roster.removeMember(id, targetId);
await this.roster.removeMember(id, targetId);
return { success: true };
}
@@ -104,11 +104,11 @@ export class TripMembersController {
) {
const { newOwnerId } = body;
try {
const result = this.roster.transferOwnership(id, newOwnerId, user.id);
const result = await this.roster.transferOwnership(id, newOwnerId, user.id);
await this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
// Nudge everyone viewing the trip to re-read it so the new ownership and the
// recomputed permissions take effect live.
const updatedTrip = this.roster.getTripForViewer(id, user.id);
const updatedTrip = await this.roster.getTripForViewer(id, user.id);
this.roster.broadcast(id, 'trip:updated', { trip: updatedTrip }, socketId);
return { success: true };
} catch (e: unknown) {
@@ -122,12 +122,12 @@ export class TripMembersController {
@HttpCode(201)
@UseGuards(TripOwnerGuard)
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) {
async createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) {
// Whitespace-only names still 400 with the legacy body — the service throws
// ValidationError('Guest name is required') after trimming.
try {
// No notifyInvite: a guest has no inbox.
return this.roster.createGuest(id, body.name, user.id);
return await this.roster.createGuest(id, body.name, user.id);
} catch (e: unknown) {
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -137,9 +137,9 @@ export class TripMembersController {
@Put(':id/guests/:userId')
@UseGuards(TripOwnerGuard)
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) {
async renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) {
try {
if (!this.roster.renameGuest(id, Number.parseInt(userId), body.name)) {
if (!(await this.roster.renameGuest(id, Number.parseInt(userId), body.name))) {
throw new HttpException({ error: 'Guest not found' }, 404);
}
return { success: true };
@@ -74,7 +74,7 @@ export class TripMembersService {
return this.dbs.connection;
}
canAccessTrip(tripId: string | number, userId: number) {
async canAccessTrip(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
}
@@ -88,7 +88,7 @@ export class TripMembersService {
/** The trip in list shape, for the re-read a handover broadcasts. Same query the
* trip routes use, imported rather than copied so the two cannot drift. */
getTripForViewer(tripId: string | number, userId: number) {
async getTripForViewer(tripId: string | number, userId: number) {
return this.db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
}
@@ -109,7 +109,7 @@ export class TripMembersService {
// ── Members ───────────────────────────────────────────────────────────────
listMembers(tripId: string | number, tripOwnerId: number) {
async listMembers(tripId: string | number, tripOwnerId: number) {
// u.is_guest rides along (#1362) so guests stay assignable everywhere a member is,
// while the UI can badge them and suppress owner-only actions. The owner is never a guest.
const members = this.db.prepare(`
@@ -134,7 +134,7 @@ export class TripMembersService {
};
}
addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult {
async addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): Promise<AddMemberResult> {
if (!identifier) throw new ValidationError('Email or username required');
// Guests (#1362) are not invitable accounts — exclude them so a trip-scoped guest
@@ -162,7 +162,7 @@ export class TripMembersService {
};
}
removeMember(tripId: string | number, targetUserId: number): void {
async removeMember(tripId: string | number, targetUserId: number): Promise<void> {
this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId);
}
@@ -172,11 +172,11 @@ export class TripMembersService {
* becomes a regular member, so nobody loses access. Runs in a transaction so the
* owner pointer and the membership rows never diverge.
*/
transferOwnership(
async transferOwnership(
tripId: string | number,
newOwnerId: number,
currentOwnerId: number,
): TransferOwnershipResult {
): Promise<TransferOwnershipResult> {
const trip = this.db.prepare('SELECT id, title, user_id FROM trips WHERE id = ?').get(tripId) as { id: number; title: string; user_id: number } | undefined;
if (!trip) throw new NotFoundError('Trip not found');
if (trip.user_id !== currentOwnerId) throw new ValidationError('Only the owner can transfer ownership');
@@ -192,14 +192,13 @@ export class TripMembersService {
const fromEmail = (this.db.prepare('SELECT email FROM users WHERE id = ?').get(currentOwnerId) as { email: string } | undefined)?.email || '';
const run = this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.prepare('UPDATE trips SET user_id = ? WHERE id = ?').run(newOwnerId, tripId);
// The new owner is no longer a plain member…
this.db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, newOwnerId);
// …and the former owner keeps access as a member.
this.db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, currentOwnerId, newOwnerId);
});
run();
return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email };
}
@@ -208,7 +207,7 @@ export class TripMembersService {
/** username is UNIQUE across all users — keep the typed name but disambiguate guests
* that happen to share it (e.g. two "Anna"s) with a numeric suffix. */
createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } {
async createGuest(tripId: string | number, name: string, invitedByUserId: number): Promise<{ member: GuestMember }> {
const display = (name || '').trim();
if (!display) throw new ValidationError('Guest name is required');
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
@@ -219,31 +218,30 @@ export class TripMembersService {
const email = `guest-${randomUUID()}@guests.invalid`;
const username = `guest-${randomUUID()}`;
const create = this.db.transaction(() => {
const guestId = await this.uow.transactional(async () => {
const res = this.db.prepare(
"INSERT INTO users (username, email, password_hash, role, is_guest, display_name) VALUES (?, ?, '', 'user', 1, ?)"
).run(username, email, display);
const guestId = Number(res.lastInsertRowid);
this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId);
return guestId;
const newGuestId = Number(res.lastInsertRowid);
this.db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, newGuestId, invitedByUserId);
return newGuestId;
});
const guestId = create();
return { member: { id: guestId, username: display, email, role: 'member', is_guest: true, avatar_url: null } };
}
/** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */
private guestOfTrip(tripId: string | number, guestUserId: number): boolean {
private async guestOfTrip(tripId: string | number, guestUserId: number): Promise<boolean> {
return !!this.db.prepare(
'SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1'
).get(guestUserId, tripId);
}
renameGuest(tripId: string | number, guestUserId: number, name: string): boolean {
async renameGuest(tripId: string | number, guestUserId: number, name: string): Promise<boolean> {
const display = (name || '').trim();
if (!display) throw new ValidationError('Guest name is required');
if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer');
if (!this.guestOfTrip(tripId, guestUserId)) return false;
if (!(await this.guestOfTrip(tripId, guestUserId))) return false;
// Rename only the display name — no global-uniqueness dedup, so a rename to a name
// another trip's guest already uses no longer produces "Name 2" (#1446).
@@ -252,7 +250,7 @@ export class TripMembersService {
}
async deleteGuest(tripId: string | number, guestUserId: number): Promise<boolean> {
if (!this.guestOfTrip(tripId, guestUserId)) return false;
if (!(await this.guestOfTrip(tripId, guestUserId))) return false;
// A guest is still a user id a plugin may hold data for, so erase that too — the
// host-side per-user tables + a durable own-db erasure per granted plugin — exactly
// like a full account deletion (otherwise a deleted guest's plugin data lingers).
@@ -55,7 +55,7 @@ export class TripReadModelService {
const ownerRow = this.getOwner(tripId);
if (!ownerRow) return null;
const { owner, members } = this.members.listMembers(tripId, ownerRow.user_id);
const { owner, members } = await this.members.listMembers(tripId, ownerRow.user_id);
const { days: rawDays } = await this.days.list(tripId);
const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items }));
@@ -99,7 +99,7 @@ export class TripReadModelService {
/** Aggregates every trip sub-collection for offline caching (legacy /:id/bundle). */
async bundle(tripId: string, trip: { user_id: number }, viewerId: number) {
const { days } = await this.days.list(tripId);
const { owner, members } = this.members.listMembers(tripId, trip.user_id);
const { owner, members } = await this.members.listMembers(tripId, trip.user_id);
return {
trip,
days,
+7 -7
View File
@@ -335,7 +335,7 @@ export class TripsMcp {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = this.members.listMembers(tripId, ownerRow.user_id);
const { owner, members } = await this.members.listMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
@@ -359,7 +359,7 @@ export class TripsMcp {
// that setting (and the admin bypass inside checkPermission) where this did not.
if (!(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied();
try {
const result = this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId);
const result = await this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
@@ -385,7 +385,7 @@ export class TripsMcp {
// requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has.
if (memberId !== ctx.userId && !(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId)))
return permissionDenied();
this.members.removeMember(tripId, memberId);
await this.members.removeMember(tripId, memberId);
this.guards.safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
@@ -408,7 +408,7 @@ export class TripsMcp {
// owner calling this would get a success that changed nothing. Say so instead.
if (ownerRow.user_id === ctx.userId)
return errorResult('You own this trip, so you cannot leave it. Hand it to another member first, or delete it.');
this.members.removeMember(tripId, ctx.userId);
await this.members.removeMember(tripId, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:removed', { userId: ctx.userId });
return ok({ success: true });
}
@@ -441,7 +441,7 @@ export class TripsMcp {
return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true };
try {
// No notifyInvite: a guest has no inbox.
const { member } = this.members.createGuest(tripId, name, ctx.userId);
const { member } = await this.members.createGuest(tripId, name, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:added', { member });
return ok({ member });
} catch (err) {
@@ -468,7 +468,7 @@ export class TripsMcp {
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true };
try {
if (!this.members.renameGuest(tripId, guestId, name))
if (!(await this.members.renameGuest(tripId, guestId, name)))
return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true };
} catch (err) {
const msg = err instanceof ValidationError ? err.message : 'Failed to rename guest.';
@@ -580,7 +580,7 @@ export class TripsMcp {
if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href);
const ownerRow = this.trips.getOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = this.members.listMembers(id, ownerRow.user_id);
const { owner, members } = await this.members.listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
+1 -1
View File
@@ -187,7 +187,7 @@ export class TripsRpc {
// Ownership transfer is a separate, deliberate action.
const trip = this.db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (trip && trip.user_id === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
this.roster.removeMember(tripId, targetUserId);
await this.roster.removeMember(tripId, targetUserId);
return { removed: true };
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ vi.mock('../../src/db/database', () => ({
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast }));
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() }));
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined) }));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
+1 -1
View File
@@ -74,7 +74,7 @@ describe('OIDC e2e (real cookie service)', () => {
server = app.getHttpServer();
vi.spyOn(app.get(AuthService), 'resolveAuthToggles').mockImplementation(() => toggles as never);
const oidc = app.get(OidcService);
vi.spyOn(oidc, 'getOidcConfig').mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', displayName: 'SSO', discoveryUrl: null });
vi.spyOn(oidc, 'getOidcConfig').mockResolvedValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', displayName: 'SSO', discoveryUrl: null });
vi.spyOn(oidc, 'discover').mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' } as never);
vi.spyOn(oidc, 'createState').mockReturnValue({ state: 'st', codeChallenge: 'cc' });
consumeAuthCode = vi.spyOn(oidc, 'consumeAuthCode').mockReturnValue({ token: 'jwt.value' });
+5 -1
View File
@@ -96,7 +96,11 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, RealtimeModule, PlacesModule] })
.overrideProvider(JourneyDomainService)
.useValue({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() })
.useValue({
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
})
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
@@ -35,7 +35,7 @@ export async function accommodationsOver(dbs: DatabaseService): Promise<Accommod
const assignments = new AssignmentsService(
dbs, permissions, realtime,
new QueryHelpersService(dbs),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
);
return new AccommodationsService(dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection));
}
+1 -1
View File
@@ -156,7 +156,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// and its on-disk set only work if all three readers see the same maps.
const placePhotoCache = new PlacePhotoCacheService(dbService, makeStorageFixture('photos/google/').storage);
const mapsService = new MapsService(dbService, placePhotoCache);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService), await createTestUnitOfWork(dbService.connection));
// The last three were previously omitted, which left them `undefined` at
// runtime — silently fine while nothing called them, a TypeError the moment
// the journey skeleton hooks landed on the place write paths. tsconfig.tests.json
+1 -1
View File
@@ -101,7 +101,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection));
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage);
const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage);
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection));
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub(), generalStorage, await createTestUnitOfWork(dbs.connection));
const atlas = new AtlasService(dbs, await createTestUnitOfWork(dbs.connection));
const dayNotes = new DayNotesService(dbs, permissions, realtime);
@@ -69,7 +69,7 @@ beforeAll(async () => {
new QueryHelpersService(dbs),
// Real collaborator rather than a stub: reconcile() runs after every mutation
// and needs the same connection to see the rows these cases write.
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
);
});
@@ -141,7 +141,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
it('BUDGET-SVC-DB-010: re-derives the persons divisor when a guest in the split is deleted', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guests = ['G1', 'G2', 'G3'].map(n => createGuest(trip.id, n, owner.id).member);
const guests = await Promise.all(['G1', 'G2', 'G3'].map(async n => (await createGuest(trip.id, n, owner.id)).member));
const item = await budget.createBudgetItem(trip.id, {
name: 'Dinner', total_price: 400,
member_ids: [owner.id, ...guests.map(g => g.id)],
@@ -160,7 +160,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
it('BUDGET-SVC-DB-011: leaves a manually entered persons count alone', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
// No member rows — `persons` is just a number someone typed.
const item = await budget.createBudgetItem(trip.id, { name: 'Rental', total_price: 300, persons: 6 });
@@ -172,7 +172,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
it('BUDGET-SVC-DB-012: drops the last member to a null divisor rather than zero', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Taxi', total_price: 50, member_ids: [guest.id] });
await deleteGuest(trip.id, guest.id);
@@ -184,7 +184,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
it('BUDGET-SVC-DB-013: saves a split from a stale client instead of failing on the users FK', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Dinner', total_price: 200, member_ids: [owner.id, guest.id] });
await deleteGuest(trip.id, guest.id);
@@ -199,7 +199,7 @@ describe('deleting a member re-splits their expenses (#1553)', () => {
it('BUDGET-SVC-DB-014: ignores a deleted member arriving through updateMembers', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const guest = createGuest(trip.id, 'G1', owner.id).member;
const guest = (await createGuest(trip.id, 'G1', owner.id)).member;
const item = await budget.createBudgetItem(trip.id, { name: 'Drinks', total_price: 60, member_ids: [owner.id, guest.id] });
await deleteGuest(trip.id, guest.id);
@@ -45,10 +45,11 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const dbs = new DatabaseService(dbConn);
const domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
const books = new JourneyBookService(dbs, domain);
let domain: JourneyDomainService;
let books: JourneyBookService;
/** A minimal document that survives normalizeBookDocument unchanged. */
function doc(title = 'one') {
@@ -60,9 +61,12 @@ function doc(title = 'one') {
};
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const uow = await createTestUnitOfWork(testDb);
domain = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow);
books = new JourneyBookService(dbs, domain);
});
beforeEach(() => {
@@ -76,15 +80,15 @@ afterAll(() => {
// -- Access -------------------------------------------------------------------
describe('access', () => {
it('refuses a journey the user cannot see', () => {
it('refuses a journey the user cannot see', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(books.getBook(journey.id, stranger.id)).toBeNull();
expect(books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull();
expect(books.deleteBook(journey.id, stranger.id)).toBeNull();
expect(books.canOpen(journey.id, stranger.id)).toBe(false);
expect(await books.getBook(journey.id, stranger.id)).toBeNull();
expect(await books.saveBook(journey.id, stranger.id, { title: '', document: doc() })).toBeNull();
expect(await books.deleteBook(journey.id, stranger.id)).toBeNull();
expect(await books.canOpen(journey.id, stranger.id)).toBe(false);
});
/*
@@ -92,80 +96,80 @@ describe('access', () => {
* over the same object is how two rules end up disagreeing about who may do
* what.
*/
it('lets a contributor edit, not only the owner', () => {
it('lets a contributor edit, not only the owner', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
const saved = books.saveBook(journey.id, helper.id, { title: 'B', document: doc() });
const saved = await books.saveBook(journey.id, helper.id, { title: 'B', document: doc() });
expect(saved && 'record' in saved).toBe(true);
expect(books.getBook(journey.id, helper.id)).not.toBeNull();
expect(await books.getBook(journey.id, helper.id)).not.toBeNull();
});
it('lets a viewer read the book but not overwrite or delete it', () => {
it('lets a viewer read the book but not overwrite or delete it', async () => {
const { user: owner } = createUser(testDb);
const { user: guest } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, guest.id, 'viewer');
books.saveBook(journey.id, owner.id, { title: 'Iceland', document: doc() });
await books.saveBook(journey.id, owner.id, { title: 'Iceland', document: doc() });
expect(books.canOpen(journey.id, guest.id)).toBe(true);
expect(books.getBook(journey.id, guest.id)).not.toBeNull();
expect(books.saveBook(journey.id, guest.id, { title: 'mine now', document: doc('two') })).toBeNull();
expect(books.deleteBook(journey.id, guest.id)).toBeNull();
expect(books.getBook(journey.id, owner.id)!.title).toBe('Iceland');
expect(await books.canOpen(journey.id, guest.id)).toBe(true);
expect(await books.getBook(journey.id, guest.id)).not.toBeNull();
expect(await books.saveBook(journey.id, guest.id, { title: 'mine now', document: doc('two') })).toBeNull();
expect(await books.deleteBook(journey.id, guest.id)).toBeNull();
expect((await books.getBook(journey.id, owner.id))!.title).toBe('Iceland');
});
it('says nothing about a journey that does not exist', () => {
it('says nothing about a journey that does not exist', async () => {
const { user } = createUser(testDb);
expect(books.canOpen(999_999, user.id)).toBe(false);
expect(books.getBook(999_999, user.id)).toBeNull();
expect(await books.canOpen(999_999, user.id)).toBe(false);
expect(await books.getBook(999_999, user.id)).toBeNull();
});
});
// -- Creating and reading -----------------------------------------------------
describe('creating and reading', () => {
it('is null for a journey with no book yet', () => {
it('is null for a journey with no book yet', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.getBook(journey.id, user.id)).toBeNull();
expect(books.canOpen(journey.id, user.id)).toBe(true);
expect(await books.getBook(journey.id, user.id)).toBeNull();
expect(await books.canOpen(journey.id, user.id)).toBe(true);
});
it('creates on first save, at version 1', () => {
it('creates on first save, at version 1', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
const saved = await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
expect(saved && 'record' in saved && saved.record.version).toBe(1);
expect(saved && 'record' in saved && saved.record.title).toBe('Iceland');
});
it('reads the document back as it went in', () => {
it('reads the document back as it went in', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
const read = books.getBook(journey.id, user.id)!;
const read = (await books.getBook(journey.id, user.id))!;
expect(read.document.spreads).toHaveLength(1);
expect(read.document.page.pageWidth).toBe(210);
});
it('records who saved it', () => {
it('records who saved it', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.getBook(journey.id, user.id)!.updatedBy).toBe(user.id);
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect((await books.getBook(journey.id, user.id))!.updatedBy).toBe(user.id);
});
it('lists books without their documents', () => {
it('lists books without their documents', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
await books.saveBook(journey.id, user.id, { title: 'Iceland', document: doc() });
const list = books.listBooks(journey.id, user.id)!;
const list = (await books.listBooks(journey.id, user.id))!;
expect(list).toHaveLength(1);
expect(list[0].title).toBe('Iceland');
expect('document' in list[0]).toBe(false);
@@ -176,7 +180,7 @@ describe('creating and reading', () => {
* not lock somebody out of their own book — normalizeBookDocument drops what
* it cannot read rather than throwing.
*/
it('opens a book whose stored JSON is broken, rather than throwing', () => {
it('opens a book whose stored JSON is broken, rather than throwing', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
testDb
@@ -185,7 +189,7 @@ describe('creating and reading', () => {
)
.run(journey.id);
const read = books.getBook(journey.id, user.id);
const read = await books.getBook(journey.id, user.id);
expect(read).not.toBeNull();
expect(read!.document.spreads).toEqual([]);
});
@@ -194,16 +198,16 @@ describe('creating and reading', () => {
// -- Concurrency --------------------------------------------------------------
describe('concurrency', () => {
function seed() {
async function seed() {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
return { user, journey };
}
it('bumps the version on every save', () => {
const { user, journey } = seed();
const second = books.saveBook(journey.id, user.id, {
it('bumps the version on every save', async () => {
const { user, journey } = await seed();
const second = await books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('two'),
baseVersion: 1,
@@ -212,11 +216,11 @@ describe('concurrency', () => {
});
/* The one this column exists for. */
it('refuses a save made against a version that has moved', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
it('refuses a save made against a version that has moved', async () => {
const { user, journey } = await seed();
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
const stale = await books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
@@ -224,11 +228,11 @@ describe('concurrency', () => {
expect(stale && 'conflict' in stale).toBe(true);
});
it('answers a conflict with the current record, not only a refusal', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
it('answers a conflict with the current record, not only a refusal', async () => {
const { user, journey } = await seed();
await books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
const stale = await books.saveBook(journey.id, user.id, {
title: 'Mine',
document: doc('three'),
baseVersion: 1,
@@ -238,72 +242,72 @@ describe('concurrency', () => {
expect(stale && 'conflict' in stale && stale.conflict.document.title).toBe('two');
});
it('leaves the stored document untouched when it refuses', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 });
it('leaves the stored document untouched when it refuses', async () => {
const { user, journey } = await seed();
await books.saveBook(journey.id, user.id, { title: 'Theirs', document: doc('two'), baseVersion: 1 });
await books.saveBook(journey.id, user.id, { title: 'Mine', document: doc('three'), baseVersion: 1 });
expect(books.getBook(journey.id, user.id)!.document.title).toBe('two');
expect(books.getBook(journey.id, user.id)!.version).toBe(2);
expect((await books.getBook(journey.id, user.id))!.document.title).toBe('two');
expect((await books.getBook(journey.id, user.id))!.version).toBe(2);
});
/*
* "Open Studio and start editing" has to work for the second person to
* arrive, who has a document but no version yet.
*/
it('takes a save with no base version as an ordinary write', () => {
const { user, journey } = seed();
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') });
it('takes a save with no base version as an ordinary write', async () => {
const { user, journey } = await seed();
const saved = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two') });
expect(saved && 'record' in saved && saved.record.version).toBe(2);
});
it('lets the loser save again once it has the new version', () => {
const { user, journey } = seed();
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const conflict = books.saveBook(journey.id, user.id, {
it('lets the loser save again once it has the new version', async () => {
const { user, journey } = await seed();
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const conflict = await books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 1,
});
expect(conflict && 'conflict' in conflict).toBe(true);
const retry = books.saveBook(journey.id, user.id, {
const retry = await books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('mine'),
baseVersion: 2,
});
expect(retry && 'record' in retry && retry.record.version).toBe(3);
expect(books.getBook(journey.id, user.id)!.document.title).toBe('mine');
expect((await books.getBook(journey.id, user.id))!.document.title).toBe('mine');
});
it('does not let a version from another journey unlock this one', () => {
const { user, journey } = seed();
it('does not let a version from another journey unlock this one', async () => {
const { user, journey } = await seed();
const other = createJourney(testDb, user.id);
books.saveBook(other.id, user.id, { title: 'O', document: doc('other') });
await books.saveBook(other.id, user.id, { title: 'O', document: doc('other') });
// Version 1 is current over there and stale here.
books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = books.saveBook(journey.id, user.id, {
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('two'), baseVersion: 1 });
const stale = await books.saveBook(journey.id, user.id, {
title: 'T',
document: doc('three'),
baseVersion: 1,
});
expect(stale && 'conflict' in stale).toBe(true);
expect(books.getBook(other.id, user.id)!.version).toBe(1);
expect((await books.getBook(other.id, user.id))!.version).toBe(1);
});
});
// -- Broadcasting -------------------------------------------------------------
describe('broadcastSaved', () => {
it('sends the version, not the document, and excludes the saver', () => {
it('sends the version, not the document, and excludes the saver', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const saved = books.saveBook(journey.id, user.id, { title: 'T', document: doc() })!;
const saved = (await books.saveBook(journey.id, user.id, { title: 'T', document: doc() }))!;
const record = 'record' in saved ? saved.record : null;
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(() => {});
books.broadcastSaved(journey.id, user.id, record!, 'socket-7');
const spy = vi.spyOn(domain, 'broadcastJourneyEvent').mockImplementation(async () => {});
await books.broadcastSaved(journey.id, user.id, record!, 'socket-7');
expect(spy).toHaveBeenCalledWith(
journey.id,
@@ -318,25 +322,25 @@ describe('broadcastSaved', () => {
// -- Deleting -----------------------------------------------------------------
describe('deleting', () => {
it('removes the book and reports it', () => {
it('removes the book and reports it', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
expect(books.deleteBook(journey.id, user.id)).toBe(true);
expect(books.getBook(journey.id, user.id)).toBeNull();
expect(await books.deleteBook(journey.id, user.id)).toBe(true);
expect(await books.getBook(journey.id, user.id)).toBeNull();
});
it('reports false when there was nothing to delete', () => {
it('reports false when there was nothing to delete', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(books.deleteBook(journey.id, user.id)).toBe(false);
expect(await books.deleteBook(journey.id, user.id)).toBe(false);
});
it('goes with the journey it belongs to', () => {
it('goes with the journey it belongs to', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
await books.saveBook(journey.id, user.id, { title: 'T', document: doc() });
testDb.prepare('DELETE FROM journeys WHERE id = ?').run(journey.id);
File diff suppressed because it is too large Load Diff
@@ -49,10 +49,11 @@ let svc: JourneyShareService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const uow = await createTestUnitOfWork(testDb);
svc = new JourneyShareService(
dbs,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new SettingsService(dbs, await createTestUnitOfWork(testDb)),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), uow),
new SettingsService(dbs, uow),
);
});
@@ -104,44 +105,44 @@ function insertJourneyPhoto(
// -- Tests --------------------------------------------------------------------
describe('createOrUpdateJourneyShareLink', () => {
it('JOURNEY-SHARE-001: creates a new share link with default permissions', () => {
it('JOURNEY-SHARE-001: creates a new share link with default permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(result.created).toBe(true);
expect(result.token).toBeTruthy();
expect(result.token.length).toBeGreaterThan(10);
});
it('JOURNEY-SHARE-002: creates a share link with custom permissions', () => {
it('JOURNEY-SHARE-002: creates a share link with custom permissions', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
});
const link = svc.getJourneyShareLink(journey.id);
const link = await svc.getJourneyShareLink(journey.id);
expect(link).not.toBeNull();
expect(link!.share_timeline).toBe(true);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', () => {
it('JOURNEY-SHARE-003: updates permissions on existing link without regenerating token', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const first = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const first = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: true,
});
const second = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const second = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
@@ -150,35 +151,35 @@ describe('createOrUpdateJourneyShareLink', () => {
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
const link = svc.getJourneyShareLink(journey.id);
const link = await svc.getJourneyShareLink(journey.id);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
});
it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', () => {
it('JOURNEY-SHARE-029: reading the link separates "no link" from "not yours"', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
addJourneyContributor(testDb, journey.id, helper.id, 'editor');
// Nothing published yet: an owner is allowed to look and finds nothing.
expect(svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null });
expect(await svc.readJourneyShareLink(journey.id, owner.id)).toEqual({ allowed: true, link: null });
svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {});
const asOwner = svc.readJourneyShareLink(journey.id, owner.id);
await svc.createOrUpdateJourneyShareLink(journey.id, owner.id, {});
const asOwner = await svc.readJourneyShareLink(journey.id, owner.id);
expect(asOwner.allowed).toBe(true);
expect(asOwner.allowed && asOwner.link?.token).toBeTruthy();
// An editor is refused outright. Answering { link: null } here would tell a
// published journey's editor it is unpublished and offer to publish it.
expect(svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false });
expect(await svc.readJourneyShareLink(journey.id, helper.id)).toEqual({ allowed: false });
});
it('JOURNEY-SHARE-027: an update leaves out flags it was not given', () => {
it('JOURNEY-SHARE-027: an update leaves out flags it was not given', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: false,
@@ -186,61 +187,61 @@ describe('createOrUpdateJourneyShareLink', () => {
});
// A caller that only flips the timeline must not silently re-publish the
// gallery and map at the unchanged token.
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false });
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: false });
const link = svc.getJourneyShareLink(journey.id);
const link = await svc.getJourneyShareLink(journey.id);
expect(link!.share_timeline).toBe(false);
expect(link!.share_gallery).toBe(false);
expect(link!.share_map).toBe(false);
expect(link!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', () => {
it('JOURNEY-SHARE-028: newest_first survives a flag-only update and is settable', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(false);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(false);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true });
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { newest_first: true });
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false });
expect(svc.getJourneyShareLink(journey.id)!.newest_first).toBe(true);
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_gallery: false });
expect((await svc.getJourneyShareLink(journey.id))!.newest_first).toBe(true);
});
it('JOURNEY-SHARE-004: different journeys get different tokens', () => {
it('JOURNEY-SHARE-004: different journeys get different tokens', async () => {
const { user } = createUser(testDb);
const j1 = createJourney(testDb, user.id);
const j2 = createJourney(testDb, user.id);
const r1 = svc.createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = svc.createOrUpdateJourneyShareLink(j2.id, user.id, {});
const r1 = await svc.createOrUpdateJourneyShareLink(j1.id, user.id, {});
const r2 = await svc.createOrUpdateJourneyShareLink(j2.id, user.id, {});
expect(r1.token).not.toBe(r2.token);
});
});
describe('getJourneyShareLink', () => {
it('JOURNEY-SHARE-005: returns null when no share link exists', () => {
it('JOURNEY-SHARE-005: returns null when no share link exists', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = svc.getJourneyShareLink(journey.id);
const result = await svc.getJourneyShareLink(journey.id);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-006: returns share link info when it exists', () => {
it('JOURNEY-SHARE-006: returns share link info when it exists', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: false,
share_map: true,
});
const result = svc.getJourneyShareLink(journey.id);
const result = await svc.getJourneyShareLink(journey.id);
expect(result).not.toBeNull();
expect(result!.token).toBeTruthy();
@@ -252,83 +253,83 @@ describe('getJourneyShareLink', () => {
});
describe('deleteJourneyShareLink', () => {
it('JOURNEY-SHARE-007: owner can remove an existing share link', () => {
it('JOURNEY-SHARE-007: owner can remove an existing share link', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const ok = svc.deleteJourneyShareLink(journey.id, user.id);
const ok = await svc.deleteJourneyShareLink(journey.id, user.id);
expect(ok).toBe(true);
expect(svc.getJourneyShareLink(journey.id)).toBeNull();
expect(await svc.getJourneyShareLink(journey.id)).toBeNull();
});
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', () => {
it('JOURNEY-SHARE-008: does not throw when deleting non-existent link', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
expect(() => svc.deleteJourneyShareLink(journey.id, user.id)).not.toThrow();
await expect(svc.deleteJourneyShareLink(journey.id, user.id)).resolves.toBe(true);
});
});
describe('validateShareTokenForPhoto', () => {
it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', () => {
it('JOURNEY-SHARE-009: returns journeyId and ownerId for valid token + photo', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.journeyId).toBe(journey.id);
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-010: returns null for invalid token', () => {
const result = svc.validateShareTokenForPhoto('nonexistent-token', 1);
it('JOURNEY-SHARE-010: returns null for invalid token', async () => {
const result = await svc.validateShareTokenForPhoto('nonexistent-token', 1);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', () => {
it('JOURNEY-SHARE-011: returns null when photo does not belong to shared journey', async () => {
const { user } = createUser(testDb);
const journey1 = createJourney(testDb, user.id);
const journey2 = createJourney(testDb, user.id);
const entry2 = createJourneyEntry(testDb, journey2.id, user.id);
const photoId = insertJourneyPhoto(entry2.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey1.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).toBeNull();
});
it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', () => {
it('JOURNEY-SHARE-012: falls back to journey owner_id when photo has no owner_id', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: undefined });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForPhoto(token, photoId);
const result = await svc.validateShareTokenForPhoto(token, photoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
// Regression — GHSA-9hc8 sibling: the byte proxy must honour share_gallery.
it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', () => {
it('JOURNEY-SHARE-017: returns null when the owner disabled the gallery (share_gallery=false)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
const photoId = insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
expect(await svc.validateShareTokenForPhoto(token, photoId)).toBeNull();
});
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', async () => {
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
@@ -343,10 +344,10 @@ describe('validateShareTokenForPhoto', () => {
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// photoId = trek_photos.id (6), not journey_photos.id (1)
const result = svc.validateShareTokenForPhoto(token, trekPhotoId);
const result = await svc.validateShareTokenForPhoto(token, trekPhotoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
@@ -355,59 +356,59 @@ describe('validateShareTokenForPhoto', () => {
});
describe('validateShareTokenForAsset', () => {
it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', () => {
it('JOURNEY-SHARE-013: returns ownerId when asset belongs to shared journey', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-123', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = svc.validateShareTokenForAsset(token, 'immich-asset-123');
const result = await svc.validateShareTokenForAsset(token, 'immich-asset-123');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-014: returns null for invalid token', () => {
const result = svc.validateShareTokenForAsset('bad-token', 'some-asset');
it('JOURNEY-SHARE-014: returns null for invalid token', async () => {
const result = await svc.validateShareTokenForAsset('bad-token', 'some-asset');
expect(result).toBeNull();
});
// Regression — GHSA-9hc8 sibling: the asset proxy must honour share_gallery.
it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', () => {
it('JOURNEY-SHARE-018: returns null when the owner disabled the gallery (share_gallery=false)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-999', ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_timeline: true, share_gallery: false, share_map: true });
expect(svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
expect(await svc.validateShareTokenForAsset(token, 'immich-asset-999')).toBeNull();
});
it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', () => {
it('JOURNEY-SHARE-029: falls back to the journey owner when the photo has no owner_id', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
insertJourneyPhoto(entry.id, { assetId: 'immich-asset-orphan' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// Without the fallback the controller filled the gap from the :ownerId path
// segment, i.e. an anonymous caller picked whose provider credentials to try.
const result = svc.validateShareTokenForAsset(token, 'immich-asset-orphan');
const result = await svc.validateShareTokenForAsset(token, 'immich-asset-orphan');
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', () => {
it('JOURNEY-SHARE-015: denies (returns null) when the asset is not part of the shared journey', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
// A valid share token must NOT resolve arbitrary asset IDs to the owner —
// otherwise it could proxy any asset out of the owner's Immich/Synology
// library (IDOR). Only assets actually in the journey may resolve.
const result = svc.validateShareTokenForAsset(token, 'nonexistent-asset');
const result = await svc.validateShareTokenForAsset(token, 'nonexistent-asset');
expect(result).toBeNull();
});
@@ -438,7 +439,7 @@ describe('getPublicJourney', () => {
location_name: 'Kyoto',
});
insertJourneyPhoto(entry1.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true,
share_gallery: true,
share_map: false,
@@ -467,7 +468,7 @@ describe('getPublicJourney', () => {
const journey = createJourney(testDb, user.id);
testDb.prepare('UPDATE journeys SET show_mood = 0, show_weather = 0 WHERE id = ?').run(journey.id);
createJourneyEntry(testDb, journey.id, user.id, { type: 'entry', title: 'Tag 1', entry_date: '2026-03-20' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
@@ -489,7 +490,7 @@ describe('getPublicJourney', () => {
title: 'Skeleton Entry',
entry_date: '2026-01-11',
});
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
@@ -510,7 +511,7 @@ describe('getPublicJourney', () => {
.run(JSON.stringify(['food', 'culture']), entry.id);
insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
@@ -523,7 +524,7 @@ describe('getPublicJourney', () => {
it('JOURNEY-SHARE-020: returns empty entries array for journey with no entries', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id, { title: 'Empty Journey' });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {});
const result = await svc.getPublicJourney(token);
@@ -542,7 +543,7 @@ describe('getPublicJourney', () => {
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
insertJourneyPhoto(entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: false,
});
@@ -559,7 +560,7 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
@@ -578,7 +579,7 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'private notes', entry_date: '2026-05-01', location_name: 'Paris',
});
testDb.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?').run(48.8566, 2.3522, entry.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: false, share_gallery: false, share_map: true,
});
@@ -602,7 +603,7 @@ describe('getPublicJourney', () => {
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ?, taken_at = ? WHERE id = ?')
.run(48.8584, 2.2945, '2026-05-01T10:00:00Z', trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: false,
});
@@ -629,7 +630,7 @@ describe('getPublicJourney', () => {
testDb.prepare('UPDATE trek_photos SET lat = ?, lng = ? WHERE id = ?')
.run(48.8584, 2.2945, trekId);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
@@ -645,7 +646,7 @@ describe('getPublicJourney', () => {
type: 'entry', title: 'Day 1', story: 'notes', entry_date: '2026-05-01',
});
insertJourneyPhoto(entry.id, { ownerId: user.id });
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: false, share_map: true,
});
@@ -671,7 +672,7 @@ describe('getPublicJourney', () => {
insertJourneyPhoto(day1.id, { filePath: '/photos/day1.jpg' });
testDb.prepare('UPDATE trek_photos SET taken_at = ? WHERE id = ?').run('2026-05-02T16:00:00.000Z', late);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, {
share_timeline: true, share_gallery: true, share_map: true,
});
@@ -682,7 +683,7 @@ describe('getPublicJourney', () => {
it('JOURNEY-SHARE-030: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const { token } = svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
const { token } = await svc.createOrUpdateJourneyShareLink(journey.id, user.id, { share_map: true });
expect((await svc.getPublicJourney(token))!.cartoApiKey).toBe('');
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run();
@@ -66,9 +66,10 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
import { createTestUnitOfWork } from '../../helpers/test-uow';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
let svc: JourneyDomainService;
/** The factory has no coordinate fields, and a route is made of coordinates. */
function placeEntry(
@@ -85,9 +86,10 @@ function placeEntry(
return entry;
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -100,24 +102,24 @@ afterAll(() => {
});
describe('journeyStats access', () => {
it('is null for a journey the user cannot see', () => {
it('is null for a journey the user cannot see', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(svc.journeyStats(journey.id, stranger.id)).toBeNull();
expect(await svc.journeyStats(journey.id, stranger.id)).toBeNull();
});
it('is null for a journey that does not exist', () => {
it('is null for a journey that does not exist', async () => {
const { user } = createUser(testDb);
expect(svc.journeyStats(999_999, user.id)).toBeNull();
expect(await svc.journeyStats(999_999, user.id)).toBeNull();
});
it('answers for the owner', () => {
it('answers for the owner', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id);
const stats = await svc.journeyStats(journey.id, user.id);
expect(stats).not.toBeNull();
expect(stats!.journeyId).toBe(journey.id);
@@ -125,13 +127,13 @@ describe('journeyStats access', () => {
});
describe('journeyStats route', () => {
it('builds the route from the entries when they carry coordinates', () => {
it('builds the route from the entries when they carry coordinates', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
expect(stats.distance).toBeGreaterThan(240_000);
@@ -139,24 +141,24 @@ describe('journeyStats route', () => {
expect(stats.steps).toBe(2);
});
it('orders the route by entry date, not by insertion', () => {
it('orders the route by entry date, not by insertion', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['first', 'second']);
});
it('skips entries without coordinates but still counts them as steps', () => {
it('skips entries without coordinates but still counts them as steps', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points).toHaveLength(1);
expect(stats.steps).toBe(2);
@@ -166,7 +168,7 @@ describe('journeyStats route', () => {
* The fallback that makes the feature work at all for the common case: a
* journey built by adding trips, before anyone has written a word.
*/
it('falls back to the trip places when no entry has coordinates', () => {
it('falls back to the trip places when no entry has coordinates', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -179,13 +181,13 @@ describe('journeyStats route', () => {
createDayAssignment(testDb, day2.id, b.id);
createDayAssignment(testDb, day1.id, a.id);
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['Hallgrímskirkja', 'Goðafoss']);
expect(stats.days).toBe(3);
});
it('prefers the entries over the trip places rather than mixing them', () => {
it('prefers the entries over the trip places rather than mixing them', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -193,7 +195,7 @@ describe('journeyStats route', () => {
createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['an entry']);
});
@@ -204,7 +206,7 @@ describe('journeyStats route', () => {
* journey two extra legs of zero length — harmless — plus two extra stops in
* the middle of the route, which is not.
*/
it('visits a place assigned to several days only once', () => {
it('visits a place assigned to several days only once', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -215,13 +217,13 @@ describe('journeyStats route', () => {
createDayAssignment(testDb, createDay(testDb, trip.id, { date }).id, hotel.id);
}
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.filter(p => p.label === 'Hotel')).toHaveLength(1);
expect(stats.places).toBe(1);
});
it('puts unscheduled places after the scheduled ones', () => {
it('puts unscheduled places after the scheduled ones', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -232,12 +234,12 @@ describe('journeyStats route', () => {
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, planned.id);
expect(loose.id).toBeGreaterThan(0);
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['scheduled', 'unscheduled']);
});
it('counts places across every trip on the journey', () => {
it('counts places across every trip on the journey', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const one = createTrip(testDb, user.id);
@@ -248,18 +250,18 @@ describe('journeyStats route', () => {
createPlace(testDb, two.id, { lat: 52.52, lng: 13.4 });
createPlace(testDb, two.id, { lat: 50.11, lng: 8.68 });
expect(svc.journeyStats(journey.id, user.id)!.places).toBe(3);
expect((await svc.journeyStats(journey.id, user.id))!.places).toBe(3);
});
});
describe('journeyStats countries', () => {
it('resolves each stop to a country and names it', () => {
it('resolves each stop to a country and names it', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.countries.map(c => c.code)).toEqual(['IS', 'DE']);
expect(stats.countries[0].name).toBe('Iceland');
@@ -271,7 +273,7 @@ describe('journeyStats countries', () => {
* of repeating a point-in-polygon test is the difference between a query and
* a boundary index.
*/
it('prefers the place_regions cache over recomputing a place country', () => {
it('prefers the place_regions cache over recomputing a place country', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -281,7 +283,7 @@ describe('journeyStats countries', () => {
.prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Paris');
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
// The cached answer wins even though the coordinates say Iceland, and the
// expensive lookup is never reached for that place.
@@ -289,12 +291,12 @@ describe('journeyStats countries', () => {
expect(countryCalls).toHaveLength(0);
});
it('leaves a stop with no country out of the list rather than inventing one', () => {
it('leaves a stop with no country out of the list rather than inventing one', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.countries).toEqual([]);
expect(stats.points).toHaveLength(1);
@@ -303,7 +305,7 @@ describe('journeyStats countries', () => {
});
describe('journeyStats totals', () => {
it('counts the gallery photographs', () => {
it('counts the gallery photographs', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const photo = testDb
@@ -313,28 +315,28 @@ describe('journeyStats totals', () => {
.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)')
.run(journey.id, photo.lastInsertRowid, Date.now());
expect(svc.journeyStats(journey.id, user.id)!.photos).toBe(1);
expect((await svc.journeyStats(journey.id, user.id))!.photos).toBe(1);
});
it('falls back to the trip dates when the entries carry none', () => {
it('falls back to the trip dates when the entries carry none', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-10' });
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { lat: 64.14, lng: -21.94 });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('is all zeroes for an empty journey rather than failing', () => {
it('is all zeroes for an empty journey rather than failing', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.distance).toBe(0);
expect(stats.days).toBe(0);
@@ -345,7 +347,7 @@ describe('journeyStats totals', () => {
expect(stats.points).toEqual([]);
});
it('answers for a contributor, not only the owner', () => {
it('answers for a contributor, not only the owner', async () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
@@ -353,7 +355,7 @@ describe('journeyStats totals', () => {
.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
.run(journey.id, helper.id, 'editor', Date.now());
expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull();
expect(await svc.journeyStats(journey.id, helper.id)).not.toBeNull();
});
});
@@ -364,14 +366,14 @@ describe('journeyStats totals', () => {
* switched back on.
*/
describe('journeyStats excluded stops', () => {
it('leaves a switched-off entry out of the route, the distance and the countries', () => {
it('leaves a switched-off entry out of the route, the distance and the countries', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
// Reykjavík to Akureyri and nothing else; the leg in from Berlin is gone.
@@ -381,49 +383,49 @@ describe('journeyStats excluded stops', () => {
expect(stats.days).toBe(5);
});
it('names the switched-off stop under excluded so it can be switched back on', () => {
it('names the switched-off stop under excluded so it can be switched back on', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]);
});
it('does not count a switched-off entry as a step', () => {
it('does not count a switched-off entry as a step', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
expect(svc.journeyStats(journey.id, user.id)!.steps).toBe(1);
expect((await svc.journeyStats(journey.id, user.id))!.steps).toBe(1);
});
/* An entry without a place was never a stop, so there is nothing to offer back. */
it('lists nothing under excluded for a switched-off entry without coordinates', () => {
it('lists nothing under excluded for a switched-off entry without coordinates', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.excluded).toEqual([]);
expect(stats.steps).toBe(1);
});
it('carries the entry on every stop read from the entries', () => {
it('carries the entry on every stop read from the entries', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' });
expect(svc.journeyStats(journey.id, user.id)!.points.map(p => p.entryId)).toEqual([a.id, b.id]);
expect((await svc.journeyStats(journey.id, user.id))!.points.map(p => p.entryId)).toEqual([a.id, b.id]);
});
it('drops the place behind a switched-off skeleton from the place count', () => {
it('drops the place behind a switched-off skeleton from the place count', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -432,12 +434,12 @@ describe('journeyStats excluded stops', () => {
const town = createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 });
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id);
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
await svc.syncTripPlaces(journey.id, trip.id, user.id);
testDb
.prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?')
.run(journey.id, airport.id);
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
// The town's skeleton still draws the route, so this is the entries path.
expect(stats.points.map(p => p.label)).toEqual(['Vík']);
@@ -450,7 +452,7 @@ describe('journeyStats excluded stops', () => {
* from a trip place; switching it off and then drawing the place it came
* from would put the same stop straight back on the map by its other name.
*/
it('leaves the place behind a switched-off skeleton out of the fallback route', () => {
it('leaves the place behind a switched-off skeleton out of the fallback route', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -460,7 +462,7 @@ describe('journeyStats excluded stops', () => {
// Only the airport is on a day, so only the airport gets a skeleton; the
// town stays a bare trip place that the fallback is there to draw.
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
await svc.syncTripPlaces(journey.id, trip.id, user.id);
// Switched off, and its point cleared by hand afterwards, which is what
// sends the journey down the fallback while the skeleton still names the
// place it came from.
@@ -472,7 +474,7 @@ describe('journeyStats excluded stops', () => {
`)
.run(journey.id, airport.id);
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['Vík']);
expect(stats.points[0].entryId).toBeNull();
@@ -484,7 +486,7 @@ describe('journeyStats excluded stops', () => {
* The pair of cases that decide which source a journey reads from, now that
* a stop can be taken out of one of them.
*/
it('draws no route at all when every stop of the journal is switched off', () => {
it('draws no route at all when every stop of the journal is switched off', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -492,14 +494,14 @@ describe('journeyStats excluded stops', () => {
createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points).toEqual([]);
expect(stats.distance).toBe(0);
expect(stats.excluded.map(s => s.label)).toEqual(['Berlin, the airport']);
});
it('still falls back to the trip places for a journal that never had a point', () => {
it('still falls back to the trip places for a journal that never had a point', async () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
@@ -507,7 +509,7 @@ describe('journeyStats excluded stops', () => {
createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 });
createJourneyEntry(testDb, journey.id, user.id, { title: 'written, not placed', stats_excluded: 1 });
const stats = svc.journeyStats(journey.id, user.id)!;
const stats = (await svc.journeyStats(journey.id, user.id))!;
expect(stats.points.map(p => p.label)).toEqual(['a trip place']);
});
@@ -46,14 +46,6 @@ function ctl(service: JourneyService, books: Partial<JourneyBookService> = {}):
return new JourneyController(service, storageStub, books as JourneyBookService, captureBackfillStub);
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try { await fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
@@ -66,24 +58,24 @@ async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number
beforeEach(() => vi.clearAllMocks());
describe('JourneyController', () => {
it('GET / lists; POST / 400 without title, else creates', () => {
expect(ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
it('GET / lists; POST / 400 without title, else creates', async () => {
expect(await ctl(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
expect(await thrownAsync(() => ctl(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
const createJourney = vi.fn().mockReturnValue({ id: 9 });
expect(ctl(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(await ctl(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
});
it('GET /suggestions + /available-trips', () => {
expect(ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
it('GET /suggestions + /available-trips', async () => {
expect(await ctl(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
expect(await ctl(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
});
it('PATCH/DELETE entries map 404', () => {
expect(thrown(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(thrown(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
it('PATCH/DELETE entries map 404', async () => {
expect(await thrownAsync(() => ctl(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(await ctl(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
expect(await thrownAsync(() => ctl(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
expect(await ctl(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
});
it('provider-photos: batch, single 400/403, success', async () => {
@@ -93,19 +85,19 @@ describe('JourneyController', () => {
expect(await thrownAsync(() => ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
});
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
expect(thrown(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', async () => {
expect(await thrownAsync(() => ctl(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
const c = ctl(svc({ linkPhotoToEntry } as Partial<JourneyService>));
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(await c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
// accepts the canonical journey_photo_id, 403 when the service refuses
expect(thrown(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('unlink photo (204) maps 404; delete photo 404 then removes the object', async () => {
expect(thrown(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
expect(await ctl(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
expect(await thrownAsync(() => ctl(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await ctl(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
});
@@ -154,54 +146,54 @@ describe('JourneyController', () => {
expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video');
});
it('GET/PATCH/DELETE /:id map 404', () => {
expect(thrown(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
expect(thrown(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
it('GET/PATCH/DELETE /:id map 404', async () => {
expect(await thrownAsync(() => ctl(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await ctl(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
expect(await thrownAsync(() => ctl(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
});
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
expect(thrown(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(thrown(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
it('trips: POST 400 without trip_id / 403, DELETE 403', async () => {
expect(await thrownAsync(() => ctl(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
expect(await thrownAsync(() => ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
expect(await thrownAsync(() => ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
expect(thrown(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(thrown(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(thrown(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(thrown(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
it('entries under journey: list 404, create 400/404, reorder 400/403', async () => {
expect(await thrownAsync(() => ctl(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await ctl(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
expect(await thrownAsync(() => ctl(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
expect(await thrownAsync(() => ctl(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
expect(await thrownAsync(() => ctl(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('contributors: add 400/403, update 403, remove 403', () => {
expect(thrown(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(thrown(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(thrown(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
it('contributors: add 400/403, update 403, remove 403', async () => {
expect(await thrownAsync(() => ctl(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
expect(await thrownAsync(() => ctl(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
expect(await thrownAsync(() => ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences 403, share-link get/set/delete', () => {
expect(thrown(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: { token: 'abc' } }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
it('preferences 403, share-link get/set/delete', async () => {
expect(await thrownAsync(() => ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: { token: 'abc' } }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
// An owner whose journey has no link gets the null; a non-owner gets the
// same refusal set/delete give, so the client can tell the two apart.
expect(ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: null }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: null });
expect(thrown(() => ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial<JourneyService>)).getShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(thrown(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: true, link: null }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: null });
expect(await thrownAsync(() => ctl(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ allowed: false }) } as Partial<JourneyService>)).getShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
const setLink = vi.fn().mockReturnValue({ token: 'abc' });
expect(ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
expect(await ctl(svc({ createOrUpdateJourneyShareLink: setLink } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
// newest_first is part of the shared contract and the public viewer reads
// it, so it has to reach the service instead of being dropped here (#1614).
expect(setLink).toHaveBeenCalledWith(9, 1, { share_timeline: true, share_gallery: undefined, share_map: undefined, newest_first: undefined });
const withOrder = vi.fn().mockReturnValue({ token: 'abc' });
ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial<JourneyService>)).setShareLink(user, '9', { newest_first: true });
await ctl(svc({ createOrUpdateJourneyShareLink: withOrder } as Partial<JourneyService>)).setShareLink(user, '9', { newest_first: true });
expect(withOrder).toHaveBeenCalledWith(9, 1, { share_timeline: undefined, share_gallery: undefined, share_map: undefined, newest_first: true });
expect(thrown(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('takes the committed bytes back out when the upload is refused', async () => {
@@ -272,9 +264,9 @@ describe('JourneyController', () => {
expect(await ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
});
it('PATCH photos: 404 then returns the updated photo', () => {
expect(thrown(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
it('PATCH photos: 404 then returns the updated photo', async () => {
expect(await thrownAsync(() => ctl(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
expect(await ctl(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
});
it('DELETE photo removes the storage object when a path exists', async () => {
@@ -322,8 +314,8 @@ describe('JourneyController', () => {
expect(storageStub.delete).toHaveBeenCalledWith('journey', 'poster.jpg');
});
it('PATCH /:id returns the updated journey on success', () => {
expect(ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
it('PATCH /:id returns the updated journey on success', async () => {
expect(await ctl(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
});
it('cover upload: 400 without file, 404 when the journey is gone, else commits + returns the journey', async () => {
@@ -335,42 +327,42 @@ describe('JourneyController', () => {
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
});
it('DELETE /:id and trips/contributors success paths', () => {
expect(ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
expect(ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
it('DELETE /:id and trips/contributors success paths', async () => {
expect(await ctl(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
expect(await ctl(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
expect(await ctl(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
expect(await ctl(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
});
it('addContributor defaults the role to viewer when omitted', () => {
it('addContributor defaults the role to viewer when omitted', async () => {
const addContributor = vi.fn().mockReturnValue(true);
ctl(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
await ctl(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
});
it('createEntry returns the entry when the journey exists', () => {
expect(ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
it('createEntry returns the entry when the journey exists', async () => {
expect(await ctl(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
});
it('reorderEntries succeeds for a numeric array', () => {
expect(ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
it('reorderEntries succeeds for a numeric array', async () => {
expect(await ctl(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
});
it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', () => {
it('restoreSuggestions answers with the count, and refuses a viewer the same way its siblings do', async () => {
const restore = vi.fn().mockReturnValue({ restored: 3 });
expect(ctl(svc({ restoreDismissedSuggestions: restore } as Partial<JourneyService>)).restoreSuggestions(user, '9')).toEqual({ restored: 3 });
expect(await ctl(svc({ restoreDismissedSuggestions: restore } as Partial<JourneyService>)).restoreSuggestions(user, '9')).toEqual({ restored: 3 });
expect(restore).toHaveBeenCalledWith(9, 1);
expect(
thrown(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).restoreSuggestions(user, '9')),
await thrownAsync(() => ctl(svc({ restoreDismissedSuggestions: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).restoreSuggestions(user, '9')),
).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
it('preferences returns the result on success', () => {
expect(ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
it('preferences returns the result on success', async () => {
expect(await ctl(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
});
it('deleteShareLink returns success when removed', () => {
expect(ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
it('deleteShareLink returns success when removed', async () => {
expect(await ctl(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
});
/*
* The Studio book (#1973). Its access shape is the same as everything around
@@ -378,25 +370,25 @@ describe('JourneyController', () => {
* is a 404 rather than a 403, so the route cannot be used to find out which
* journeys exist.
*/
it('book: 404 vs a journey with no book yet, and the save status codes', () => {
it('book: 404 vs a journey with no book yet, and the save status codes', async () => {
// A journey with no book yet is not an error: Studio opens on it, lays a
// book out and saves. 404 here would make "no book" and "no journey"
// indistinguishable to the client.
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null });
expect(thrown(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } });
expect(await ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).getBook(user, '9')).toEqual({ book: null });
expect(await thrownAsync(() => ctl(svc(), { getBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).getBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await ctl(svc(), { getBook: vi.fn().mockReturnValue({ id: 3, version: 4 }), canOpen: vi.fn() }).getBook(user, '9')).toEqual({ book: { id: 3, version: 4 } });
// A refused save says which refusal it was. Out of reach entirely is still
// 404; a viewer who can open the journey but may not write it gets 403, so
// the editor can say why instead of claiming the journey vanished.
expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc(), { saveBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).saveBook(user, '9', { document: {} } as never))).toEqual({ status: 403, body: { error: 'Not allowed' } });
// The record comes back bare, not in an envelope — the contract in
// shared/src/book/book-store.schema.ts is the record itself.
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ record: { id: 3, version: 5 } });
expect(ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 });
expect(await ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { title: 'T', document: { v: 1 }, baseVersion: 4 } as never, 'sock-7')).toEqual({ id: 3, version: 5 });
expect(saveBook).toHaveBeenCalledWith(9, 1, { title: 'T', document: { v: 1 }, baseVersion: 4 });
// Forwarded so the client that just saved does not process its own change.
expect(broadcastSaved).toHaveBeenCalledWith(9, 1, { id: 3, version: 5 }, 'sock-7');
@@ -404,11 +396,11 @@ describe('JourneyController', () => {
// A missing title is '' rather than undefined: the column is NOT NULL and
// an untitled book is an ordinary thing to have.
const untitled = vi.fn().mockReturnValue({ record: { id: 3, version: 1 } });
ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never);
await ctl(svc(), { saveBook: untitled, broadcastSaved: vi.fn() }).saveBook(user, '9', { document: {} } as never);
expect(untitled.mock.calls[0][2].title).toBe('');
});
it('book: a conflict is a 409 that carries the other version', () => {
it('book: a conflict is a 409 that carries the other version', async () => {
/*
* Two people editing is the normal case, not an exception. A bare 409 would
* make showing what the other version is a second round trip, at exactly
@@ -416,7 +408,7 @@ describe('JourneyController', () => {
*/
const broadcastSaved = vi.fn();
const saveBook = vi.fn().mockReturnValue({ conflict: { id: 3, version: 6, title: 'Theirs' } });
expect(thrown(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({
expect(await thrownAsync(() => ctl(svc(), { saveBook, broadcastSaved }).saveBook(user, '9', { document: {}, baseVersion: 4 } as never))).toEqual({
status: 409,
body: { error: 'Book was changed by someone else', current: { id: 3, version: 6, title: 'Theirs' } },
});
@@ -424,13 +416,13 @@ describe('JourneyController', () => {
expect(broadcastSaved).not.toHaveBeenCalled();
});
it('book: delete answers 204, 403 for a viewer, or 404 out of reach', () => {
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined();
it('book: delete answers 204, 403 for a viewer, or 404 out of reach', async () => {
expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(true) }).deleteBook(user, '9')).toBeUndefined();
// False means there was nothing to delete, which is still a 204 — deleting
// a book that is already gone is not a failure.
expect(ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined();
expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(thrown(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).deleteBook(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
expect(await ctl(svc(), { deleteBook: vi.fn().mockReturnValue(false) }).deleteBook(user, '9')).toBeUndefined();
expect(await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(false) }).deleteBook(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
expect(await thrownAsync(() => ctl(svc(), { deleteBook: vi.fn().mockReturnValue(null), canOpen: vi.fn().mockReturnValue(true) }).deleteBook(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
});
});
+12 -12
View File
@@ -278,16 +278,16 @@ describe('createAuthCode / consumeAuthCode', () => {
// ── generateToken ─────────────────────────────────────────────────────────────
describe('generateToken', () => {
it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', () => {
it('OIDC-SVC-057: remember=true signs with the SESSION_DURATION_REMEMBER lifetime', async () => {
const { user } = createUser(testDb, { email: 'remember@example.com' });
const token = svc.generateToken({ id: user.id }, true);
const token = await svc.generateToken({ id: user.id }, true);
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
expect(decoded.exp - decoded.iat).toBe(2592000);
});
it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', () => {
it('OIDC-SVC-058: remember=false or absent signs with the default SESSION_DURATION lifetime', async () => {
const { user } = createUser(testDb, { email: 'default@example.com' });
for (const token of [svc.generateToken({ id: user.id }, false), svc.generateToken({ id: user.id })]) {
for (const token of [await svc.generateToken({ id: user.id }, false), await svc.generateToken({ id: user.id })]) {
const decoded = jwtLib.decode(token) as { iat: number; exp: number };
expect(decoded.exp - decoded.iat).toBe(86400);
}
@@ -1212,8 +1212,8 @@ describe('wrapper methods', () => {
// The SSO configuration read/write moved here from AdminService, which held the SQL
// for a domain that already had a module. Same cases, same service, new owner.
describe('OIDC settings', () => {
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => {
const result = svc.getOidcSettings() as any;
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', async () => {
const result = await svc.getOidcSettings() as any;
expect(result.issuer).toBe('');
expect(result.client_id).toBe('');
expect(result.oidc_only).toBe(false);
@@ -1224,14 +1224,14 @@ describe('OIDC settings', () => {
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', async () => {
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = svc.getOidcSettings() as any;
const result = await svc.getOidcSettings() as any;
expect(result.issuer).toBe('https://auth.example.com');
expect(result.client_id).toBe('my-client');
});
it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', async () => {
await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = svc.getOidcSettings() as any;
const result = await svc.getOidcSettings() as any;
// oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles
expect(result.oidc_only).toBe(false);
});
@@ -1239,7 +1239,7 @@ describe('OIDC settings', () => {
it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', async () => {
const result = await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any;
expect(result.success).toBe(true);
const settings = svc.getOidcSettings();
const settings = await svc.getOidcSettings();
expect(settings).toMatchObject({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' });
});
});
@@ -1264,13 +1264,13 @@ describe('OIDC settings — the lockout guard', () => {
it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', async () => {
const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never);
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' });
expect(svc.getOidcSettings().client_secret_set).toBe(true);
expect((await svc.getOidcSettings()).client_secret_set).toBe(true);
// Omitted: the write skips the column entirely rather than blanking it, which is
// what lets the admin panel save the form without re-typing the secret.
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' });
expect(svc.getOidcSettings().client_secret_set).toBe(true);
expect((await svc.getOidcSettings()).client_secret_set).toBe(true);
await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' });
expect(svc.getOidcSettings().client_secret_set).toBe(false);
expect((await svc.getOidcSettings()).client_secret_set).toBe(false);
toggles.mockRestore();
});
});
@@ -145,17 +145,17 @@ beforeEach(() => {
// ── Kill switch ──────────────────────────────────────────────────────────────
describe('enrichDisabled', () => {
it('ENRICH-001: is off only when the setting is literally "false"', () => {
it('ENRICH-001: is off only when the setting is literally "false"', async () => {
const svc = make(mapsStub(), cacheStub());
mockDbGet.mockReturnValue(undefined);
expect(svc.enrichDisabled()).toBe(false); // never configured — fail open
expect(await svc.enrichDisabled()).toBe(false); // never configured — fail open
mockDbGet.mockReturnValue({ value: 'true' });
expect(svc.enrichDisabled()).toBe(false);
expect(await svc.enrichDisabled()).toBe(false);
mockDbGet.mockReturnValue({ value: 'false' });
expect(svc.enrichDisabled()).toBe(true);
expect(await svc.enrichDisabled()).toBe(true);
});
it('ENRICH-002: answers the disabled envelope without touching a provider', async () => {
@@ -90,7 +90,7 @@ async function svc(searchNominatim: MapsService['searchNominatim']): Promise<Pla
new QueryHelpersService(dbs),
new UnsplashService(dbs, new RuntimeEnvService(), storageFx.storage),
photoCacheStub,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
storageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
);
@@ -106,7 +106,7 @@ async function makePlacesService(maps: MapsService = new MapsService(dbs, photoC
new QueryHelpersService(dbs),
new UnsplashService(dbs, new RuntimeEnvService(), placesStorageFx.storage),
photoCacheStub,
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)),
new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
placesStorageFx.storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
);
@@ -53,7 +53,7 @@ describe('members', () => {
it('GET 404 without access, else owner+members+current_user_id', async () => {
expect(await thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
const s = svc({ listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }) } as Partial<TripMembersService>);
expect(tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 });
expect(await tc(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 });
});
it('POST 403 without member_manage, else adds + notifies', async () => {
@@ -134,7 +134,7 @@ describe('guests (#1362)', () => {
expect(await thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } });
const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } });
const s = svc({ createGuest } as Partial<TripMembersService>);
expect(tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } });
expect(await tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } });
expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id);
});
@@ -142,7 +142,7 @@ describe('guests (#1362)', () => {
const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial<TripMembersService>);
expect(await thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } });
const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial<TripMembersService>);
expect(tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true });
expect(await tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true });
});
it('delete: 404 when the guest is missing, else success', async () => {
@@ -126,7 +126,7 @@ afterAll(() => {
// ── Delegating helpers (what the controller calls around the mutations) ───────
describe('TripMembersService delegation', () => {
it('MEMBERS-SVC-001: canAccessTrip forwards the db helper — owner and member get the access row, a stranger gets nothing', () => {
it('MEMBERS-SVC-001: canAccessTrip forwards the db helper — owner and member get the access row, a stranger gets nothing', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
@@ -136,9 +136,9 @@ describe('TripMembersService delegation', () => {
// Every member route answers 404 on a falsy result and reads access.user_id as
// the trip owner, so a delegation that stopped forwarding would lock the whole
// roster out rather than fail loudly.
expect(roster.canAccessTrip(String(trip.id), owner.id)).toMatchObject({ user_id: owner.id });
expect(roster.canAccessTrip(trip.id, member.id)).toMatchObject({ user_id: owner.id });
expect(roster.canAccessTrip(trip.id, stranger.id)).toBeUndefined();
expect(await roster.canAccessTrip(String(trip.id), owner.id)).toMatchObject({ user_id: owner.id });
expect(await roster.canAccessTrip(trip.id, member.id)).toMatchObject({ user_id: owner.id });
expect(await roster.canAccessTrip(trip.id, stranger.id)).toBeUndefined();
});
it('MEMBERS-SVC-002: can() resolves member_manage at its trip_owner default and lets admins through', async () => {
@@ -152,7 +152,7 @@ describe('TripMembersService delegation', () => {
expect(await roster.can('member_manage', 'admin', owner.id, member.id, true)).toBe(true);
});
it('MEMBERS-SVC-003: broadcast forwards the socket id so the originating client is not echoed', () => {
it('MEMBERS-SVC-003: broadcast forwards the socket id so the originating client is not echoed', async () => {
roster.broadcast('9', 'trip:updated', { trip: { id: 9 } } as never, 'sock-1');
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock-1');
@@ -162,7 +162,7 @@ describe('TripMembersService delegation', () => {
expect(broadcast).toHaveBeenLastCalledWith('9', 'trip:updated', { trip: { id: 9 } }, undefined);
});
it('MEMBERS-SVC-004: getTripForViewer re-reads the trip in list shape, with is_owner per viewer', () => {
it('MEMBERS-SVC-004: getTripForViewer re-reads the trip in list shape, with is_owner per viewer', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Handover' });
@@ -171,11 +171,11 @@ describe('TripMembersService delegation', () => {
// The handover broadcast hands the raw :id route param straight in, so the
// named-parameter query has to keep matching a string id against the INTEGER
// column — and the payload the clients re-read must carry their own is_owner.
const asOwner = roster.getTripForViewer(String(trip.id), owner.id) as Record<string, unknown>;
const asOwner = await roster.getTripForViewer(String(trip.id), owner.id) as Record<string, unknown>;
expect(asOwner).toMatchObject({ id: trip.id, title: 'Handover', is_owner: 1, owner_username: owner.username, shared_count: 1 });
const asMember = roster.getTripForViewer(trip.id, member.id) as Record<string, unknown>;
const asMember = await roster.getTripForViewer(trip.id, member.id) as Record<string, unknown>;
expect(asMember.is_owner).toBe(0);
expect(roster.getTripForViewer(999999, owner.id)).toBeUndefined();
expect(await roster.getTripForViewer(999999, owner.id)).toBeUndefined();
});
it('MEMBERS-SVC-005: notifyInvite sends a user-scoped trip_invite carrying the actor, invitee and trip', async () => {
@@ -217,44 +217,44 @@ describe('addMember fallbacks', () => {
// membership is inserted — losing that row must not cost the invitee their
// access or throw on an undefined title.
const broken = await rosterWithMissingRow('SELECT title FROM trips WHERE id = ?');
const result = broken.addMember(trip.id, invitee.email, owner.id, owner.id);
const result = await broken.addMember(trip.id, invitee.email, owner.id, owner.id);
expect(result.tripTitle).toBe('Untitled');
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeDefined();
});
it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', () => {
it('MEMBERS-SVC-008: addMember resolves a padded identifier and matches on username as well as email', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
// Pasted invites arrive with surrounding whitespace; without the trim the
// lookup misses and the box answers 'User not found' for a real account.
expect(roster.addMember(trip.id, ` ${invitee.username} `, owner.id, owner.id).member.id).toBe(invitee.id);
expect((await roster.addMember(trip.id, ` ${invitee.username} `, owner.id, owner.id)).member.id).toBe(invitee.id);
});
});
// ── transferOwnership guard rails (#973) ─────────────────────────────────────
describe('transferOwnership guard rails', () => {
it('MEMBERS-SVC-009: rejects a trip that no longer exists, before any other check', () => {
it('MEMBERS-SVC-009: rejects a trip that no longer exists, before any other check', async () => {
const { user: owner } = createUser(testDb);
// NotFoundError, not ValidationError: the controller maps the two to 404 and
// 400. Passing the owner as the new owner too proves the trip lookup runs
// first — otherwise this would surface as 'You already own this trip'.
expect(() => roster.transferOwnership(999999, owner.id, owner.id)).toThrow(NotFoundError);
expect(() => roster.transferOwnership(999999, owner.id, owner.id)).toThrow('Trip not found');
await expect(roster.transferOwnership(999999, owner.id, owner.id)).rejects.toThrow(NotFoundError);
await expect(roster.transferOwnership(999999, owner.id, owner.id)).rejects.toThrow('Trip not found');
});
it('MEMBERS-SVC-010: rejects an id with no user row before it checks membership', () => {
it('MEMBERS-SVC-010: rejects an id with no user row before it checks membership', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
// A stale id from the client must read as a 404 'User not found' rather than
// the 400 the non-member branch below it would produce, and the trip must keep
// its owner either way.
expect(() => roster.transferOwnership(trip.id, 999999, owner.id)).toThrow(NotFoundError);
expect(() => roster.transferOwnership(trip.id, 999999, owner.id)).toThrow('User not found');
await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow(NotFoundError);
await expect(roster.transferOwnership(trip.id, 999999, owner.id)).rejects.toThrow('User not found');
expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(owner.id);
});
@@ -268,7 +268,7 @@ describe('transferOwnership guard rails', () => {
// handover halfway, which would leave the owner pointer and the member rows
// disagreeing about who owns the trip.
const broken = await rosterWithMissingRow('SELECT email FROM users WHERE id = ?');
const result = broken.transferOwnership(trip.id, member.id, owner.id);
const result = await broken.transferOwnership(trip.id, member.id, owner.id);
expect(result.fromEmail).toBe('');
expect(result.toEmail).toBe(member.email);
expect((testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id).toBe(member.id);
@@ -278,13 +278,13 @@ describe('transferOwnership guard rails', () => {
// ── Guest name validation (#1362) ────────────────────────────────────────────
describe('guest name validation', () => {
it('MEMBERS-SVC-012: createGuest rejects an absent, blank or over-long name and writes nothing', () => {
it('MEMBERS-SVC-012: createGuest rejects an absent, blank or over-long name and writes nothing', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => roster.createGuest(trip.id, undefined as never, owner.id)).toThrow(ValidationError);
expect(() => roster.createGuest(trip.id, ' ', owner.id)).toThrow('Guest name is required');
expect(() => roster.createGuest(trip.id, 'x'.repeat(51), owner.id)).toThrow('Guest name must be 50 characters or fewer');
await expect(roster.createGuest(trip.id, undefined as never, owner.id)).rejects.toThrow(ValidationError);
await expect(roster.createGuest(trip.id, ' ', owner.id)).rejects.toThrow('Guest name is required');
await expect(roster.createGuest(trip.id, 'x'.repeat(51), owner.id)).rejects.toThrow('Guest name must be 50 characters or fewer');
// The guards run ahead of the transaction, so a rejected name can never leave
// a credential-less users row behind with no trip to belong to.
@@ -292,25 +292,25 @@ describe('guest name validation', () => {
// 50 is the accepted boundary the DTO shares — off by one here and the API
// starts refusing names the client believes are valid.
expect(roster.createGuest(trip.id, 'x'.repeat(50), owner.id).member.username).toHaveLength(50);
expect((await roster.createGuest(trip.id, 'x'.repeat(50), owner.id)).member.username).toHaveLength(50);
});
it('MEMBERS-SVC-013: renameGuest rejects an absent, blank or over-long name — before the trip-scope check', () => {
it('MEMBERS-SVC-013: renameGuest rejects an absent, blank or over-long name — before the trip-scope check', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member: guest } = roster.createGuest(trip.id, 'Ida', owner.id);
const { member: guest } = await roster.createGuest(trip.id, 'Ida', owner.id);
expect(() => roster.renameGuest(trip.id, guest.id, undefined as never)).toThrow(ValidationError);
expect(() => roster.renameGuest(trip.id, guest.id, ' ')).toThrow('Guest name is required');
expect(() => roster.renameGuest(trip.id, guest.id, 'x'.repeat(51))).toThrow('Guest name must be 50 characters or fewer');
await expect(roster.renameGuest(trip.id, guest.id, undefined as never)).rejects.toThrow(ValidationError);
await expect(roster.renameGuest(trip.id, guest.id, ' ')).rejects.toThrow('Guest name is required');
await expect(roster.renameGuest(trip.id, guest.id, 'x'.repeat(51))).rejects.toThrow('Guest name must be 50 characters or fewer');
// Order matters for the status code: an unusable name throws (400) even for an
// id that is not a guest of this trip, where the scope check returns false (404).
expect(() => roster.renameGuest(trip.id, owner.id, '')).toThrow('Guest name is required');
await expect(roster.renameGuest(trip.id, owner.id, '')).rejects.toThrow('Guest name is required');
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida');
// A padded name is stored trimmed, so the roster does not render the spaces.
expect(roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true);
expect(await roster.renameGuest(trip.id, guest.id, ' Ida M. ')).toBe(true);
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guest.id) as { display_name: string }).display_name).toBe('Ida M.');
});
@@ -319,7 +319,7 @@ describe('guest name validation', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const otherTrip = createTrip(testDb, other.id);
const { member: guest } = roster.createGuest(trip.id, 'Jo', owner.id);
const { member: guest } = await roster.createGuest(trip.id, 'Jo', owner.id);
// The route only proves ownership of the trip in the URL, so this check is the
// only thing between it and a foreign guest's users row — and the delete
@@ -332,7 +332,7 @@ describe('guest name validation', () => {
// ── listMembers shaping ──────────────────────────────────────────────────────
describe('listMembers shaping', () => {
it('MEMBERS-SVC-015: avatar_url follows the storage form and the inviter is named, not just its id', () => {
it('MEMBERS-SVC-015: avatar_url follows the storage form and the inviter is named, not just its id', async () => {
const { user: owner } = createUser(testDb);
const { user: uploaded } = createUser(testDb);
const { user: sso } = createUser(testDb);
@@ -345,7 +345,7 @@ describe('listMembers shaping', () => {
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(trip.id, sso.id, owner.id);
addTripMember(testDb, trip.id, bare.id);
const { owner: ownerRow, members } = roster.listMembers(trip.id, owner.id);
const { owner: ownerRow, members } = await roster.listMembers(trip.id, owner.id);
// added_at has second resolution, so rows created in one test are tied — index
// by id rather than asserting the ORDER BY.
const byId = new Map(members.map(m => [m.id, m]));
@@ -106,7 +106,7 @@ beforeAll(async () => {
dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()),
new UnsplashService(dbs(), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
);
+34 -34
View File
@@ -133,7 +133,7 @@ beforeAll(async () => {
new QueryHelpersService(dbs()),
new UnsplashService(dbs(), new RuntimeEnvService(), coversFx.storage),
photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()), await createTestUnitOfWork(dbs().connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
);
@@ -589,13 +589,13 @@ describe('resyncAccommodationDays (#1288)', () => {
});
describe('transferOwnership (#973)', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = membersSvc.transferOwnership(trip.id, member.id, owner.id);
const result = await membersSvc.transferOwnership(trip.id, member.id, owner.id);
expect(result.toEmail).toBe(member.email);
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
@@ -607,35 +607,35 @@ describe('transferOwnership (#973)', () => {
expect(memberIds).not.toContain(member.id);
});
it('TRIP-SVC-021: rejects a transfer from a non-owner', () => {
it('TRIP-SVC-021: rejects a transfer from a non-owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// member (not the owner) attempts the transfer
expect(() => membersSvc.transferOwnership(trip.id, member.id, member.id)).toThrow();
await expect(membersSvc.transferOwnership(trip.id, member.id, member.id)).rejects.toThrow();
});
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => {
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member');
await expect(membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).rejects.toThrow('New owner must be a trip member');
});
it('TRIP-SVC-023: rejects transferring to yourself', () => {
it('TRIP-SVC-023: rejects transferring to yourself', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => membersSvc.transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip');
await expect(membersSvc.transferOwnership(trip.id, owner.id, owner.id)).rejects.toThrow('You already own this trip');
});
});
describe('guest members (#1362)', () => {
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => {
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, ' Anna ', owner.id);
const { member } = await membersSvc.createGuest(trip.id, ' Anna ', owner.id);
expect(member.username).toBe('Anna');
expect(member.is_guest).toBe(true);
@@ -650,17 +650,17 @@ describe('guest members (#1362)', () => {
expect(m).toBeTruthy();
// Surfaces in listMembers with is_guest=true and the typed display name.
const { members } = membersSvc.listMembers(trip.id, owner.id) as any;
const { members } = await membersSvc.listMembers(trip.id, owner.id) as any;
const guest = members.find((x: any) => x.id === member.id);
expect(guest.username).toBe('Anna');
expect(guest.is_guest).toBe(true);
});
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => {
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const a = membersSvc.createGuest(trip.id, 'Sam', owner.id);
const b = membersSvc.createGuest(trip.id, 'Sam', owner.id);
const a = await membersSvc.createGuest(trip.id, 'Sam', owner.id);
const b = await membersSvc.createGuest(trip.id, 'Sam', owner.id);
// both keep the plain display name; only the internal (uuid) username differs
expect(a.member.username).toBe('Sam');
expect(b.member.username).toBe('Sam');
@@ -669,26 +669,26 @@ describe('guest members (#1362)', () => {
expect(usernames[0].username).not.toBe(usernames[1].username);
});
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => {
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', async () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Bob', owner.id);
const { member } = await membersSvc.createGuest(trip.id, 'Bob', owner.id);
expect(membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true);
expect(await membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true);
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert');
// A real user cannot be renamed through the guest path…
expect(membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
expect(await membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
// …and a guest cannot be renamed from a different trip.
expect(membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
expect(await membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
});
it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Carol', owner.id);
const { member } = await membersSvc.createGuest(trip.id, 'Carol', owner.id);
// Real members are not deletable via the guest path.
expect(await membersSvc.deleteGuest(trip.id, owner.id)).toBe(false);
@@ -698,15 +698,15 @@ describe('guest members (#1362)', () => {
expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined();
});
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => {
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Dora', owner.id);
const { member } = await membersSvc.createGuest(trip.id, 'Dora', owner.id);
// The synthetic username/email must not resolve through the invite box.
expect(() => membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found');
await expect(membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).rejects.toThrow('User not found');
// Ownership can never be handed to a guest.
expect(() => membersSvc.transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest');
await expect(membersSvc.transferOwnership(trip.id, member.id, owner.id)).rejects.toThrow('Cannot transfer ownership to a guest');
});
});
@@ -1113,22 +1113,22 @@ describe('folded quirk branches', () => {
expect(getDays(trip.id)).toHaveLength(7);
});
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => {
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', async () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Joinable' });
const result = membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id);
const result = await membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id);
expect(result.member.id).toBe(invitee.id);
expect(result.tripTitle).toBe('Joinable');
expect(result.targetUserId).toBe(invitee.id);
// Duplicate + owner + missing identifier reject with the byte-identical errors.
expect(() => membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).toThrow('User already has access');
expect(() => membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).toThrow('Trip owner is already a member');
expect(() => membersSvc.addMember(trip.id, '', owner.id, owner.id)).toThrow('Email or username required');
await expect(membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).rejects.toThrow('User already has access');
await expect(membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).rejects.toThrow('Trip owner is already a member');
await expect(membersSvc.addMember(trip.id, '', owner.id, owner.id)).rejects.toThrow('Email or username required');
membersSvc.removeMember(trip.id, invitee.id);
await membersSvc.removeMember(trip.id, invitee.id);
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined();
});
@@ -1248,7 +1248,7 @@ describe('quirk fixes', () => {
it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member: guest } = membersSvc.createGuest(trip.id, 'Gia', owner.id);
const { member: guest } = await membersSvc.createGuest(trip.id, 'Gia', owner.id);
const item = await budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] });
const broken = await failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1');
@@ -1260,11 +1260,11 @@ describe('quirk fixes', () => {
expect(row.persons).toBe(2);
});
it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', () => {
it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', async () => {
const { user: owner } = createUser(testDb, { username: 'owner-handle' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id);
const trip = createTrip(testDb, owner.id);
const { owner: row } = membersSvc.listMembers(trip.id, owner.id);
const { owner: row } = await membersSvc.listMembers(trip.id, owner.id);
expect(row.username).toBe('Olive Displayed');
});
});
@@ -77,7 +77,7 @@ beforeAll(async () => {
new QueryHelpersService(dbs),
new UnsplashService(dbs, runtimeEnv, makeStorageFixture('').storage),
photoCache,
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)),
new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs), await createTestUnitOfWork(dbs.connection)),
makeStorageFixture('').storage,
await accommodationsOver(dbs), await createTestUnitOfWork(dbs.connection),
);