mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?)',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user