mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -18,6 +18,6 @@ import { getPluginMcpToolSource } from '../plugin-mcp-tools';
|
||||
* exist and an app built without the plugins runtime resolves it to undefined.
|
||||
* Tests pass it explicitly instead of reaching for the global.
|
||||
*/
|
||||
export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): void {
|
||||
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools });
|
||||
export async function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke'], dynamicTools: McpAttachOptions['dynamicTools'] = getPluginMcpToolSource() ?? undefined): Promise<void> {
|
||||
if (registry) await registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice }, { onInvoke, dynamicTools });
|
||||
}
|
||||
|
||||
@@ -115,10 +115,10 @@ export class McpRegistry {
|
||||
* `ctx` as the handler's last argument (in the SDK `extra` slot). `opts`
|
||||
* carries per-session hooks — see `McpAttachOptions`.
|
||||
*/
|
||||
attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): void {
|
||||
async attach(server: McpServer, ctx: McpContext, opts?: McpAttachOptions): Promise<void> {
|
||||
const registrar = server as unknown as LooseRegistrar;
|
||||
for (const { entry, instance } of this.bound) {
|
||||
if (!this.allowed(entry, ctx, instance)) continue;
|
||||
if (!(await this.allowed(entry, ctx, instance))) continue;
|
||||
const handler = (instance as unknown as Record<string, AnyHandler>)[entry.methodName];
|
||||
switch (entry.kind) {
|
||||
case 'tool':
|
||||
@@ -142,7 +142,7 @@ export class McpRegistry {
|
||||
// tools/list is insertion-ordered, so host-contributed tools sort last,
|
||||
// which is the right priority signal in a long list; and it reads the way
|
||||
// it works — the registry, then whatever this session added on top.
|
||||
if (opts?.dynamicTools) this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools);
|
||||
if (opts?.dynamicTools) await this.attachDynamicTools(registrar, ctx, opts, opts.dynamicTools);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,12 +161,12 @@ export class McpRegistry {
|
||||
return this.reserved;
|
||||
}
|
||||
|
||||
private attachDynamicTools(
|
||||
private async attachDynamicTools(
|
||||
registrar: LooseRegistrar,
|
||||
ctx: McpContext,
|
||||
opts: McpAttachOptions,
|
||||
source: McpDynamicToolSource,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
let tools: readonly McpDynamicTool[];
|
||||
try {
|
||||
tools = source(ctx) ?? [];
|
||||
@@ -197,7 +197,7 @@ export class McpRegistry {
|
||||
// entries only avoid it because validate() pre-checks them at boot, and
|
||||
// a per-session source has no boot to be checked at.
|
||||
const entry: McpEntry = { kind: 'tool', methodName: DYNAMIC_METHOD_NAME, options: tool.options };
|
||||
if (!this.allowed(entry, ctx, owner)) continue;
|
||||
if (!(await this.allowed(entry, ctx, owner))) continue;
|
||||
this.attachTool(registrar, tool.options, owner, tool.handler as AnyHandler, ctx, opts);
|
||||
} catch (err) {
|
||||
console.warn(`[nest-mcp] skipped dynamic tool "${String(name)}": ${describeError(err)}`);
|
||||
@@ -263,12 +263,12 @@ export class McpRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
private allowed(entry: McpEntry, ctx: McpContext, instance: object): boolean {
|
||||
private async allowed(entry: McpEntry, ctx: McpContext, instance: object): Promise<boolean> {
|
||||
// The declaring instance goes in so the gate can read an injected
|
||||
// collaborator. It is resolved here, at attach, rather than captured when
|
||||
// the options object was built — the class body runs long before the
|
||||
// container exists.
|
||||
if (entry.options.when && !entry.options.when(ctx, instance)) return false;
|
||||
if (entry.options.when && !(await entry.options.when(ctx, instance))) return false;
|
||||
const access = entry.options.access;
|
||||
if (access === undefined) return true;
|
||||
if (typeof access === 'function') return access(ctx);
|
||||
|
||||
@@ -103,7 +103,7 @@ interface McpEntryOptionsBase {
|
||||
* so a predicate written against its own controller class stays assignable
|
||||
* here. The registry always passes the instance that declared the entry.
|
||||
*/
|
||||
when?(ctx: McpContext, self: object): boolean;
|
||||
when?(ctx: McpContext, self: object): boolean | Promise<boolean>;
|
||||
/** Omitted ⇒ the entry is always registered (subject to `when`). */
|
||||
access?: McpAccess;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AccommodationsController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() rawBody: AccommodationCreateDto,
|
||||
@@ -89,10 +89,10 @@ export class AccommodationsController {
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const { accommodation, mirror } = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
const { accommodation, mirror } = await this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId);
|
||||
this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
// The stop rides in the answer as well, for the session that booked the night
|
||||
// with its socket down: over the socket it would already have it.
|
||||
return { accommodation, assignment: mirror.created };
|
||||
@@ -100,7 +100,7 @@ export class AccommodationsController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -117,15 +117,15 @@ export class AccommodationsController {
|
||||
if (errors.length > 0) {
|
||||
throw new HttpException({ error: errors[0].message }, 404);
|
||||
}
|
||||
const { accommodation, mirror } = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
const { accommodation, mirror } = await this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
return { accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
async remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -138,8 +138,8 @@ export class AccommodationsController {
|
||||
// Turning a night back into a pause in road trip mode: the booking goes, the
|
||||
// stop stays and becomes the traveller's. Everywhere else a cancelled booking
|
||||
// takes the stop it brought with it.
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.accommodations.remove(id, { keepStop: keepStop === 'true' });
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.accommodations.remove(id, { keepStop: keepStop === 'true' });
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
for (const reservationId of linkedReservationIds) {
|
||||
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AuthService } from '../auth/auth.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { AccommodationsService, type MirrorSender } from './accommodations.service';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
@@ -34,6 +35,7 @@ export class AccommodationsMcp {
|
||||
private readonly places: PlacesService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/** The tools' own broadcast, handed to announceMirror so the day stop a booking
|
||||
@@ -68,12 +70,12 @@ export class AccommodationsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
return ok({ accommodation, assignment: mirror.created });
|
||||
}
|
||||
|
||||
@@ -119,22 +121,22 @@ export class AccommodationsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const dayErrors = this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const result = await this.uow.transactional(async () => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const { accommodation, mirror } = this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
const { accommodation, mirror } = await this.accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation, mirror };
|
||||
});
|
||||
// The stamped copy, not the one create() returned: booking the night types the
|
||||
// place as lodging, and place:created is the only announcement it gets here.
|
||||
const place = result.mirror.stamped ?? result.place;
|
||||
const place = (await result.mirror).stamped ?? result.place;
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
|
||||
this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId));
|
||||
return ok({ place, accommodation: result.accommodation, assignment: result.mirror.created });
|
||||
await this.accommodations.announceMirror(tripId, { ...result.mirror, stamped: null }, this.mirrorSender(tripId));
|
||||
return ok({ place, accommodation: result.accommodation, assignment: (await result.mirror).created });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
|
||||
}
|
||||
@@ -167,12 +169,12 @@ export class AccommodationsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const existing = this.accommodations.getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const { accommodation, mirror } = this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
const { accommodation, mirror } = await this.accommodations.updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
// movedAssignment rather than a delete/create pair: an edit carries the booking's
|
||||
// own stop across instead of rebuilding it, so the caller sees the same row.
|
||||
return ok({ accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed });
|
||||
@@ -191,12 +193,12 @@ export class AccommodationsMcp {
|
||||
async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
// linkedReservationId stays the first one so the tool's answer keeps its shape;
|
||||
// linkedReservationIds carries the rest for a block that had more than one booking.
|
||||
const { linkedReservationId, linkedReservationIds, mirror } = this.accommodations.deleteAccommodation(accommodationId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
const { linkedReservationId, linkedReservationIds, mirror } = await this.accommodations.deleteAccommodation(accommodationId);
|
||||
await this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId));
|
||||
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId, linkedReservationIds });
|
||||
return ok({ success: true, linkedReservationId, linkedReservationIds, removedAssignments: mirror.removed });
|
||||
}
|
||||
|
||||
@@ -42,12 +42,12 @@ export class AccommodationsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('accommodations.create', { permission: 'db:write:accommodations' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'accommodation');
|
||||
const parsed = accommodationCreateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
const input = parsed.data as AccommodationInput;
|
||||
const placeId = Math.trunc(Number(input.place_id));
|
||||
const startDayId = Math.trunc(Number(input.start_day_id));
|
||||
@@ -56,7 +56,7 @@ export class AccommodationsRpc {
|
||||
// Verifies the place and both days belong to this trip.
|
||||
const errors = this.days.validateAccommodationRefs(tripId, placeId, startDayId, endDayId);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const { accommodation, mirror } = this.days.createAccommodation(tripId, {
|
||||
const { accommodation, mirror } = await this.days.createAccommodation(tripId, {
|
||||
place_id: placeId,
|
||||
start_day_id: startDayId,
|
||||
end_day_id: endDayId,
|
||||
@@ -69,41 +69,41 @@ export class AccommodationsRpc {
|
||||
this.realtime.broadcast(tripId, 'accommodation:created', { accommodation });
|
||||
// The block creates a partner hotel reservation, so the bookings view refreshes too.
|
||||
this.realtime.broadcast(tripId, 'reservation:created', {});
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
return accommodation;
|
||||
}
|
||||
|
||||
@PluginMethod('accommodations.update', { permission: 'db:write:accommodations' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const accommodationId = num(params.accommodationId, 'accommodationId');
|
||||
const actor = this.guards.requireActor(ctx, 'accommodation');
|
||||
const parsed = accommodationUpdateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid accommodation: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
const existing = this.days.getAccommodation(accommodationId, tripId);
|
||||
if (!existing) throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
|
||||
const input = parsed.data as { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string };
|
||||
const errors = this.days.validateAccommodationRefs(tripId, input.place_id, input.start_day_id, input.end_day_id);
|
||||
if (errors.length > 0) throw new ForbiddenResource(errors[0].message);
|
||||
const { accommodation, mirror } = this.days.updateAccommodation(accommodationId, existing, input);
|
||||
const { accommodation, mirror } = await this.days.updateAccommodation(accommodationId, existing, input);
|
||||
this.realtime.broadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
return accommodation;
|
||||
}
|
||||
|
||||
@PluginMethod('accommodations.delete', { permission: 'db:write:accommodations' })
|
||||
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const accommodationId = num(params.accommodationId, 'accommodationId');
|
||||
const actor = this.guards.requireActor(ctx, 'accommodation');
|
||||
this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, ACCOMMODATION_EDIT_ACTION);
|
||||
if (!this.days.getAccommodation(accommodationId, tripId)) {
|
||||
throw new ForbiddenResource(`no accommodation ${accommodationId} on trip ${tripId}`);
|
||||
}
|
||||
// Deleting a block can take its partner reservation and budget item with it.
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.days.deleteAccommodation(accommodationId);
|
||||
this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = await this.days.deleteAccommodation(accommodationId);
|
||||
await this.days.announceMirror(tripId, mirror, (event, payload) => this.realtime.broadcast(tripId, event, payload));
|
||||
for (const reservationId of linkedReservationIds) this.realtime.broadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
for (const itemId of deletedBudgetItemIds) this.realtime.broadcast(tripId, 'budget:deleted', { itemId });
|
||||
this.realtime.broadcast(tripId, 'accommodation:deleted', { accommodationId });
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Injectable } from '@nestjs/common';
|
||||
import type { RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import type { User } from '../../types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
|
||||
type MirroredAssignment = Awaited<ReturnType<AssignmentsService['createAssignment']>>;
|
||||
|
||||
/** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */
|
||||
type DayVias = { dayId: number; vias: RoadtripVia[] };
|
||||
@@ -95,6 +96,7 @@ export class AccommodationsService {
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
@@ -107,7 +109,7 @@ export class AccommodationsService {
|
||||
return this.dbs.canAccessTrip(Number(tripId), userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
@@ -160,24 +162,24 @@ export class AccommodationsService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Put a freshly written stay on the map. */
|
||||
attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
async attachStayStop(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise<AccommodationMirror> {
|
||||
return this.mirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
}
|
||||
|
||||
/** Carry a stay's own stop over to where the stay now is. `checkInChanged` says the
|
||||
* booking was given a new hour, which seats the night afresh (see remirrorStay). */
|
||||
moveStayStop(
|
||||
async moveStayStop(
|
||||
accommodationId: number,
|
||||
placeId: number | null,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
opts: { checkInChanged?: boolean } = {},
|
||||
): AccommodationMirror {
|
||||
): Promise<AccommodationMirror> {
|
||||
return this.remirrorStay(accommodationId, placeId, dayId, checkIn, opts);
|
||||
}
|
||||
|
||||
/** Take back the stops of a stay that is being deleted elsewhere. */
|
||||
dropStayStops(accommodationId: number): AccommodationMirror {
|
||||
async dropStayStops(accommodationId: number): Promise<AccommodationMirror> {
|
||||
return this.releaseStops(accommodationId, {});
|
||||
}
|
||||
|
||||
@@ -191,7 +193,7 @@ export class AccommodationsService {
|
||||
* sent the request. The day order and the vias sent here are news to that
|
||||
* session too, and they only make sense arriving behind the stop they concern.
|
||||
*/
|
||||
announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): void {
|
||||
async announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): Promise<void> {
|
||||
for (const stop of mirror.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
|
||||
if (mirror.created) send('assignment:created', { assignment: mirror.created });
|
||||
if (mirror.moved) {
|
||||
@@ -207,7 +209,7 @@ export class AccommodationsService {
|
||||
// A night is seated by its check-in, which renumbers the stops around it. The
|
||||
// created/moved event alone puts the row at the end of the day on every other
|
||||
// screen, so the day that changed sends its order along.
|
||||
for (const dayId of this.touchedDays(mirror)) {
|
||||
for (const dayId of await this.touchedDays(mirror)) {
|
||||
const orderedIds = this.db.all<{ id: number }>(
|
||||
'SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index', dayId).map(row => row.id);
|
||||
send('assignment:reordered', { dayId, orderedIds });
|
||||
@@ -220,7 +222,7 @@ export class AccommodationsService {
|
||||
}
|
||||
|
||||
/** Days whose stop order this write can have changed, each named once. */
|
||||
private touchedDays(mirror: AccommodationMirror): number[] {
|
||||
private async touchedDays(mirror: AccommodationMirror): Promise<number[]> {
|
||||
const days = new Set<number>();
|
||||
if (mirror.created) days.add(mirror.created.day_id);
|
||||
if (mirror.moved) { days.add(mirror.moved.assignment.day_id); days.add(mirror.moved.oldDayId); }
|
||||
@@ -479,12 +481,12 @@ export class AccommodationsService {
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private relocateOwnStop(
|
||||
private async relocateOwnStop(
|
||||
stop: { id: number; day_id: number; order_index: number },
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
): MirroredAssignment | null {
|
||||
): Promise<MirroredAssignment | null> {
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ? AND id != ?', dayId, placeId, stop.id)) {
|
||||
return null;
|
||||
}
|
||||
@@ -516,7 +518,7 @@ export class AccommodationsService {
|
||||
return this.assignments.getAssignmentWithPlace(stop.id);
|
||||
}
|
||||
|
||||
private mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): AccommodationMirror {
|
||||
private async mirrorStay(accommodationId: number, placeId: number | null, dayId: number, checkIn?: string | null): Promise<AccommodationMirror> {
|
||||
const mirror = noMirror();
|
||||
// A stay can outlive its place (place_id is ON DELETE SET NULL) and the booking
|
||||
// form writes stays that never had one. Nothing to put on the map then.
|
||||
@@ -539,7 +541,7 @@ export class AccommodationsService {
|
||||
// would leave that copy without it. The day list has nothing else to tell the
|
||||
// stop from a place the traveller added, so it would show the hotel a second
|
||||
// time until the next reload.
|
||||
mirror.created = this.assignments.createAssignment(dayId, placeId, null, {
|
||||
mirror.created = await this.assignments.createAssignment(dayId, placeId, null, {
|
||||
accommodationId,
|
||||
orderIndex: this.positionForCheckIn(dayId, checkIn),
|
||||
});
|
||||
@@ -568,7 +570,7 @@ export class AccommodationsService {
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private releaseStops(accommodationId: number, opts: { keepStop?: boolean }): AccommodationMirror {
|
||||
private async releaseStops(accommodationId: number, opts: { keepStop?: boolean }): Promise<AccommodationMirror> {
|
||||
const mirror = noMirror();
|
||||
const own = this.ownStops(accommodationId);
|
||||
const before = this.stopOrders(own.map(stop => stop.day_id));
|
||||
@@ -578,7 +580,7 @@ export class AccommodationsService {
|
||||
// The stop stays, but it is the traveller's now. Days hides a stop whose
|
||||
// accommodation_id is set, so a client left holding the old row keeps the
|
||||
// place invisible on a day it is standing on.
|
||||
const released = this.assignments.getAssignmentWithPlace(stop.id);
|
||||
const released = await this.assignments.getAssignmentWithPlace(stop.id);
|
||||
if (released) mirror.updated.push(released);
|
||||
continue;
|
||||
}
|
||||
@@ -601,13 +603,13 @@ export class AccommodationsService {
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private remirrorStay(
|
||||
private async remirrorStay(
|
||||
accommodationId: number,
|
||||
placeId: number | null,
|
||||
dayId: number,
|
||||
checkIn?: string | null,
|
||||
opts: { checkInChanged?: boolean } = {},
|
||||
): AccommodationMirror {
|
||||
): Promise<AccommodationMirror> {
|
||||
const own = this.ownStops(accommodationId);
|
||||
if (own.length === 0) return noMirror();
|
||||
if (own.length === 1 && own[0].day_id === dayId && own[0].place_id === placeId) {
|
||||
@@ -629,7 +631,7 @@ export class AccommodationsService {
|
||||
// on its id, all of which a DELETE takes with it.
|
||||
if (own.length === 1 && placeId) {
|
||||
const before = this.stopOrders([own[0].day_id, dayId]);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn);
|
||||
const moved = await this.relocateOwnStop(own[0], placeId, dayId, checkIn);
|
||||
if (moved) {
|
||||
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
|
||||
mirror.stamped = this.stampLodging(placeId);
|
||||
@@ -644,19 +646,19 @@ export class AccommodationsService {
|
||||
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
|
||||
}
|
||||
this.reanchorVias(mirror, beforeRebuild);
|
||||
const fresh = this.mirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
const fresh = await this.mirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
mirror.created = fresh.created;
|
||||
mirror.stamped = fresh.stamped;
|
||||
for (const day of fresh.vias ?? []) this.noteVias(mirror, day);
|
||||
return mirror;
|
||||
}
|
||||
|
||||
createAccommodation(tripId: string | number, data: CreateAccommodationData) {
|
||||
async createAccommodation(tripId: string | number, data: CreateAccommodationData) {
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
|
||||
|
||||
// The stay, its partner hotel reservation and the day stop it implies are one
|
||||
// logical write, and an atomic one, so a failed insert halfway can't leave an orphan.
|
||||
const written = this.db.transaction(() => {
|
||||
const written = await this.uow.transactional(async () => {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null
|
||||
@@ -680,7 +682,7 @@ export class AccommodationsService {
|
||||
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
|
||||
);
|
||||
|
||||
return { accommodationId: newId, mirror: this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) };
|
||||
return { accommodationId: newId, mirror: await this.mirrorStay(Number(newId), place_id ?? null, start_day_id, check_in) };
|
||||
});
|
||||
|
||||
return { accommodation: this.getAccommodationWithPlace(written.accommodationId), mirror: written.mirror };
|
||||
@@ -690,7 +692,7 @@ export class AccommodationsService {
|
||||
return this.db.get<DayAccommodation>('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
}
|
||||
|
||||
updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
|
||||
async updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
|
||||
place_id?: number; start_day_id?: number; end_day_id?: number;
|
||||
check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string;
|
||||
}) {
|
||||
@@ -705,12 +707,12 @@ export class AccommodationsService {
|
||||
|
||||
// The stay row and the day stop that mirrors it describe the same booking, so a
|
||||
// move that wrote only one of the two must not survive.
|
||||
const mirror = this.db.transaction(() => {
|
||||
const mirror = await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?',
|
||||
newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id
|
||||
);
|
||||
return this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
|
||||
return await this.remirrorStay(Number(id), newPlaceId, newStartDayId, newCheckIn, {
|
||||
checkInChanged: fields.check_in !== undefined && (fields.check_in || null) !== (existing.check_in || null),
|
||||
});
|
||||
});
|
||||
@@ -742,14 +744,14 @@ export class AccommodationsService {
|
||||
* that no longer exists. `linkedReservationId` / `deletedBudgetItemId` stay on the
|
||||
* result as the first of each, because the RPC, MCP and REST callers read them.
|
||||
*/
|
||||
deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): {
|
||||
async deleteAccommodation(id: string | number, opts: { keepStop?: boolean } = {}): Promise<{
|
||||
linkedReservationId: number | null;
|
||||
deletedBudgetItemId: number | null;
|
||||
linkedReservationIds: number[];
|
||||
deletedBudgetItemIds: number[];
|
||||
mirror: AccommodationMirror;
|
||||
} {
|
||||
return this.db.transaction(() => {
|
||||
}> {
|
||||
return this.uow.transactional(async () => {
|
||||
const linkedRes = this.db.all<{ id: number }>('SELECT id FROM reservations WHERE accommodation_id = ?', Number(id));
|
||||
const deletedBudgetItemIds: number[] = [];
|
||||
for (const res of linkedRes) {
|
||||
@@ -761,7 +763,7 @@ export class AccommodationsService {
|
||||
this.db.run('DELETE FROM reservations WHERE id = ?', res.id);
|
||||
}
|
||||
|
||||
const mirror = this.releaseStops(Number(id), opts);
|
||||
const mirror = await this.releaseStops(Number(id), opts);
|
||||
|
||||
this.db.run('DELETE FROM day_accommodations WHERE id = ?', id);
|
||||
const linkedReservationIds = linkedRes.map(r => r.id);
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface HasAddons {
|
||||
|
||||
/** Gate an MCP entry on an addon being enabled. */
|
||||
export function addonGate(addonId: string) {
|
||||
return (_ctx: McpContext, self: HasAddons): boolean => self.addons.isAddonEnabled(addonId);
|
||||
return (_ctx: McpContext, self: HasAddons): Promise<boolean> => self.addons.isAddonEnabled(addonId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +35,6 @@ export function addonGate(addonId: string) {
|
||||
* addon toggle and its own flag — notes, polls or chat.
|
||||
*/
|
||||
export function collabFeatureGate(feature: 'chat' | 'notes' | 'polls') {
|
||||
return (_ctx: McpContext, self: HasAddons): boolean =>
|
||||
self.addons.isAddonEnabled(ADDON_IDS.COLLAB) && self.addons.getCollabFeatures()[feature];
|
||||
return async (_ctx: McpContext, self: HasAddons): Promise<boolean> =>
|
||||
(await self.addons.isAddonEnabled(ADDON_IDS.COLLAB)) && (await self.addons.getCollabFeatures())[feature];
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ export class AddonGuard implements CanActivate {
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const meta = this.reflector.getAllAndOverride<RequireAddonMeta | undefined>(REQUIRE_ADDON, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!meta) return true;
|
||||
if (!this.addons.isAddonEnabled(meta.addonId)) {
|
||||
if (!(await this.addons.isAddonEnabled(meta.addonId))) {
|
||||
throw new HttpException({ error: `${meta.label} addon is not enabled` }, 404);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AddonsController {
|
||||
constructor(private readonly addons: AddonsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
async list() {
|
||||
return this.addons.list();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ export class AddonsMcp {
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
})
|
||||
listAddons(_args: Record<string, never>, _ctx: McpContext) {
|
||||
const { addons, collabFeatures, bagTracking } = this.addons.list();
|
||||
async listAddons(_args: Record<string, never>, _ctx: McpContext) {
|
||||
const { addons, collabFeatures, bagTracking } = await this.addons.list();
|
||||
// icon, config and fields are admin-panel chrome: an icon name and the
|
||||
// photo-provider settings form say nothing about what a tool can do.
|
||||
return ok({
|
||||
|
||||
@@ -29,28 +29,28 @@ export class AddonsService {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
isAddonEnabled(addonId: string): boolean {
|
||||
async isAddonEnabled(addonId: string): Promise<boolean> {
|
||||
const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as
|
||||
| { enabled: number }
|
||||
| undefined;
|
||||
return !!addon?.enabled;
|
||||
}
|
||||
|
||||
getBagTracking() {
|
||||
async getBagTracking() {
|
||||
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
}
|
||||
|
||||
updateBagTracking(enabled: boolean) {
|
||||
async updateBagTracking(enabled: boolean) {
|
||||
this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
getCollabFeatures() {
|
||||
async getCollabFeatures() {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
|
||||
@@ -67,7 +67,7 @@ export class AddonsService {
|
||||
};
|
||||
}
|
||||
|
||||
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
async updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = {
|
||||
chat: 'collab_chat_enabled',
|
||||
notes: 'collab_notes_enabled',
|
||||
@@ -75,12 +75,12 @@ export class AddonsService {
|
||||
polls: 'collab_polls_enabled',
|
||||
whatsnext: 'collab_whatsnext_enabled',
|
||||
};
|
||||
const before = this.getCollabFeatures();
|
||||
const before = await this.getCollabFeatures();
|
||||
const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
const after = this.getCollabFeatures();
|
||||
const after = await this.getCollabFeatures();
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
// whether anything actually flipped — a no-op save must not tear down every
|
||||
// live MCP session (#1414).
|
||||
@@ -88,7 +88,7 @@ export class AddonsService {
|
||||
return { features: after, changed };
|
||||
}
|
||||
|
||||
list() {
|
||||
async list() {
|
||||
const addons = this.db
|
||||
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
|
||||
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
@@ -97,7 +97,7 @@ export class AddonsService {
|
||||
// here (instead of a migration) also covers installs that still hold an
|
||||
// enabled provider under a disabled journey from before updateAddon
|
||||
// cascaded the disable.
|
||||
const providers = !this.isAddonEnabled(ADDON_IDS.JOURNEY)
|
||||
const providers = !(await this.isAddonEnabled(ADDON_IDS.JOURNEY))
|
||||
? []
|
||||
: (this.db
|
||||
.prepare(
|
||||
@@ -135,8 +135,8 @@ export class AddonsService {
|
||||
}
|
||||
|
||||
return {
|
||||
collabFeatures: this.getCollabFeatures(),
|
||||
bagTracking: this.getBagTracking().enabled,
|
||||
collabFeatures: await this.getCollabFeatures(),
|
||||
bagTracking: (await this.getBagTracking()).enabled,
|
||||
addons: [
|
||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map((p) => ({
|
||||
@@ -171,22 +171,22 @@ export class AddonsService {
|
||||
// backfills 'true' for installs that never touched the switches, so nobody
|
||||
// loses a feature on upgrade.
|
||||
|
||||
private readFlag(key: string) {
|
||||
private async readFlag(key: string) {
|
||||
const row = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
}
|
||||
|
||||
private writeFlag(key: string, enabled: boolean) {
|
||||
private async writeFlag(key: string, enabled: boolean) {
|
||||
this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
getPlacesPhotos() { return this.readFlag('places_photos_enabled'); }
|
||||
updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); }
|
||||
getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); }
|
||||
updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); }
|
||||
getPlacesDetails() { return this.readFlag('places_details_enabled'); }
|
||||
updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
|
||||
async getPlacesPhotos() { return this.readFlag('places_photos_enabled'); }
|
||||
async updatePlacesPhotos(enabled: boolean) { return this.writeFlag('places_photos_enabled', enabled); }
|
||||
async getPlacesAutocomplete() { return this.readFlag('places_autocomplete_enabled'); }
|
||||
async updatePlacesAutocomplete(enabled: boolean) { return this.writeFlag('places_autocomplete_enabled', enabled); }
|
||||
async getPlacesDetails() { return this.readFlag('places_details_enabled'); }
|
||||
async updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
|
||||
|
||||
/**
|
||||
* The shadow log, fail-CLOSED like the three above but for the opposite
|
||||
@@ -195,8 +195,8 @@ export class AddonsService {
|
||||
* so there is nothing to backfill and an absent row correctly means off.
|
||||
* PlaceShadowService.enabled() reads the same key the same way.
|
||||
*/
|
||||
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
|
||||
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
|
||||
async getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
|
||||
async updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
|
||||
|
||||
/**
|
||||
* Enrichment reads fail-OPEN, unlike the three switches above.
|
||||
@@ -211,14 +211,14 @@ export class AddonsService {
|
||||
* the same key the same way. If these two ever disagree the admin panel shows
|
||||
* "off" while the feature runs, which is worse than either default.
|
||||
*/
|
||||
getPlacesEnrich() {
|
||||
async getPlacesEnrich() {
|
||||
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); }
|
||||
async updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); }
|
||||
|
||||
// ── Transit backend (#1699) ────────────────────────────────────────────────
|
||||
// Not a flag: two named backends, so it stores the name rather than a
|
||||
@@ -243,11 +243,11 @@ export class AddonsService {
|
||||
return resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey).source;
|
||||
}
|
||||
|
||||
getTransitProvider(userId = 0) {
|
||||
return { provider: readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) };
|
||||
async getTransitProvider(userId = 0) {
|
||||
return { provider: await readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) };
|
||||
}
|
||||
|
||||
updateTransitProvider(provider: TransitProvider, userId = 0) {
|
||||
return { provider: writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) };
|
||||
async updateTransitProvider(provider: TransitProvider, userId = 0) {
|
||||
return { provider: await writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,16 @@ import { ADDON_IDS } from '../../addons';
|
||||
* hand the bare RequestHandler to consumer.apply() ahead of the SDK routers.
|
||||
*/
|
||||
export function createMcpAddonGate(addons: AddonsService): RequestHandler {
|
||||
// Express cannot await a middleware, so the now-async gate runs in a helper and
|
||||
// its rejection is handed to next() — the same error path a synchronous throw
|
||||
// took before (recipe R1.5).
|
||||
return (_req, res, next) => {
|
||||
if (!addons.isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
void (async () => {
|
||||
if (!(await addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})().catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,39 +79,39 @@ export class AdminController {
|
||||
|
||||
@Post('users')
|
||||
@HttpCode(201)
|
||||
createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
|
||||
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
|
||||
const result = ok(this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
|
||||
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateUser(id, body));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
||||
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.deleteUser(id, user.id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
logInfo(`Admin ${user.email} deleted user ${result.email}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('users/:id/passkeys')
|
||||
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.resetUserPasskeys(id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||
return { success: true, deleted: result.deleted };
|
||||
}
|
||||
|
||||
@Delete('users/:id/mfa')
|
||||
resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.resetUserMfa(id, user.id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -123,9 +123,9 @@ export class AdminController {
|
||||
permissions() { return this.admin.getPermissions(); }
|
||||
|
||||
@Put('permissions')
|
||||
savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
|
||||
const result = this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
|
||||
async savePermissions(@CurrentUser() user: User, @Body() body: AdminPermissionsDto, @Req() req: Request) {
|
||||
const result = await this.admin.savePermissions(body.permissions as unknown as Parameters<AdminService['savePermissions']>[0]);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.permissions_update', resource: 'permissions', ip: getClientIp(req), details: body.permissions as Record<string, unknown> });
|
||||
return { success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) };
|
||||
}
|
||||
|
||||
@@ -134,12 +134,12 @@ export class AdminController {
|
||||
|
||||
@Post('save-demo-baseline')
|
||||
@HttpCode(200)
|
||||
saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
||||
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.admin.saveDemoBaseline();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||
return { success: true, message: result.message };
|
||||
}
|
||||
|
||||
@@ -171,16 +171,16 @@ export class AdminController {
|
||||
|
||||
@Post('invites')
|
||||
@HttpCode(201)
|
||||
createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
|
||||
async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) {
|
||||
const result = this.invites.createInvite(user.id, body);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } });
|
||||
return { invite: result.invite };
|
||||
}
|
||||
|
||||
@Delete('invites/:id')
|
||||
deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.invites.deleteInvite(id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -197,9 +197,9 @@ export class AdminController {
|
||||
getBagTracking() { return this.addons.getBagTracking(); }
|
||||
|
||||
@Put('bag-tracking')
|
||||
updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updateBagTracking(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updateBagTracking(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateBagTracking(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.bag_tracking', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -207,9 +207,9 @@ export class AdminController {
|
||||
getPlacesPhotos() { return this.addons.getPlacesPhotos(); }
|
||||
|
||||
@Put('places-photos')
|
||||
updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlacesPhotos(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updatePlacesPhotos(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlacesPhotos(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_photos', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -217,9 +217,9 @@ export class AdminController {
|
||||
getPlacesAutocomplete() { return this.addons.getPlacesAutocomplete(); }
|
||||
|
||||
@Put('places-autocomplete')
|
||||
updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlacesAutocomplete(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updatePlacesAutocomplete(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlacesAutocomplete(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_autocomplete', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -227,9 +227,9 @@ export class AdminController {
|
||||
getPlacesDetails() { return this.addons.getPlacesDetails(); }
|
||||
|
||||
@Put('places-details')
|
||||
updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlacesDetails(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updatePlacesDetails(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlacesDetails(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_details', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -237,9 +237,9 @@ export class AdminController {
|
||||
getPlaceShadow() { return this.addons.getPlaceShadow(); }
|
||||
|
||||
@Put('place-shadow')
|
||||
updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlaceShadow(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlaceShadow(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -247,9 +247,9 @@ export class AdminController {
|
||||
getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
|
||||
|
||||
@Put('places-enrich')
|
||||
updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlacesEnrich(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
async updatePlacesEnrich(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlacesEnrich(body.enabled);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.places_enrich', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -257,12 +257,12 @@ export class AdminController {
|
||||
getCollabFeatures() { return this.addons.getCollabFeatures(); }
|
||||
|
||||
@Put('collab-features')
|
||||
updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
|
||||
const { features, changed } = this.addons.updateCollabFeatures(body);
|
||||
async updateCollabFeatures(@CurrentUser() user: User, @Body() body: AdminCollabFeaturesDto, @Req() req: Request) {
|
||||
const { features, changed } = await this.addons.updateCollabFeatures(body);
|
||||
// Collab flags gate MCP registration, but a no-op save must not tear down
|
||||
// every live MCP session (#1414).
|
||||
if (changed) this.admin.invalidateMcpSessions();
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.collab_features', ip: getClientIp(req), details: features });
|
||||
return features;
|
||||
}
|
||||
|
||||
@@ -272,9 +272,9 @@ export class AdminController {
|
||||
getTransitProvider(@CurrentUser() user: User) { return this.addons.getTransitProvider(user.id); }
|
||||
|
||||
@Put('transit-provider')
|
||||
updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
|
||||
const result = this.addons.updateTransitProvider(body.provider, user.id);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } });
|
||||
async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateTransitProvider(body.provider, user.id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ export class AdminController {
|
||||
|
||||
@Put('addons/:id')
|
||||
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
|
||||
const result = ok(this.admin.updateAddon(id, body));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
const result = await ok(await this.admin.updateAddon(id, body));
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||
// Sessions only need re-creating when the registered MCP surface can
|
||||
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
||||
// saves and photo-provider toggles used to kill every session (#1414).
|
||||
@@ -309,9 +309,9 @@ export class AdminController {
|
||||
listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; }
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.tokens.adminDeleteMcpToken(id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -319,9 +319,9 @@ export class AdminController {
|
||||
listOAuthSessions() { return { sessions: this.oauth.listAllOAuthSessions() }; }
|
||||
|
||||
@Delete('oauth-sessions/:id')
|
||||
revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.oauth.adminRevokeOAuthSession(id));
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -329,12 +329,12 @@ export class AdminController {
|
||||
@ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach')
|
||||
@Post('rotate-jwt-secret')
|
||||
@HttpCode(200)
|
||||
rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
||||
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.admin.rotateJwtSecret();
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -309,8 +309,8 @@ export class AdminService {
|
||||
|
||||
// ── Permissions ────────────────────────────────────────────────────────────
|
||||
|
||||
getPermissions() {
|
||||
const current = this.permissions.getAllPermissions();
|
||||
async getPermissions() {
|
||||
const current = await this.permissions.getAllPermissions();
|
||||
const actions = PERMISSION_ACTIONS.map((a) => ({
|
||||
key: a.key,
|
||||
level: current[a.key],
|
||||
@@ -320,9 +320,9 @@ export class AdminService {
|
||||
return { permissions: actions };
|
||||
}
|
||||
|
||||
savePermissions(permissions: Record<string, string>) {
|
||||
const { skipped } = this.permissions.savePermissions(permissions);
|
||||
return { permissions: this.permissions.getAllPermissions(), skipped };
|
||||
async savePermissions(permissions: Record<string, string>) {
|
||||
const { skipped } = await this.permissions.savePermissions(permissions);
|
||||
return { permissions: await this.permissions.getAllPermissions(), skipped };
|
||||
}
|
||||
|
||||
// ── Audit Log ──────────────────────────────────────────────────────────────
|
||||
@@ -664,7 +664,7 @@ export class AdminService {
|
||||
];
|
||||
}
|
||||
|
||||
updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
async updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
|
||||
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
|
||||
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
|
||||
@@ -682,14 +682,14 @@ export class AdminService {
|
||||
// Photo providers are Journey's shelf rows — their whole UI lives inside
|
||||
// journeys, so enabling one under a disabled journey addon would only
|
||||
// advertise an integration nothing can reach.
|
||||
if (provider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) {
|
||||
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
|
||||
return { error: 'Enable the Journey addon first', status: 409 };
|
||||
}
|
||||
|
||||
// Same rule one shelf down: a document provider only exists to serve the
|
||||
// file manager, so switching one on under a disabled Documents addon would
|
||||
// advertise a sync nothing can reach.
|
||||
if (docProvider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) {
|
||||
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
|
||||
return { error: 'Enable the Documents addon first', status: 409 };
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ import { DatabaseService } from '../database/database.service';
|
||||
export class AirportsService implements OnApplicationBootstrap {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
this.backfillFlightEndpoints();
|
||||
await this.backfillFlightEndpoints();
|
||||
} catch (err) {
|
||||
console.error('[DB] Flight endpoint backfill failed:', err);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class AirportsService implements OnApplicationBootstrap {
|
||||
return findByIata(code) as Airport | null;
|
||||
}
|
||||
|
||||
backfillFlightEndpoints(): void {
|
||||
async backfillFlightEndpoints(): Promise<void> {
|
||||
const pending = this.db.prepare(`
|
||||
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
|
||||
FROM reservations r
|
||||
|
||||
@@ -48,16 +48,16 @@ export class DayAssignmentsController {
|
||||
constructor(private readonly assignments: AssignmentsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
async list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
|
||||
if (!this.assignments.dayExists(dayId, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
return { assignments: this.assignments.listDayAssignments(dayId) };
|
||||
return { assignments: await this.assignments.listDayAssignments(dayId) };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('dayId') dayId: string,
|
||||
@@ -70,7 +70,7 @@ export class DayAssignmentsController {
|
||||
if (!this.assignments.placeExists(body.place_id, tripId)) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
}
|
||||
const assignment = this.assignments.createAssignment(dayId, body.place_id, body.notes);
|
||||
const assignment = await this.assignments.createAssignment(dayId, body.place_id, body.notes);
|
||||
this.assignments.broadcast(tripId, 'assignment:created', { assignment }, socketId);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { assignment };
|
||||
@@ -128,7 +128,7 @@ export class AssignmentOpsController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/move')
|
||||
move(
|
||||
async move(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -141,7 +141,7 @@ export class AssignmentOpsController {
|
||||
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
|
||||
throw new HttpException({ error: 'Target day not found' }, 404);
|
||||
}
|
||||
const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
|
||||
const { assignment, oldDayId } = await this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
|
||||
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
|
||||
this.assignments.reconcile(tripId, socketId);
|
||||
return { assignment };
|
||||
@@ -157,7 +157,7 @@ export class AssignmentOpsController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/time')
|
||||
time(
|
||||
async time(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -167,7 +167,7 @@ export class AssignmentOpsController {
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
const { assignment, reordered, vias } = this.assignments.updateTime(id, body.place_time, body.end_time);
|
||||
const { assignment, reordered, vias } = await this.assignments.updateTime(id, body.place_time, body.end_time);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
// The whole day when a start moved stops, or collaborators apply the one row
|
||||
// they were sent to their old order and end up with a third one.
|
||||
@@ -184,7 +184,7 @@ export class AssignmentOpsController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/end-day')
|
||||
endDay(
|
||||
async endDay(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: AssignmentEndDayDto,
|
||||
@@ -193,7 +193,7 @@ export class AssignmentOpsController {
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
const assignment = this.assignments.setEndDay(id, body.end_day);
|
||||
const assignment = await this.assignments.setEndDay(id, body.end_day);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export class AssignmentOpsController {
|
||||
// neighbours; no reconcile — the note doesn't touch the journey skeleton.
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/notes')
|
||||
notes(
|
||||
async notes(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -213,14 +213,14 @@ export class AssignmentOpsController {
|
||||
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
const assignment = this.assignments.updateNotes(id, body.notes);
|
||||
const assignment = await this.assignments.updateNotes(id, body.notes);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/transport')
|
||||
transport(
|
||||
async transport(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -231,8 +231,8 @@ export class AssignmentOpsController {
|
||||
throw new HttpException({ error: 'Assignment not found' }, 404);
|
||||
}
|
||||
const assignment = body.direction === 'incoming'
|
||||
? this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null)
|
||||
: this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
|
||||
? await this.assignments.setIncomingLegTransportMode(id, body.transport_mode ?? null)
|
||||
: await this.assignments.setLegTransportMode(id, body.transport_mode ?? null);
|
||||
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
|
||||
return { assignment };
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.');
|
||||
if (!this.assignments.placeExists(placeId, tripId)) return errorResult('Place not found.');
|
||||
const assignment = this.assignments.createAssignment(dayId, placeId, notes || null);
|
||||
const assignment = await this.assignments.createAssignment(dayId, placeId, notes || null);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:created', { assignment });
|
||||
this.assignments.reconcile(tripId);
|
||||
return ok({ assignment });
|
||||
@@ -74,9 +74,9 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const assignment = this.assignments.setEndDay(assignmentId, end_day);
|
||||
const assignment = await this.assignments.setEndDay(assignmentId, end_day);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return errorResult('Assignment not found.');
|
||||
this.assignments.deleteAssignment(assignmentId);
|
||||
@@ -127,10 +127,10 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return errorResult('Assignment not found.');
|
||||
const { assignment, reordered, vias } = this.assignments.updateTime(
|
||||
const { assignment, reordered, vias } = await this.assignments.updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : existing.assignment_time,
|
||||
end_time !== undefined ? end_time : existing.assignment_end_time
|
||||
@@ -163,9 +163,9 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const assignment = this.assignments.updateNotes(assignmentId, notes);
|
||||
const assignment = await this.assignments.updateNotes(assignmentId, notes);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
@@ -190,11 +190,11 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const assignment = direction === 'incoming'
|
||||
? this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
|
||||
: this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
|
||||
? await this.assignments.setIncomingLegTransportMode(assignmentId, transport_mode ?? null)
|
||||
: await this.assignments.setLegTransportMode(assignmentId, transport_mode ?? null);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
@@ -220,10 +220,10 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
if (!this.days.getDay(newDayId, tripId)) return errorResult('Day not found.');
|
||||
const result = this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
|
||||
const result = await this.assignments.moveAssignment(assignmentId, newDayId, orderIndex ?? 0);
|
||||
// REST parity shape ({ assignment, oldDayId, newDayId }) — the client keys its
|
||||
// per-day assignment map on newDayId, so omitting it filed the moved assignment
|
||||
// under "undefined" on collaborator screens.
|
||||
@@ -269,7 +269,7 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
|
||||
const participants = this.assignments.setParticipants(assignmentId, userIds, tripId);
|
||||
this.guards.safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
@@ -293,7 +293,7 @@ export class AssignmentsMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
|
||||
this.assignments.reorderAssignments(dayId, assignmentIds);
|
||||
// REST parity shape ({ dayId, orderedIds }) — the client only reads orderedIds,
|
||||
|
||||
@@ -25,7 +25,7 @@ interface DayStopRow {
|
||||
* vias; both stay null when the save left every stop where it was.
|
||||
*/
|
||||
export interface AssignmentTimeUpdate {
|
||||
assignment: ReturnType<AssignmentsService['getAssignmentWithPlace']>;
|
||||
assignment: Awaited<ReturnType<AssignmentsService['getAssignmentWithPlace']>>;
|
||||
reordered: { dayId: number; orderedIds: number[] } | null;
|
||||
vias: { dayId: number; vias: RoadtripVia[] } | null;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class AssignmentsService {
|
||||
return this.dbs.canAccessTrip(Number(tripId), userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class AssignmentsService {
|
||||
* Public because the accommodation mirror moves a stop in place and has to hand
|
||||
* the moved row back in exactly this shape.
|
||||
*/
|
||||
getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
async getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
const a = this.dbs.get<AssignmentRow>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
@@ -111,7 +111,7 @@ export class AssignmentsService {
|
||||
|
||||
// Same compact tag projection as listDayAssignments, so an assignment has
|
||||
// one wire shape regardless of which read path produced it.
|
||||
const tags = this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true })[a.place_id] || [];
|
||||
const tags = (await this.queryHelpers.loadTagsByPlaceIds([a.place_id], { compact: true }))[a.place_id] || [];
|
||||
|
||||
const participants = this.dbs.all<Participant>(`
|
||||
SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar
|
||||
@@ -128,7 +128,7 @@ export class AssignmentsService {
|
||||
return formatAssignmentWithPlace(a, tags, participants);
|
||||
}
|
||||
|
||||
listDayAssignments(dayId: string | number) {
|
||||
async listDayAssignments(dayId: string | number) {
|
||||
const assignments = this.dbs.all<AssignmentRow>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
@@ -145,10 +145,10 @@ export class AssignmentsService {
|
||||
`, dayId);
|
||||
|
||||
const placeIds = [...new Set(assignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const assignmentIds = assignments.map(a => a.id);
|
||||
const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds);
|
||||
const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(assignmentIds);
|
||||
|
||||
return assignments.map(a => {
|
||||
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
|
||||
@@ -219,7 +219,7 @@ export class AssignmentsService {
|
||||
`, id, tripId);
|
||||
}
|
||||
|
||||
moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
|
||||
async moveAssignment(id: string | number, newDayId: unknown, orderIndex: number | null | undefined) {
|
||||
// The source day comes from the row, not the caller — callers can't lie
|
||||
// about (or race on) where the assignment was.
|
||||
const oldDayId = this.dbs.transaction(() => {
|
||||
@@ -227,7 +227,7 @@ export class AssignmentsService {
|
||||
this.dbs.run('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?', newDayId, orderIndex ?? 0, id);
|
||||
return row?.day_id;
|
||||
});
|
||||
const updated = this.getAssignmentWithPlace(Number(id));
|
||||
const updated = await this.getAssignmentWithPlace(Number(id));
|
||||
return { assignment: updated, oldDayId };
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export class AssignmentsService {
|
||||
* such a day the order stored and the order drawn can differ. The old sort did the
|
||||
* same.
|
||||
*/
|
||||
updateTime(id: string | number, placeTime: unknown, endTime: unknown): AssignmentTimeUpdate {
|
||||
async updateTime(id: string | number, placeTime: unknown, endTime: unknown): Promise<AssignmentTimeUpdate> {
|
||||
const sorted = this.dbs.transaction(() => {
|
||||
const stored = this.dbs.get<{ day_id: number; start: string | null }>(`
|
||||
SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start
|
||||
@@ -280,7 +280,7 @@ export class AssignmentsService {
|
||||
});
|
||||
|
||||
return {
|
||||
assignment: this.getAssignmentWithPlace(Number(id)),
|
||||
assignment: await this.getAssignmentWithPlace(Number(id)),
|
||||
reordered: sorted ? { dayId: sorted.dayId, orderedIds: sorted.orderedIds } : null,
|
||||
vias: sorted?.viasMoved ? { dayId: sorted.dayId, vias: this.listDayVias(sorted.dayId) } : null,
|
||||
};
|
||||
|
||||
@@ -25,27 +25,27 @@ export class ItineraryRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('itinerary.assign', { permission: 'db:write:itinerary' })
|
||||
assign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async assign(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const placeId = num(params.placeId, 'placeId');
|
||||
const actor = this.guards.requireActor(ctx, 'itinerary');
|
||||
const notes = params.notes === undefined || params.notes === null ? null : str(params.notes, 'notes');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
if (!this.assignments.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
if (!this.assignments.placeExists(placeId, tripId)) throw new ForbiddenResource(`no place ${placeId} on trip ${tripId}`);
|
||||
const assignment = this.assignments.createAssignment(dayId, placeId, notes);
|
||||
const assignment = await this.assignments.createAssignment(dayId, placeId, notes);
|
||||
this.realtime.broadcast(tripId, 'assignment:created', { assignment });
|
||||
this.assignments.reconcile(tripId);
|
||||
return assignment;
|
||||
}
|
||||
|
||||
@PluginMethod('itinerary.unassign', { permission: 'db:write:itinerary' })
|
||||
unassign(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async unassign(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const assignmentId = num(params.assignmentId, 'assignmentId');
|
||||
const actor = this.guards.requireActor(ctx, 'itinerary');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) throw new ForbiddenResource(`no assignment ${assignmentId} on trip ${tripId}`);
|
||||
this.assignments.deleteAssignment(assignmentId);
|
||||
|
||||
@@ -23,9 +23,9 @@ export class AtlasRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('atlas.visited', { permission: 'db:read:atlas' })
|
||||
visited(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async visited(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'reads');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
return {
|
||||
countries: this.atlas.listVisitedCountries(userId),
|
||||
regions: this.atlas.listManuallyVisitedRegions(userId),
|
||||
@@ -33,32 +33,32 @@ export class AtlasRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.bucketList', { permission: 'db:read:atlas' })
|
||||
bucketList(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async bucketList(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'reads');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
return this.atlas.bucketList(userId) as unknown[];
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.markCountry', { permission: 'db:write:atlas' })
|
||||
markCountry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async markCountry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
const code = this.code(params.code, 'code');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
this.atlas.markCountry(userId, code);
|
||||
return { visited: true };
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.unmarkCountry', { permission: 'db:write:atlas' })
|
||||
unmarkCountry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async unmarkCountry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
const code = this.code(params.code, 'code');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
this.atlas.unmarkCountry(userId, code);
|
||||
return { visited: false };
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.markRegion', { permission: 'db:write:atlas' })
|
||||
markRegion(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async markRegion(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
// A missing name falls back to the code, so the row is never nameless.
|
||||
const regionName = typeof params.regionName === 'string' && params.regionName
|
||||
@@ -66,26 +66,26 @@ export class AtlasRpc {
|
||||
: String(params.regionCode ?? '');
|
||||
const regionCode = this.code(params.regionCode, 'regionCode');
|
||||
const countryCode = this.code(params.countryCode, 'countryCode');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
this.atlas.markRegion(userId, regionCode, regionName, countryCode);
|
||||
return { visited: true };
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.unmarkRegion', { permission: 'db:write:atlas' })
|
||||
unmarkRegion(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async unmarkRegion(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
const regionCode = this.code(params.regionCode, 'regionCode');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
this.atlas.unmarkRegion(userId, regionCode);
|
||||
return { visited: false };
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.createBucketItem', { permission: 'db:write:atlas' })
|
||||
createBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bucket item name is required');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
try {
|
||||
return this.atlas.createBucketItem(userId, input as never);
|
||||
} catch (err) {
|
||||
@@ -97,10 +97,10 @@ export class AtlasRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('atlas.deleteBucketItem', { permission: 'db:write:atlas' })
|
||||
deleteBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async deleteBucketItem(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireAtlasUser(ctx, 'writes');
|
||||
const itemId = num(params.itemId, 'itemId');
|
||||
this.requireAtlasAddon();
|
||||
await this.requireAtlasAddon();
|
||||
if (!this.atlas.deleteBucketItem(userId, itemId)) {
|
||||
throw new ForbiddenResource(`no bucket item ${itemId} for this user`);
|
||||
}
|
||||
@@ -121,8 +121,8 @@ export class AtlasRpc {
|
||||
return ctx.actingUserId;
|
||||
}
|
||||
|
||||
private requireAtlasAddon(): void {
|
||||
this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
private async requireAtlasAddon(): Promise<void> {
|
||||
await this.guards.requireAddon(ADDON_IDS.ATLAS, 'atlas');
|
||||
}
|
||||
|
||||
/** Country and region codes are short and stored upper-cased. */
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
|
||||
export class AuditService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private resolveUserEmail(userId: number | null): string {
|
||||
private async resolveUserEmail(userId: number | null): Promise<string> {
|
||||
if (userId == null) return 'anonymous';
|
||||
try {
|
||||
const row = this.dbs.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
|
||||
@@ -77,14 +77,14 @@ export class AuditService {
|
||||
}
|
||||
|
||||
/** Best-effort; never throws — failures are logged only. */
|
||||
writeAudit(entry: {
|
||||
async writeAudit(entry: {
|
||||
userId: number | null;
|
||||
action: string;
|
||||
resource?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
debugDetails?: Record<string, unknown>;
|
||||
ip?: string | null;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
|
||||
this.dbs.run(
|
||||
@@ -92,7 +92,7 @@ export class AuditService {
|
||||
entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null
|
||||
);
|
||||
|
||||
const email = this.resolveUserEmail(entry.userId);
|
||||
const email = await this.resolveUserEmail(entry.userId);
|
||||
const label = ACTION_LABELS[entry.action] || entry.action;
|
||||
const brief = buildInfoSummary(entry.action, entry.details);
|
||||
logInfo(oneLine(`${email} ${label}${brief} ip=${entry.ip || '-'}`));
|
||||
|
||||
@@ -68,13 +68,13 @@ export class AuthPublicController {
|
||||
@Post('register')
|
||||
@Public('creating the account that would carry the session')
|
||||
@HttpCode(201)
|
||||
register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 10);
|
||||
const result = this.auth.registerUser(body);
|
||||
const result = await this.auth.registerUser(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export class AuthPublicController {
|
||||
const started = Date.now();
|
||||
const result = this.auth.loginUser(body);
|
||||
if (result.auditAction) {
|
||||
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
|
||||
@@ -120,15 +120,15 @@ export class AuthPublicController {
|
||||
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
|
||||
const origin = this.auth.getAppUrl();
|
||||
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
|
||||
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
|
||||
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
|
||||
try {
|
||||
const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
|
||||
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
||||
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
||||
} catch {
|
||||
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
|
||||
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
|
||||
}
|
||||
} else {
|
||||
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
|
||||
await this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed);
|
||||
@@ -138,33 +138,33 @@ export class AuthPublicController {
|
||||
@Post('reset-password')
|
||||
@Public('the reset token IS the credential')
|
||||
@HttpCode(200)
|
||||
resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) {
|
||||
async resetPassword(@Body() body: ResetPasswordDto, @Req() req: Request) {
|
||||
// Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on
|
||||
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
|
||||
this.limit('reset', req, 5);
|
||||
const ip = getClientIp(req);
|
||||
const result = this.auth.resetPassword(body);
|
||||
if (result.error) {
|
||||
this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
||||
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true };
|
||||
}
|
||||
this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
||||
await this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('mfa/verify-login')
|
||||
@Public('second factor of a login that has no session yet')
|
||||
@HttpCode(200)
|
||||
verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('mfa', req, 5);
|
||||
const result = this.auth.verifyMfaLogin(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
async changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 5);
|
||||
// Carry the session's remember choice into the re-issued token/cookie so a
|
||||
// "remember me" login survives a password change (#1927). Bearer callers
|
||||
@@ -120,17 +120,17 @@ export class AuthController {
|
||||
// Refresh this device's cookie with the new password_version so the user
|
||||
// stays logged in here while all other sessions are invalidated.
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('me')
|
||||
deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
||||
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
||||
const result = this.auth.deleteAccount(user.id, user.email, user.role);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -145,9 +145,9 @@ export class AuthController {
|
||||
* Nothing is written when nothing changed — the panel saves before every test
|
||||
* click, and each click would otherwise cost a log line.
|
||||
*/
|
||||
private auditApiKeys(userId: number, changed: string[], req: Request): void {
|
||||
private async auditApiKeys(userId: number, changed: string[], req: Request): Promise<void> {
|
||||
if (!changed.length) return;
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId,
|
||||
action: 'settings.api_keys_update',
|
||||
resource: 'api_keys',
|
||||
@@ -157,28 +157,28 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Put('me/maps-key')
|
||||
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
|
||||
async mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
|
||||
// changedKeys is for the audit line, not for the client: destructured off so
|
||||
// the response body stays what it always was.
|
||||
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
|
||||
this.auditApiKeys(user.id, changedKeys, req);
|
||||
await this.auditApiKeys(user.id, changedKeys, req);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('me/api-keys')
|
||||
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
|
||||
async apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
|
||||
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
|
||||
this.auditApiKeys(user.id, changedKeys, req);
|
||||
await this.auditApiKeys(user.id, changedKeys, req);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('me/settings')
|
||||
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
const result = this.profile.updateSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
return { success: result.success, user: result.user };
|
||||
}
|
||||
|
||||
@@ -239,12 +239,12 @@ export class AuthController {
|
||||
|
||||
@Put('app-settings')
|
||||
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
|
||||
updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
|
||||
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
|
||||
const result = this.auth.updateAppSettings(user.id, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
||||
// Named so the settings tab can say which fields the operator holds rather
|
||||
// than showing a saved value that silently did not save.
|
||||
return { success: true, ...(result.managedKeys?.length ? { managed_keys: result.managedKeys } : {}) };
|
||||
@@ -274,25 +274,25 @@ export class AuthController {
|
||||
@Post('mfa/enable')
|
||||
@MfaExempt('completing setup is the way out of the policy')
|
||||
@HttpCode(200)
|
||||
mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
|
||||
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
|
||||
this.limit('mfa', req, 5);
|
||||
const result = this.auth.enableMfa(user.id, body.code);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
|
||||
}
|
||||
|
||||
@Post('mfa/disable')
|
||||
@HttpCode(200)
|
||||
mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
|
||||
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.disableMfa(user.id, user.email, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||
return { success: true, mfa_enabled: result.mfa_enabled };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Request, Response } from 'express';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { validatePassword } from '../common/passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
|
||||
@@ -132,6 +133,7 @@ export class AuthService {
|
||||
private readonly mailer: MailerService,
|
||||
private readonly tokens: EphemeralTokenService,
|
||||
private readonly allowedFileTypes: AllowedFileTypesService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
// Cookie
|
||||
@@ -253,7 +255,7 @@ export class AuthService {
|
||||
return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw);
|
||||
}
|
||||
|
||||
getAppConfig(authenticatedUser: User | undefined | null) {
|
||||
async getAppConfig(authenticatedUser: User | undefined | null) {
|
||||
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
|
||||
const isDemo = readEnv().demo.enabled;
|
||||
const toggles = this.resolveAuthToggles();
|
||||
@@ -350,7 +352,7 @@ export class AuthService {
|
||||
places_details_enabled: placesDetailsEnabled,
|
||||
places_enrich_enabled: placesEnrichEnabled,
|
||||
place_shadow_enabled: placeShadowEnabled,
|
||||
permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined,
|
||||
permissions: authenticatedUser ? (await this.permissions.getAllPermissions()) : undefined,
|
||||
// Case-sensitive on purpose (legacy parity).
|
||||
dev_mode: readEnv().app.nodeEnv === 'development',
|
||||
};
|
||||
@@ -379,7 +381,7 @@ export class AuthService {
|
||||
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
|
||||
}
|
||||
|
||||
registerUser(rawBody: unknown): { error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> } {
|
||||
async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
|
||||
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
@@ -426,7 +428,7 @@ export class AuthService {
|
||||
try {
|
||||
// One transaction for the whole signup: a mid-sequence throw (invite
|
||||
// bookkeeping, trip auto-join) must not leave a half-registered user.
|
||||
return this.db.transaction(() => {
|
||||
return await this.uow.transactional(async () => {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)',
|
||||
username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'
|
||||
@@ -446,7 +448,7 @@ export class AuthService {
|
||||
// Trip-bound invite (#1402): auto-add the freshly registered user to the
|
||||
// trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted.
|
||||
if (validInvite.trip_id) {
|
||||
this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
|
||||
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export class PasskeyController {
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ export class PasskeyController {
|
||||
const started = Date.now();
|
||||
const result = await this.passkeys.passkeyLoginVerify(body);
|
||||
if (result.auditAction) {
|
||||
this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
|
||||
}
|
||||
// Pad to the same floor as password login so timing can't distinguish a
|
||||
// known credential from an unknown one.
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
|
||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
@@ -118,11 +118,11 @@ export class PasskeyController {
|
||||
|
||||
@Delete('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
|
||||
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export class BackupController {
|
||||
}
|
||||
try {
|
||||
const backup = await this.backup.createBackup();
|
||||
this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'backup.create', resource: backup.filename, ip: getClientIp(req), details: { size: backup.size } });
|
||||
return { success: true, backup };
|
||||
} catch {
|
||||
throw new HttpException({ error: 'Error creating backup' }, 500);
|
||||
@@ -109,7 +109,7 @@ export class BackupController {
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'backup.restore', resource: filename, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
@@ -142,7 +142,7 @@ export class BackupController {
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'backup.upload_restore', resource: origName, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
@@ -164,10 +164,10 @@ export class BackupController {
|
||||
|
||||
@ManagedForbidden('the operator schedules backups off-volume; a second schedule inside it is not one')
|
||||
@Put('auto-settings')
|
||||
updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) {
|
||||
async updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) {
|
||||
try {
|
||||
const settings = this.autoBackup.updateAutoSettings(body || {});
|
||||
this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'backup.auto_settings', ip: getClientIp(req), details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days } });
|
||||
return { settings };
|
||||
} catch (err) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
@@ -185,7 +185,7 @@ export class BackupController {
|
||||
throw new HttpException({ error: 'Backup not found' }, 404);
|
||||
}
|
||||
await this.backup.deleteBackup(filename);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'backup.delete', resource: filename, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export class BookingImportService {
|
||||
}
|
||||
|
||||
/** True when the LLM fallback is enabled and configured for this user. */
|
||||
aiAvailable(userId: number): boolean {
|
||||
async aiAvailable(userId: number): Promise<boolean> {
|
||||
return this.llmParse.isAvailable(userId);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export class BookingImportService {
|
||||
onProgress?: (done: number, total: number, fileName: string) => void,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const kitineraryAvailable = this.extractor.isAvailable();
|
||||
const aiAvailable = this.llmParse.isAvailable(userId);
|
||||
const aiAvailable = await this.llmParse.isAvailable(userId);
|
||||
if (!kitineraryAvailable && !aiAvailable) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
@@ -296,7 +296,7 @@ export class BookingImportService {
|
||||
};
|
||||
}
|
||||
|
||||
const { reservation, accommodationCreated } = this.reservations.create(tripId, {
|
||||
const { reservation, accommodationCreated } = await this.reservations.create(tripId, {
|
||||
...reservationData,
|
||||
place_id: placeId,
|
||||
create_accommodation: createAccommodation,
|
||||
@@ -309,7 +309,7 @@ export class BookingImportService {
|
||||
|
||||
// Turn an extracted price into a real linked cost (Costs addon), so the
|
||||
// booking shows up as an expense — not just a price in metadata.
|
||||
if (this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
if ((await this.addons.isAddonEnabled(ADDON_IDS.BUDGET))) {
|
||||
const meta =
|
||||
reservationData.metadata && typeof reservationData.metadata === 'object'
|
||||
? (reservationData.metadata as Record<string, unknown>)
|
||||
|
||||
@@ -99,11 +99,11 @@ export class BudgetMcp {
|
||||
* and is passed through. Reads ride the leaf TripMembershipService — the
|
||||
* hydrated member services all live in modules that import this one.
|
||||
*/
|
||||
private resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
|
||||
private async resolveMemberIds(tripId: number, member_ids?: number[]): Promise<number[] | undefined> {
|
||||
if (member_ids !== undefined) return member_ids;
|
||||
const ownerId = this.membership.getOwnerId(tripId);
|
||||
const ownerId = await this.membership.getOwnerId(tripId);
|
||||
if (ownerId === null) return undefined;
|
||||
return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)]));
|
||||
return Array.from(new Set([ownerId, ...(await this.membership.listMemberUserIds(tripId))]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,7 +205,7 @@ export class BudgetMcp {
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
|
||||
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
|
||||
if (members !== undefined) {
|
||||
@@ -214,7 +214,7 @@ export class BudgetMcp {
|
||||
}
|
||||
// The split participants are the members of an uneven split; the equal-split
|
||||
// list still carries them so the row's `persons` count comes out the same.
|
||||
const splitIds = members ? members.map(m => m.user_id) : this.resolveMemberIds(tripId, member_ids);
|
||||
const splitIds = members ? members.map(m => m.user_id) : await this.resolveMemberIds(tripId, member_ids);
|
||||
const itemData = { category, name, total_price, currency, member_ids: splitIds, members, payers, expense_date, place_id, note };
|
||||
// Freeze the live FX rate at entry time so a settled position isn't re-opened
|
||||
// when live rates drift (#1445) — same as the REST create path.
|
||||
@@ -238,7 +238,7 @@ export class BudgetMcp {
|
||||
async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = this.budget.deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return errorResult('Budget item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId });
|
||||
@@ -280,7 +280,7 @@ export class BudgetMcp {
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
|
||||
if (members !== undefined) {
|
||||
// An edit that leaves the total alone still has to reconcile against it, so
|
||||
@@ -324,10 +324,10 @@ export class BudgetMcp {
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (place_id != null && !this.placeOnTrip(tripId, place_id)) return errorResult('place_id does not belong to this trip.');
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined);
|
||||
const members = (userIds && userIds.length > 0) ? userIds : await this.resolveMemberIds(tripId, undefined);
|
||||
try {
|
||||
const item = this.db.transaction(() => {
|
||||
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members, place_id });
|
||||
@@ -356,7 +356,7 @@ export class BudgetMcp {
|
||||
async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const result = this.budget.updateMembers(itemId, tripId, userIds);
|
||||
if (!result) return errorResult('Budget item not found.');
|
||||
const item = this.budget.getBudgetItem(itemId, tripId);
|
||||
@@ -380,7 +380,7 @@ export class BudgetMcp {
|
||||
async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid);
|
||||
this.guards.safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, userId: memberId, paid: paid ? 1 : 0 });
|
||||
return ok({ member });
|
||||
@@ -445,7 +445,7 @@ export class BudgetMcp {
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Freeze-then-write composite, same as the REST path: the rate for the display
|
||||
// currency is frozen at entry time (#1445).
|
||||
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId);
|
||||
@@ -476,7 +476,7 @@ export class BudgetMcp {
|
||||
) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Freeze-then-write composite, same as the REST path: an edit that leaves the
|
||||
// currency alone keeps the rate frozen at settle time.
|
||||
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at });
|
||||
@@ -499,7 +499,7 @@ export class BudgetMcp {
|
||||
async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = this.budget.deleteSettlement(settlementId, tripId);
|
||||
if (!deleted) return errorResult('Settlement not found.');
|
||||
this.guards.safeBroadcast(tripId, 'budget:settlement-deleted', { settlementId });
|
||||
|
||||
@@ -118,7 +118,7 @@ export class BudgetService {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('budget_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,22 +38,22 @@ export class CostsRpc {
|
||||
|
||||
@PluginMethod('costs.getByTrip', { permission: 'db:read:costs' })
|
||||
getByTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () => {
|
||||
this.requireBudgetAddon();
|
||||
return this.guards.tripRead(params, ctx, async () => {
|
||||
await this.requireBudgetAddon();
|
||||
return this.budget.listBudgetItems(num(params.tripId, 'tripId'));
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod('costs.listMine', { permission: 'db:read:costs' })
|
||||
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
// Cross-trip aggregate. The acting user is host-bound; a job or onLoad is refused
|
||||
// the same way tripRead refuses one.
|
||||
if (ctx.actingUserId === undefined) throw new ForbiddenResource('cost reads require an authenticated user context');
|
||||
this.requireBudgetAddon();
|
||||
await this.requireBudgetAddon();
|
||||
// The leaf membership read, not TripsService.list: TripsModule imports this
|
||||
// one, so injecting TripsService here would close a cycle. Same id set,
|
||||
// same newest-first order.
|
||||
const tripIds = this.membership.listAccessibleTripIds(ctx.actingUserId);
|
||||
const tripIds = await this.membership.listAccessibleTripIds(ctx.actingUserId);
|
||||
return tripIds.flatMap((id) => this.budget.listBudgetItems(id));
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ export class CostsRpc {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.requireCostActor(ctx);
|
||||
this.requireBudgetAddon();
|
||||
await this.requireBudgetAddon();
|
||||
const parsed = budgetCreateItemRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
|
||||
this.requireCostEdit(tripId, actor);
|
||||
await this.requireCostEdit(tripId, actor);
|
||||
// BudgetService.create freezes the FX rate and resolves members/payers, so the
|
||||
// plugin path produces the same row the web app would.
|
||||
const item = await this.budget.create(String(tripId), parsed.data);
|
||||
@@ -77,10 +77,10 @@ export class CostsRpc {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const itemId = num(params.itemId, 'itemId');
|
||||
const actor = this.requireCostActor(ctx);
|
||||
this.requireBudgetAddon();
|
||||
await this.requireBudgetAddon();
|
||||
const parsed = budgetUpdateItemRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid cost: ${schemaMessage(parsed.error)}`);
|
||||
this.requireCostEdit(tripId, actor);
|
||||
await this.requireCostEdit(tripId, actor);
|
||||
// update re-freezes the FX rate on a currency change, exactly like create.
|
||||
const item = await this.budget.update(String(itemId), String(tripId), parsed.data);
|
||||
if (item == null) throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
|
||||
@@ -89,12 +89,12 @@ export class CostsRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('costs.delete', { permission: 'db:write:costs' })
|
||||
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const itemId = num(params.itemId, 'itemId');
|
||||
const actor = this.requireCostActor(ctx);
|
||||
this.requireBudgetAddon();
|
||||
this.requireCostEdit(tripId, actor);
|
||||
await this.requireBudgetAddon();
|
||||
await this.requireCostEdit(tripId, actor);
|
||||
if (!this.budget.remove(String(itemId), String(tripId))) {
|
||||
throw new ForbiddenResource(`no cost ${itemId} on trip ${tripId}`);
|
||||
}
|
||||
@@ -110,14 +110,14 @@ export class CostsRpc {
|
||||
return ctx.actingUserId;
|
||||
}
|
||||
|
||||
private requireBudgetAddon(): void {
|
||||
this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs');
|
||||
private async requireBudgetAddon(): Promise<void> {
|
||||
await this.guards.requireAddon(ADDON_IDS.BUDGET, 'costs');
|
||||
}
|
||||
|
||||
/** Trip access plus budget_edit, with the cost-specific refusal message. */
|
||||
private requireCostEdit(tripId: number, userId: number): void {
|
||||
private async requireCostEdit(tripId: number, userId: number): Promise<void> {
|
||||
if (!this.db.canAccessTrip(tripId, userId)) throw new ForbiddenResource(`no access to trip ${tripId}`);
|
||||
if (!this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId)) {
|
||||
if (!(await this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId))) {
|
||||
throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,32 +28,32 @@ export class CategoriesController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(): CategoryListResponse {
|
||||
return { categories: this.categories.list() };
|
||||
async list(): Promise<CategoryListResponse> {
|
||||
return { categories: await this.categories.list() };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): { category: Category } {
|
||||
return { category: this.categories.create(user.id, body.name, body.color, body.icon) };
|
||||
async create(@CurrentUser() user: User, @Body() body: CategoryCreateDto): Promise<{ category: Category }> {
|
||||
return { category: await this.categories.create(user.id, body.name, body.color, body.icon) };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
update(@Param('id') id: string, @Body() body: CategoryUpdateDto): { category: Category } {
|
||||
if (!this.categories.getById(id)) {
|
||||
async update(@Param('id') id: string, @Body() body: CategoryUpdateDto): Promise<{ category: Category }> {
|
||||
if (!(await this.categories.getById(id))) {
|
||||
throw new HttpException({ error: 'Category not found' }, 404);
|
||||
}
|
||||
return { category: this.categories.update(id, body.name, body.color, body.icon) };
|
||||
return { category: await this.categories.update(id, body.name, body.color, body.icon) };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
remove(@Param('id') id: string): { success: boolean } {
|
||||
if (!this.categories.getById(id)) {
|
||||
async remove(@Param('id') id: string): Promise<{ success: boolean }> {
|
||||
if (!(await this.categories.getById(id))) {
|
||||
throw new HttpException({ error: 'Category not found' }, 404);
|
||||
}
|
||||
this.categories.remove(id);
|
||||
await this.categories.remove(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class CategoriesMcp {
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async listCategories(_args: Record<string, never>, _ctx: McpContext) {
|
||||
const categories = this.categories.list();
|
||||
const categories = await this.categories.list();
|
||||
return ok({ categories });
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class CategoriesMcp {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
// The palette is instance-wide; the REST route restricts management to admins. Match it.
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
const category = this.categories.create(ctx.userId, name, color, icon);
|
||||
const category = await this.categories.create(ctx.userId, name, color, icon);
|
||||
return ok({ category });
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ export class CategoriesMcp {
|
||||
async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
|
||||
const category = this.categories.update(categoryId, name, color, icon);
|
||||
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
|
||||
const category = await this.categories.update(categoryId, name, color, icon);
|
||||
return ok({ category });
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export class CategoriesMcp {
|
||||
async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!this.categories.getById(categoryId)) return errorResult('Category not found');
|
||||
this.categories.remove(categoryId);
|
||||
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
|
||||
await this.categories.remove(categoryId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class CategoriesMcp {
|
||||
mimeType: 'application/json',
|
||||
})
|
||||
async categoriesResource(uri: URL, _ctx: McpContext) {
|
||||
const categories = this.categories.list();
|
||||
const categories = await this.categories.list();
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -11,7 +11,7 @@ export class CategoriesRpc {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@PluginMethod('categories.list', { permission: 'db:read:categories' })
|
||||
list(): unknown[] {
|
||||
return this.categories.list() as unknown[];
|
||||
async list(): Promise<unknown[]> {
|
||||
return (await this.categories.list()) as unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import { DatabaseService } from '../database/database.service';
|
||||
export class CategoriesService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
list(): Category[] {
|
||||
async list(): Promise<Category[]> {
|
||||
return this.db.all<Category>('SELECT * FROM categories ORDER BY name ASC');
|
||||
}
|
||||
|
||||
getById(id: string | number): Category | undefined {
|
||||
async getById(id: string | number): Promise<Category | undefined> {
|
||||
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id);
|
||||
}
|
||||
|
||||
create(userId: number, name: string, color?: string, icon?: string): Category {
|
||||
async create(userId: number, name: string, color?: string, icon?: string): Promise<Category> {
|
||||
const result = this.db.run(
|
||||
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)',
|
||||
name,
|
||||
@@ -34,7 +34,7 @@ export class CategoriesService {
|
||||
) as Category;
|
||||
}
|
||||
|
||||
update(id: string | number, name?: string, color?: string, icon?: string): Category {
|
||||
async update(id: string | number, name?: string, color?: string, icon?: string): Promise<Category> {
|
||||
this.db.run(
|
||||
`
|
||||
UPDATE categories SET
|
||||
@@ -51,7 +51,7 @@ export class CategoriesService {
|
||||
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id) as Category;
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
async remove(id: string | number): Promise<void> {
|
||||
this.db.run('DELETE FROM categories WHERE id = ?', id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +118,8 @@ export class CollabController {
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.collab.canEdit(trip, user)) {
|
||||
private async requireEdit(trip: NonNullable<ReturnType<CollabService['verifyTripAccess']>>, user: User): Promise<void> {
|
||||
if (!(await this.collab.canEdit(trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export class CollabController {
|
||||
};
|
||||
try {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
if (!this.collab.canUploadFiles(trip, user)) {
|
||||
if (!(await this.collab.canUploadFiles(trip, user))) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -345,12 +345,12 @@ export class CollabController {
|
||||
let trip;
|
||||
try {
|
||||
trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
} catch (err) {
|
||||
cleanupSpool();
|
||||
throw err;
|
||||
}
|
||||
if (uploaded.length && !this.collab.canUploadFiles(trip, user)) {
|
||||
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
|
||||
cleanupSpool();
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export class CollabMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned });
|
||||
this.guards.safeBroadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
@@ -127,7 +127,7 @@ export class CollabMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned });
|
||||
if (!note) return errorResult('Note not found.');
|
||||
this.guards.safeBroadcast(tripId, 'collab:note:updated', { note });
|
||||
@@ -148,7 +148,7 @@ export class CollabMcp {
|
||||
async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = await this.collab.deleteNote(tripId, noteId);
|
||||
if (!deleted) return errorResult('Note not found.');
|
||||
this.guards.safeBroadcast(tripId, 'collab:note:deleted', { noteId });
|
||||
@@ -195,7 +195,7 @@ export class CollabMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline });
|
||||
this.guards.safeBroadcast(tripId, 'collab:poll:created', { poll });
|
||||
return ok({ poll });
|
||||
@@ -216,7 +216,7 @@ export class CollabMcp {
|
||||
async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex);
|
||||
if (result.error) return errorResult(result.error);
|
||||
this.guards.safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
|
||||
@@ -237,7 +237,7 @@ export class CollabMcp {
|
||||
async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const poll = this.collab.closePoll(tripId, pollId);
|
||||
if (!poll) return errorResult('Poll not found.');
|
||||
this.guards.safeBroadcast(tripId, 'collab:poll:closed', { poll });
|
||||
@@ -258,7 +258,7 @@ export class CollabMcp {
|
||||
async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = this.collab.deletePoll(tripId, pollId);
|
||||
if (!deleted) return errorResult('Poll not found.');
|
||||
this.guards.safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
|
||||
@@ -297,7 +297,7 @@ export class CollabMcp {
|
||||
async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null);
|
||||
if (result.error) return errorResult(result.error);
|
||||
this.guards.safeBroadcast(tripId, 'collab:message:created', { message: result.message });
|
||||
@@ -318,7 +318,7 @@ export class CollabMcp {
|
||||
async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const result = this.collab.deleteMessage(tripId, messageId, ctx.userId);
|
||||
if (result.error) return errorResult(result.error);
|
||||
this.guards.safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
|
||||
@@ -340,7 +340,7 @@ export class CollabMcp {
|
||||
async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('collab_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji);
|
||||
if (!result.found) return errorResult('Message not found.');
|
||||
this.guards.safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
|
||||
|
||||
@@ -31,62 +31,62 @@ export class CollabRpc {
|
||||
|
||||
@PluginMethod('collab.listNotes', { permission: 'db:read:collab' })
|
||||
listNotes(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () => {
|
||||
this.requireCollabAddon();
|
||||
return this.guards.tripRead(params, ctx, async () => {
|
||||
await this.requireCollabAddon();
|
||||
return this.collab.listNotes(num(params.tripId, 'tripId')) as unknown[];
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod('collab.listPolls', { permission: 'db:read:collab' })
|
||||
listPolls(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () => {
|
||||
this.requireCollabAddon();
|
||||
return this.guards.tripRead(params, ctx, async () => {
|
||||
await this.requireCollabAddon();
|
||||
return this.collab.listPolls(num(params.tripId, 'tripId')) as unknown[];
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod('collab.listMessages', { permission: 'db:read:collab' })
|
||||
listMessages(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.guards.tripRead(params, ctx, () => {
|
||||
this.requireCollabAddon();
|
||||
return this.guards.tripRead(params, ctx, async () => {
|
||||
await this.requireCollabAddon();
|
||||
const before = params.before != null ? num(params.before, 'before') : undefined;
|
||||
return this.collab.listMessages(num(params.tripId, 'tripId'), before) as unknown[];
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod('collab.createNote', { permission: 'db:write:collab' })
|
||||
createNote(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createNote(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'collab note');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.title !== 'string' || input.title.trim() === '') throw new BadParams('note title is required');
|
||||
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
this.requireCollabAddon();
|
||||
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
await this.requireCollabAddon();
|
||||
const note = this.collab.createNote(String(tripId), actor, input as never);
|
||||
this.realtime.broadcast(tripId, 'collab:note:created', { note }, undefined);
|
||||
return note;
|
||||
}
|
||||
|
||||
@PluginMethod('collab.createPoll', { permission: 'db:write:collab' })
|
||||
createPoll(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createPoll(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'collab poll');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.question !== 'string' || input.question.trim() === '') throw new BadParams('poll question is required');
|
||||
if (!Array.isArray(input.options) || input.options.length < 2) throw new BadParams('a poll needs at least two options');
|
||||
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
this.requireCollabAddon();
|
||||
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
await this.requireCollabAddon();
|
||||
const poll = this.collab.createPoll(String(tripId), actor, input as never);
|
||||
this.realtime.broadcast(tripId, 'collab:poll:created', { poll }, undefined);
|
||||
return poll;
|
||||
}
|
||||
|
||||
@PluginMethod('collab.votePoll', { permission: 'db:write:collab' })
|
||||
votePoll(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async votePoll(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'collab poll');
|
||||
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
this.requireCollabAddon();
|
||||
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
await this.requireCollabAddon();
|
||||
const result = this.collab.votePoll(String(tripId), String(num(params.pollId, 'pollId')), actor, num(params.optionIndex, 'optionIndex'));
|
||||
// The service reports its own validation failures rather than throwing.
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
@@ -95,14 +95,14 @@ export class CollabRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('collab.createMessage', { permission: 'db:write:collab' })
|
||||
createMessage(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createMessage(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'collab message');
|
||||
if (typeof params.text !== 'string' || params.text.trim() === '' || params.text.length > 4000) {
|
||||
throw new BadParams('message text is required (max 4000 chars)');
|
||||
}
|
||||
this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
this.requireCollabAddon();
|
||||
await this.guards.requireTripEdit(tripId, actor, COLLAB_EDIT_ACTION);
|
||||
await this.requireCollabAddon();
|
||||
const replyTo = typeof params.replyTo === 'number' ? params.replyTo : null;
|
||||
const result = this.collab.createMessage(String(tripId), actor, params.text, replyTo);
|
||||
if (result.error) throw new BadParams(result.error);
|
||||
@@ -110,7 +110,7 @@ export class CollabRpc {
|
||||
return result.message;
|
||||
}
|
||||
|
||||
private requireCollabAddon(): void {
|
||||
this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
private async requireCollabAddon(): Promise<void> {
|
||||
await this.guards.requireAddon(ADDON_IDS.COLLAB, 'collab');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +147,11 @@ export class CollabService {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('collab_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
canUploadFiles(trip: Trip, user: User): boolean {
|
||||
async canUploadFiles(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('file_upload', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ export class CollectionsMcp {
|
||||
})
|
||||
async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) {
|
||||
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
|
||||
try { return ok(this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
|
||||
try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
// ── Labels ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,56 +29,56 @@ export class CollectionsRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('collections.listMine', { permission: 'db:read:collections' })
|
||||
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireCollectionsUser(ctx, 'reads');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.collections.listCollections(userId);
|
||||
}
|
||||
|
||||
@PluginMethod('collections.get', { permission: 'db:read:collections' })
|
||||
get(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async get(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
// getCollection is user-scoped by the service, so a plugin only ever fetches one
|
||||
// the acting user may see.
|
||||
const userId = this.requireCollectionsUser(ctx, 'reads');
|
||||
const id = num(params.id, 'id');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.collections.getCollection(userId, id);
|
||||
}
|
||||
|
||||
@PluginMethod('collections.create', { permission: 'db:write:collections' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const parsed = collectionCreateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
|
||||
const userId = this.requireCollectionsUser(ctx, 'writes');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.mapCollectionError(() => this.collections.createCollection(userId, parsed.data as never));
|
||||
}
|
||||
|
||||
@PluginMethod('collections.update', { permission: 'db:write:collections' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const parsed = collectionUpdateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid collection: ${schemaMessage(parsed.error)}`);
|
||||
const userId = this.requireCollectionsUser(ctx, 'writes');
|
||||
const id = num(params.id, 'id');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.mapCollectionError(() => this.collections.updateCollection(userId, id, parsed.data as never, undefined));
|
||||
}
|
||||
|
||||
@PluginMethod('collections.savePlace', { permission: 'db:write:collections' })
|
||||
savePlace(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async savePlace(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const parsed = collectionSavePlaceRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
|
||||
const userId = this.requireCollectionsUser(ctx, 'writes');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.mapCollectionError(() => this.collections.savePlace(userId, parsed.data as never, undefined));
|
||||
}
|
||||
|
||||
@PluginMethod('collections.copyToTrip', { permission: 'db:write:collections' })
|
||||
copyToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async copyToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const parsed = collectionCopyToTripRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid copy request: ${schemaMessage(parsed.error)}`);
|
||||
const userId = this.requireCollectionsUser(ctx, 'writes');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never));
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class CollectionsRpc {
|
||||
async deletePlace(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireCollectionsUser(ctx, 'writes');
|
||||
const placeId = num(params.placeId, 'placeId');
|
||||
this.requireCollectionsAddon();
|
||||
await this.requireCollectionsAddon();
|
||||
// deletePlace is async (it deletes the underlying storage object): await it so a
|
||||
// refusal actually reaches the plugin as RESOURCE_FORBIDDEN/BAD_PARAMS instead of
|
||||
// being dropped as an unhandled rejection while this returns {deleted: true} anyway.
|
||||
@@ -101,8 +101,8 @@ export class CollectionsRpc {
|
||||
return ctx.actingUserId;
|
||||
}
|
||||
|
||||
private requireCollectionsAddon(): void {
|
||||
this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
private async requireCollectionsAddon(): Promise<void> {
|
||||
await this.guards.requireAddon(ADDON_IDS.COLLECTIONS, 'collections');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1232,11 +1232,11 @@ export class CollectionsService {
|
||||
// Copy to trip
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } {
|
||||
async copyToTrip(userId: number, body: CollectionCopyToTripRequest): Promise<{ copied: number; skipped: { id: number; name: string }[] }> {
|
||||
const trip = this.db.canAccessTrip(body.trip_id, userId);
|
||||
if (!trip) httpError(404, 'Trip not found');
|
||||
const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user';
|
||||
if (!this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId)) {
|
||||
if (!(await this.permissions.checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId))) {
|
||||
httpError(403, 'Not allowed to edit this trip');
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export class DayNotesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.notes.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const note = this.notes.create(dayId, tripId, text, time, icon, sort_order, color);
|
||||
this.guards.safeBroadcast(tripId, 'dayNote:created', { dayId, note });
|
||||
@@ -100,7 +100,7 @@ export class DayNotesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const existing = this.notes.getNote(noteId, dayId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
const note = this.notes.update(noteId, existing, { text, time: time !== undefined ? time : undefined, icon, color, sort_order });
|
||||
@@ -122,7 +122,7 @@ export class DayNotesMcp {
|
||||
async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.notes.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const note = this.notes.getNote(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
this.notes.remove(noteId);
|
||||
|
||||
@@ -35,13 +35,13 @@ export class DayNotesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('daynotes.create', { permission: 'db:write:daynotes' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const actor = this.guards.requireActor(ctx, 'day note');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.text !== 'string' || input.text.trim() === '') throw new BadParams('note text is required');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
if (!this.dayNotes.dayExists(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
const i = input as DayNoteInput;
|
||||
const note = this.dayNotes.create(dayId, tripId, i.text ?? '', i.time, i.icon, i.sort_order);
|
||||
@@ -50,12 +50,12 @@ export class DayNotesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('daynotes.update', { permission: 'db:write:daynotes' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const noteId = num(params.noteId, 'noteId');
|
||||
const actor = this.guards.requireActor(ctx, 'day note');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
const current = this.dayNotes.getNote(noteId, dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
|
||||
const note = this.dayNotes.update(noteId, current as never, asPayload(params.input) as DayNoteInput);
|
||||
@@ -64,12 +64,12 @@ export class DayNotesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('daynotes.delete', { permission: 'db:write:daynotes' })
|
||||
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const noteId = num(params.noteId, 'noteId');
|
||||
const actor = this.guards.requireActor(ctx, 'day note');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_NOTE_EDIT_ACTION);
|
||||
const current = this.dayNotes.getNote(noteId, dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no note ${noteId} on day ${dayId}`);
|
||||
this.dayNotes.remove(noteId);
|
||||
|
||||
@@ -37,7 +37,7 @@ export class DayNotesService {
|
||||
return this.dbs.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
canEdit(trip: TripAccess, user: User): boolean {
|
||||
async canEdit(trip: TripAccess, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export class DaysController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
async reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: DayReorderDto,
|
||||
@@ -71,7 +71,7 @@ export class DaysController {
|
||||
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
|
||||
}
|
||||
try {
|
||||
this.days.reorder(tripId, body.orderedIds);
|
||||
await this.days.reorder(tripId, body.orderedIds);
|
||||
} catch (err) {
|
||||
if (err instanceof DayReorderError) {
|
||||
throw new HttpException({ error: err.message }, 400);
|
||||
@@ -84,7 +84,7 @@ export class DaysController {
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -98,14 +98,14 @@ export class DaysController {
|
||||
// The zod-parsed body carries only the keys the client actually sent, so
|
||||
// the service's presence sentinels preserve the omitted column (the client
|
||||
// updates notes and title in separate requests).
|
||||
const day = this.days.update(id, current as never, body);
|
||||
const day = await this.days.update(id, current as never, body);
|
||||
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':id/transport')
|
||||
transport(
|
||||
async transport(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -115,7 +115,7 @@ export class DaysController {
|
||||
if (!this.days.getDay(id, tripId)) {
|
||||
throw new HttpException({ error: 'Day not found' }, 404);
|
||||
}
|
||||
const day = this.days.setDefaultTransportMode(id, body.transport_mode ?? null);
|
||||
const day = await this.days.setDefaultTransportMode(id, body.transport_mode ?? null);
|
||||
this.days.broadcast(tripId, 'day:updated', { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@@ -64,13 +64,13 @@ export class DaysMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const current = this.days.getDay(dayId, tripId);
|
||||
if (!current) return errorResult('Day not found.');
|
||||
// The rest spread carries only the keys the caller actually sent, which is
|
||||
// what update()'s presence sentinels need: naming the two fields here would
|
||||
// hand it an undefined notes on a title-only call and wipe the day's notes.
|
||||
const updated = this.days.update(dayId, current, fields);
|
||||
const updated = await this.days.update(dayId, current, fields);
|
||||
this.guards.safeBroadcast(tripId, 'day:updated', { day: updated });
|
||||
return ok({ day: updated });
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export class DaysMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (position === undefined) {
|
||||
const day = this.days.create(tripId, date, notes);
|
||||
this.guards.safeBroadcast(tripId, 'day:created', { day });
|
||||
@@ -128,9 +128,9 @@ export class DaysMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
this.days.reorder(tripId, orderedIds);
|
||||
await this.days.reorder(tripId, orderedIds);
|
||||
} catch (err) {
|
||||
// A non-permutation and an inverted stay are both the caller's input, so
|
||||
// they come back as tool errors rather than a throw the SDK has to dress up.
|
||||
@@ -155,7 +155,7 @@ export class DaysMcp {
|
||||
async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
|
||||
this.days.remove(dayId);
|
||||
// REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id }
|
||||
@@ -181,9 +181,9 @@ export class DaysMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.');
|
||||
const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
|
||||
const day = await this.days.setDefaultTransportMode(dayId, transport_mode ?? null);
|
||||
this.guards.safeBroadcast(tripId, 'day:updated', { day });
|
||||
return ok({ day });
|
||||
}
|
||||
@@ -206,7 +206,7 @@ export class DaysMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const { days } = this.days.list(id);
|
||||
const { days } = await this.days.list(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -24,12 +24,12 @@ export class DaysRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('days.create', { permission: 'db:write:days' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'day');
|
||||
const parsed = dayCreateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
const input = parsed.data as { date?: string; notes?: string };
|
||||
const day = this.days.create(tripId, input.date, input.notes);
|
||||
this.realtime.broadcast(tripId, 'day:created', { day });
|
||||
@@ -37,27 +37,27 @@ export class DaysRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('days.update', { permission: 'db:write:days' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const actor = this.guards.requireActor(ctx, 'day');
|
||||
const parsed = dayUpdateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid day: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
// getDay scopes the row to the trip before the write touches it.
|
||||
const current = this.days.getDay(dayId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
const day = this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null });
|
||||
const day = await this.days.update(dayId, current, parsed.data as { notes?: string; title?: string | null });
|
||||
this.realtime.broadcast(tripId, 'day:updated', { day });
|
||||
return day;
|
||||
}
|
||||
|
||||
@PluginMethod('days.delete', { permission: 'db:write:days' })
|
||||
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const dayId = num(params.dayId, 'dayId');
|
||||
const actor = this.guards.requireActor(ctx, 'day');
|
||||
this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DAY_EDIT_ACTION);
|
||||
if (!this.days.getDay(dayId, tripId)) throw new ForbiddenResource(`no day ${dayId} on trip ${tripId}`);
|
||||
this.days.remove(dayId);
|
||||
this.realtime.broadcast(tripId, 'day:deleted', { dayId });
|
||||
|
||||
@@ -72,7 +72,7 @@ export class DaysService {
|
||||
return this.db.canAccessTrip(Number(tripId), userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('day_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export class DaysService {
|
||||
// Day assignment helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getAssignmentsForDay(dayId: number | string) {
|
||||
async getAssignmentsForDay(dayId: number | string) {
|
||||
const assignments = this.db.all<AssignmentRow>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
@@ -103,7 +103,7 @@ export class DaysService {
|
||||
// One batched tag load instead of the legacy per-assignment query; the
|
||||
// non-compact loader returns the same full tag rows (t.* minus the join
|
||||
// key), so the output shape is unchanged.
|
||||
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]);
|
||||
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds([...new Set(assignments.map(a => a.place_id))]);
|
||||
|
||||
return assignments.map(a => {
|
||||
const tags = tagsByPlaceId[a.place_id] || [];
|
||||
@@ -160,7 +160,7 @@ export class DaysService {
|
||||
// Day CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
list(tripId: string | number) {
|
||||
async list(tripId: string | number) {
|
||||
const days = this.db.all<Day>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
|
||||
|
||||
if (days.length === 0) {
|
||||
@@ -186,10 +186,10 @@ export class DaysService {
|
||||
`, ...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
|
||||
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const allAssignmentIds = allAssignments.map(a => a.id);
|
||||
const participantsByAssignment = this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds);
|
||||
const participantsByAssignment = await this.queryHelpers.loadParticipantsByAssignmentIds(allAssignmentIds);
|
||||
|
||||
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
|
||||
for (const a of allAssignments) {
|
||||
@@ -233,7 +233,7 @@ export class DaysService {
|
||||
return this.db.get<Day>('SELECT * FROM days WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
}
|
||||
|
||||
update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
|
||||
async update(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
|
||||
// Both columns use the presence sentinel: an absent key preserves the
|
||||
// current value (the legacy version always wrote notes, so setting a title
|
||||
// silently wiped the day's notes — the client sends the two fields in
|
||||
@@ -244,7 +244,7 @@ export class DaysService {
|
||||
id
|
||||
);
|
||||
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
|
||||
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
|
||||
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,10 +252,10 @@ export class DaysService {
|
||||
* notes/title the way the general day update would, and symmetric with the
|
||||
* per-leg assignment transport setter. Per-segment leg modes still override it.
|
||||
*/
|
||||
setDefaultTransportMode(id: string | number, mode: string | null) {
|
||||
async setDefaultTransportMode(id: string | number, mode: string | null) {
|
||||
this.db.run('UPDATE days SET default_transport_mode = ? WHERE id = ?', mode ?? null, id);
|
||||
const updatedDay = this.db.get<Day>('SELECT * FROM days WHERE id = ?', id)!;
|
||||
return { ...updatedDay, assignments: this.getAssignmentsForDay(id) };
|
||||
return { ...updatedDay, assignments: await this.getAssignmentsForDay(id) };
|
||||
}
|
||||
|
||||
remove(id: string | number): void {
|
||||
|
||||
@@ -54,8 +54,8 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
* well as on arrival, so a switch thrown during the debounce window still
|
||||
* takes effect.
|
||||
*/
|
||||
private syncIsOn(link: LinkRow): boolean {
|
||||
if (this.sync.isSwitchedOff(link)) return false;
|
||||
private async syncIsOn(link: LinkRow): Promise<boolean> {
|
||||
if ((await this.sync.isSwitchedOff(link))) return false;
|
||||
const killSwitch = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED,
|
||||
)?.value;
|
||||
@@ -72,12 +72,12 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
@Post(':token')
|
||||
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
|
||||
@HttpCode(200)
|
||||
nudge(@Param('token') token: string, @Req() req: Request) {
|
||||
async nudge(@Param('token') token: string, @Req() req: Request) {
|
||||
const link = this.config.getLinkByToken(token);
|
||||
// Always 200, even for an unknown token: a 404 here would let anyone probe
|
||||
// which tokens exist, and a provider that gets an error will retry anyway.
|
||||
if (!link || link.sync_enabled !== 1) return { received: true };
|
||||
if (!this.syncIsOn(link)) return { received: true };
|
||||
if (!(await this.syncIsOn(link))) return { received: true };
|
||||
|
||||
// The secret is only known to a provider TREK subscribed at itself, so it
|
||||
// is only demanded there. A URL pasted into a store by hand (Papra, or a
|
||||
@@ -108,11 +108,11 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
*/
|
||||
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>, isRetry = false): void {
|
||||
if (this.pending.has(linkId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
this.pending.delete(linkId);
|
||||
const fresh = reload();
|
||||
if (!fresh || fresh.sync_enabled !== 1) return;
|
||||
if (!this.syncIsOn(fresh)) return;
|
||||
if (!(await this.syncIsOn(fresh))) return;
|
||||
void this.sync.syncLink(fresh).then((res) => {
|
||||
// A run that was already in flight answers `busy`, and the changes this
|
||||
// nudge was about may have landed after that run read the folder. Ask
|
||||
|
||||
@@ -382,7 +382,7 @@ export class DocSyncController {
|
||||
// Refused before the shelved rows are touched, so a binding an admin
|
||||
// switched off stays exactly as it was and resumes where it stopped. A code
|
||||
// rather than a sentence: the client says it in the reader's language.
|
||||
if (this.sync.isSwitchedOff(link)) {
|
||||
if ((await this.sync.isSwitchedOff(link))) {
|
||||
throw new HttpException({ error: PROVIDER_DISABLED }, 409);
|
||||
}
|
||||
// A person asking for a run is also asking for the rows that gave up to be
|
||||
|
||||
@@ -82,7 +82,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
|
||||
|
||||
async tick(): Promise<void> {
|
||||
try {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return;
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return;
|
||||
const killSwitch = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED)?.value;
|
||||
// Unrecognised values mean ON here because the setting is absent by
|
||||
// default; only an explicit 'false' stops the sync.
|
||||
|
||||
@@ -51,9 +51,9 @@ export class DocSyncMcp {
|
||||
access: { group: 'files', mode: 'read' },
|
||||
when: documentsAddonOn,
|
||||
})
|
||||
getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
async getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
return ok(this.sync.status(tripId));
|
||||
return ok(await this.sync.status(tripId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
@@ -108,7 +108,7 @@ export class DocSyncMcp {
|
||||
// Refused as the REST route refuses it, before the shelved rows below are
|
||||
// touched: a binding an admin switched off stays exactly as it was, so it
|
||||
// resumes where it stopped once the provider is back on.
|
||||
if (this.sync.isSwitchedOff(link)) {
|
||||
if ((await this.sync.isSwitchedOff(link))) {
|
||||
results.push({ linkId: link.id, provider: link.provider_id, state: 'disabled', errorCode: PROVIDER_DISABLED });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -133,8 +133,8 @@ export class DocSyncService {
|
||||
* resumes exactly where it stopped once the switch is back on. Public because
|
||||
* a manual run has to refuse before it un-shelves anything.
|
||||
*/
|
||||
isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): boolean {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return true;
|
||||
async isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): Promise<boolean> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true;
|
||||
return !this.config.enabledProviderIds().includes(link.provider_id);
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export class DocSyncService {
|
||||
// First, before anything that records a failure: a binding whose provider
|
||||
// is switched off must come back as it was left. The webhook and a first
|
||||
// run after binding reach this without asking beforehand.
|
||||
if (this.isSwitchedOff(link)) {
|
||||
if ((await this.isSwitchedOff(link))) {
|
||||
return { state: 'disabled', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
|
||||
}
|
||||
// An orphaned binding runs for nobody. Its credential belongs to somebody
|
||||
@@ -1100,7 +1100,7 @@ export class DocSyncService {
|
||||
}
|
||||
|
||||
/** Per-trip view for the UI: what is synced, what needs attention. */
|
||||
status(tripId: number): Record<string, unknown> {
|
||||
async status(tripId: number): Promise<Record<string, unknown>> {
|
||||
const links = this.config.listLinks(tripId);
|
||||
const counts = this.db.connection
|
||||
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
|
||||
@@ -1138,22 +1138,26 @@ export class DocSyncService {
|
||||
.all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
|
||||
|
||||
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
|
||||
// `map` cannot await the provider-off read, so the projection runs as an
|
||||
// explicit loop — same order, same rows.
|
||||
const linkCards = [];
|
||||
for (const l of links) {
|
||||
const row = byLink.get(l.id);
|
||||
linkCards.push({
|
||||
...this.config.publicLink(l, null),
|
||||
// Paused rather than failed, so the card can say why nothing moves
|
||||
// without the binding's own state being touched.
|
||||
providerOff: await this.isSwitchedOff(l),
|
||||
holdings: {
|
||||
inTrek: totalHere.n,
|
||||
atProvider: Number(row?.atProvider ?? 0),
|
||||
paired: Number(row?.paired ?? 0),
|
||||
missing: Number(row?.missing ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
links: links.map((l) => {
|
||||
const row = byLink.get(l.id);
|
||||
return {
|
||||
...this.config.publicLink(l, null),
|
||||
// Paused rather than failed, so the card can say why nothing moves
|
||||
// without the binding's own state being touched.
|
||||
providerOff: this.isSwitchedOff(l),
|
||||
holdings: {
|
||||
inTrek: totalHere.n,
|
||||
atProvider: Number(row?.atProvider ?? 0),
|
||||
paired: Number(row?.paired ?? 0),
|
||||
missing: Number(row?.missing ?? 0),
|
||||
},
|
||||
};
|
||||
}),
|
||||
links: linkCards,
|
||||
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ export class FeedsMcp {
|
||||
}
|
||||
|
||||
/** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */
|
||||
private denyTripFeed(tripId: number, userId: number) {
|
||||
private async denyTripFeed(tripId: number, userId: number) {
|
||||
if (!this.db.canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ export class FeedsMcp {
|
||||
access: { group: 'trips', mode: 'share' },
|
||||
})
|
||||
async getTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
const denied = this.denyTripFeed(tripId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const denied = await this.denyTripFeed(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
return ok(this.feeds.getTripToken(String(tripId), ctx.userId, this.base()));
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ export class FeedsMcp {
|
||||
})
|
||||
async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const denied = this.denyTripFeed(tripId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const denied = await this.denyTripFeed(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
return ok(this.feeds.generateTripToken(String(tripId), ctx.userId, this.base()));
|
||||
}
|
||||
|
||||
@@ -107,8 +107,8 @@ export class FeedsMcp {
|
||||
})
|
||||
async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const denied = this.denyTripFeed(tripId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const denied = await this.denyTripFeed(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
return ok(this.feeds.rotateTripToken(String(tripId), ctx.userId, this.base()));
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ export class FeedsMcp {
|
||||
})
|
||||
async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const denied = this.denyTripFeed(tripId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const denied = await this.denyTripFeed(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
this.feeds.disableTripToken(String(tripId), ctx.userId);
|
||||
// Matches the route, which answers the cleared token as a null URL.
|
||||
return ok({ feed_url: null });
|
||||
|
||||
@@ -129,7 +129,7 @@ export class FilesController {
|
||||
if (isDemoWriteBlocked(this.env, user.email)) {
|
||||
throw new HttpException(DEMO_WRITE_ERROR, 403);
|
||||
}
|
||||
if (!this.files.can('file_upload', trip, user)) {
|
||||
if (!(await this.files.can('file_upload', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission to upload files' }, 403);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -173,8 +173,8 @@ export class FilesController {
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Put(':id')
|
||||
update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
async update(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!(await this.files.can('file_edit', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission to edit files' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
@@ -194,8 +194,8 @@ export class FilesController {
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Patch(':id/star')
|
||||
star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
async star(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!(await this.files.can('file_edit', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
@@ -210,7 +210,7 @@ export class FilesController {
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete('trash/empty')
|
||||
async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
if (!(await this.files.can('file_delete', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const deleted = await this.files.emptyTrash(tripId);
|
||||
@@ -220,7 +220,7 @@ export class FilesController {
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id/permanent')
|
||||
async permanent(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
if (!(await this.files.can('file_delete', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getDeletedFile(id, tripId);
|
||||
@@ -234,8 +234,8 @@ export class FilesController {
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
async remove(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!(await this.files.can('file_delete', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission to delete files' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
@@ -250,8 +250,8 @@ export class FilesController {
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201.
|
||||
restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!this.files.can('file_delete', trip, user)) {
|
||||
async restore(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!(await this.files.can('file_delete', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getDeletedFile(id, tripId);
|
||||
@@ -266,8 +266,8 @@ export class FilesController {
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Post(':id/link')
|
||||
@HttpCode(200) // Express answers link with res.json (200).
|
||||
link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
async link(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: FileLinkDto) {
|
||||
if (!(await this.files.can('file_edit', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
const file = this.files.getFileById(id, tripId);
|
||||
@@ -281,8 +281,8 @@ export class FilesController {
|
||||
|
||||
@UseGuards(TripAccessGuard)
|
||||
@Delete(':id/link/:linkId')
|
||||
unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
||||
if (!this.files.can('file_edit', trip, user)) {
|
||||
async unlink(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string, @Param('linkId') linkId: string) {
|
||||
if (!(await this.files.can('file_edit', trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
// deleteFileLink scopes by (linkId, fileId) only, so the file itself has to
|
||||
|
||||
@@ -123,7 +123,7 @@ export class FilesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const current = this.files.getFileById(fileId, tripId);
|
||||
if (!current) return errorResult('File not found.');
|
||||
// Same enforcement as the REST route's assertLinkTargets, through the same
|
||||
@@ -163,7 +163,7 @@ export class FilesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
|
||||
// The REST body allows all three to be absent and stores a link row pointing at
|
||||
// nothing. A tool caller that gets here with no target made a mistake, and saying
|
||||
@@ -194,7 +194,7 @@ export class FilesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved
|
||||
// against :tripId first, exactly as the REST route does it. Otherwise a member of
|
||||
// any trip could drop a link row belonging to a foreign trip's file.
|
||||
|
||||
@@ -101,7 +101,7 @@ export class FilesRpc {
|
||||
if (input.content_base64.length > 14 * 1024 * 1024) {
|
||||
throw new BadParams('file exceeds the 10MB plugin upload cap');
|
||||
}
|
||||
this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION);
|
||||
return this.writeFile(tripId, input as unknown as CreateInput, actor);
|
||||
}
|
||||
|
||||
@@ -148,11 +148,11 @@ export class FilesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('files.createLink', { permission: 'db:write:files' })
|
||||
createLink(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createLink(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const fileId = num(params.fileId, 'fileId');
|
||||
const actor = this.guards.requireActor(ctx, 'file link');
|
||||
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
|
||||
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
const opts = asPayload(params.opts) as { reservation_id?: number; assignment_id?: number; place_id?: number };
|
||||
// A link target on another trip would otherwise attach this trip's file to it.
|
||||
@@ -166,11 +166,11 @@ export class FilesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('files.update', { permission: 'db:write:files' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const fileId = num(params.fileId, 'fileId');
|
||||
const actor = this.guards.requireActor(ctx, 'file');
|
||||
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
|
||||
const current = this.files.getFileById(fileId, tripId);
|
||||
if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
const input = asPayload(params.input) as { description?: string; place_id?: number | null; reservation_id?: number | null };
|
||||
@@ -190,11 +190,11 @@ export class FilesRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('files.softDelete', { permission: 'db:write:files' })
|
||||
softDelete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async softDelete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const fileId = num(params.fileId, 'fileId');
|
||||
const actor = this.guards.requireActor(ctx, 'file');
|
||||
this.guards.requireTripEdit(tripId, actor, DELETE_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, DELETE_ACTION);
|
||||
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
this.files.softDeleteFile(fileId);
|
||||
this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined);
|
||||
|
||||
@@ -85,7 +85,7 @@ export class FilesService {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
can(action: FilePermission, trip: Trip, user: User): boolean {
|
||||
async can(action: FilePermission, trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission(action, user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ export class FeaturesController {
|
||||
}
|
||||
|
||||
@Get('features')
|
||||
features() {
|
||||
async features() {
|
||||
return {
|
||||
bookingImport: this.extractor.isAvailable(),
|
||||
// Addon-level flag (per-user config availability is reported per-file in
|
||||
// the preview response). Drives whether the client shows AI affordances.
|
||||
aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
aiParsing: await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export class AirtrailImportService {
|
||||
const ids = chain.map(f => String(f.id));
|
||||
try {
|
||||
const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId);
|
||||
const { reservation } = this.reservations.create(tripId, mapped as any);
|
||||
const { reservation } = await this.reservations.create(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
@@ -278,7 +278,7 @@ export class AirtrailImportService {
|
||||
}
|
||||
|
||||
try {
|
||||
const { reservation } = this.reservations.create(tripId, mapped as any);
|
||||
const { reservation } = await this.reservations.create(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
|
||||
@@ -34,8 +34,8 @@ export class AirtrailLinkService {
|
||||
) {}
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
syncGloballyEnabled(): boolean {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
|
||||
async syncGloballyEnabled(): Promise<boolean> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL))) return false;
|
||||
const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'");
|
||||
return row?.value !== 'false';
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export class AirtrailLinkService {
|
||||
* next pull's AirTrail-wins policy can't silently revert the local edit.
|
||||
*/
|
||||
async pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
|
||||
if (!this.syncGloballyEnabled()) return;
|
||||
if (!(await this.syncGloballyEnabled())) return;
|
||||
|
||||
const row = this.db.get<{
|
||||
id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number;
|
||||
|
||||
@@ -38,7 +38,7 @@ export class AirtrailSyncService {
|
||||
* than a module-level flag, because the service is a container singleton. */
|
||||
private running = false;
|
||||
|
||||
syncGloballyEnabled(): boolean {
|
||||
async syncGloballyEnabled(): Promise<boolean> {
|
||||
return this.link.syncGloballyEnabled();
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export class AirtrailSyncService {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
await this.reservations.update(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
this.db.run(
|
||||
'UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?',
|
||||
hash,
|
||||
@@ -110,7 +110,7 @@ export class AirtrailSyncService {
|
||||
/** Background poll across every connected owner (scheduler). */
|
||||
async runAirtrailSync(): Promise<void> {
|
||||
if (this.running) return;
|
||||
if (!this.link.syncGloballyEnabled()) return;
|
||||
if (!(await this.link.syncGloballyEnabled())) return;
|
||||
this.running = true;
|
||||
let changed = 0;
|
||||
try {
|
||||
@@ -132,7 +132,7 @@ export class AirtrailSyncService {
|
||||
* background poll.
|
||||
*/
|
||||
async runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
|
||||
if (!this.link.syncGloballyEnabled()) return { changed: 0 };
|
||||
if (!(await this.link.syncGloballyEnabled())) return { changed: 0 };
|
||||
try {
|
||||
return { changed: await this.syncOwner(userId) };
|
||||
} catch (err) {
|
||||
|
||||
@@ -100,7 +100,7 @@ export class AirtrailService {
|
||||
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
if (ssrf.isPrivate) {
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId,
|
||||
action: 'airtrail.private_ip_configured',
|
||||
ip: clientIp,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type DawarichSuggestionList,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { AtlasService } from '../atlas/atlas.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
@@ -53,6 +54,7 @@ export class DawarichSuggestionsService {
|
||||
private readonly assignments: AssignmentsService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -175,7 +177,7 @@ export class DawarichSuggestionsService {
|
||||
* MCP tool and any future caller each shape the failure their own way — the
|
||||
* atlas domain's `BucketItemExistsError` is the precedent.
|
||||
*/
|
||||
accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult {
|
||||
async accept(userId: number, id: number, body: DawarichAccept, sid?: string): Promise<DawarichAcceptResult> {
|
||||
const row = this.db.get<SuggestionRow>(
|
||||
'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
|
||||
id,
|
||||
@@ -201,12 +203,12 @@ export class DawarichSuggestionsService {
|
||||
* written: permission to touch the trip is not permission to attach a place
|
||||
* to somebody else's day.
|
||||
*/
|
||||
private acceptAsPlace(
|
||||
private async acceptAsPlace(
|
||||
userId: number,
|
||||
row: SuggestionRow,
|
||||
body: DawarichAccept,
|
||||
sid?: string,
|
||||
): DawarichAcceptResult {
|
||||
): Promise<DawarichAcceptResult> {
|
||||
const tripId = body.tripId ?? row.trip_id;
|
||||
if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400);
|
||||
|
||||
@@ -219,10 +221,10 @@ export class DawarichSuggestionsService {
|
||||
// owner, a member accepting a stay must be refused exactly as they would be
|
||||
// in the planner. Asked here rather than in the controller so the MCP tool
|
||||
// cannot take a different route to the same write.
|
||||
this.requirePermission('place_edit', access.user_id, userId);
|
||||
await this.requirePermission('place_edit', access.user_id, userId);
|
||||
|
||||
if (body.dayId !== undefined) {
|
||||
this.requirePermission('day_edit', access.user_id, userId);
|
||||
await this.requirePermission('day_edit', access.user_id, userId);
|
||||
if (!this.assignments.dayExists(body.dayId, tripId)) {
|
||||
throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400);
|
||||
}
|
||||
@@ -235,7 +237,7 @@ export class DawarichSuggestionsService {
|
||||
// a place with no assignment, or a place nobody recorded as accepted — is
|
||||
// worse than none: the stay would come back as unhandled while the place it
|
||||
// already produced sat on the trip.
|
||||
const { created, placeId, assignment } = this.db.transaction(() => {
|
||||
const { created, placeId, assignment } = await this.uow.transactional(async () => {
|
||||
const place = this.places.create(String(tripId), {
|
||||
name: body.name?.trim() || row.name,
|
||||
lat,
|
||||
@@ -253,7 +255,7 @@ export class DawarichSuggestionsService {
|
||||
// because this path made it.
|
||||
this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id);
|
||||
|
||||
const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined;
|
||||
const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined;
|
||||
this.markAccepted(row.id, 'place', { placeId: id });
|
||||
// Re-read, so what goes out on the wire carries the source: everyone else
|
||||
// on the trip should see the Dawarich mark on it too, not only the person
|
||||
@@ -379,16 +381,16 @@ export class DawarichSuggestionsService {
|
||||
* tool holds `ctx.userId`, and a permission that depended on which door
|
||||
* somebody came through would not be a permission.
|
||||
*/
|
||||
private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void {
|
||||
private async requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): Promise<void> {
|
||||
const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
|
||||
const allowed = this.permissions.checkPermission(
|
||||
const allowed = await this.permissions.checkPermission(
|
||||
action,
|
||||
actor?.role ?? 'user',
|
||||
tripOwnerId,
|
||||
userId,
|
||||
tripOwnerId !== userId,
|
||||
);
|
||||
if (!allowed) throw new AcceptError('forbidden', 'No permission', 403);
|
||||
if (!(await allowed)) throw new AcceptError('forbidden', 'No permission', 403);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,13 +73,13 @@ export class DawarichSyncService {
|
||||
) {}
|
||||
|
||||
/** The addon gate, evaluated per tick so an admin toggle lands without a restart. */
|
||||
syncGloballyEnabled(): boolean {
|
||||
async syncGloballyEnabled(): Promise<boolean> {
|
||||
return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH);
|
||||
}
|
||||
|
||||
/** Every connected user, one after another. The cron's entry point. */
|
||||
async runSync(): Promise<void> {
|
||||
if (!this.syncGloballyEnabled()) return;
|
||||
if (!(await this.syncGloballyEnabled())) return;
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
@@ -132,7 +132,7 @@ export class DawarichSyncService {
|
||||
|
||||
/** The body of a sync, with the guard above already held. */
|
||||
private async syncUserOnce(userId: number): Promise<DawarichSyncOutcome> {
|
||||
if (!this.syncGloballyEnabled()) {
|
||||
if (!(await this.syncGloballyEnabled())) {
|
||||
this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
|
||||
return { state: 'failed', created: 0, updated: 0, missing: 0 };
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ export class DawarichController {
|
||||
|
||||
@Delete('settings')
|
||||
@HttpCode(200)
|
||||
disconnect(@CurrentUser() user: User, @Req() req: Request) {
|
||||
this.dawarich.disconnect(user.id, getClientIp(req));
|
||||
async disconnect(@CurrentUser() user: User, @Req() req: Request) {
|
||||
await this.dawarich.disconnect(user.id, getClientIp(req));
|
||||
this.tracks.forget(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -138,7 +138,10 @@ export class DawarichController {
|
||||
@Body() body: DawarichAcceptDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
return this.guard(() =>
|
||||
// guardAsync, not guard: accept() is async now, so its AcceptError arrives as
|
||||
// a rejection a synchronous try/catch cannot see — and the domain's own code
|
||||
// would be dropped from the body.
|
||||
return this.guardAsync(() =>
|
||||
this.suggestions.accept(user.id, parseId(id), body, socketId(req)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class DawarichService {
|
||||
return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' };
|
||||
}
|
||||
if (ssrf.isPrivate) {
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId,
|
||||
action: 'dawarich.private_ip_configured',
|
||||
ip: clientIp,
|
||||
@@ -212,9 +212,9 @@ export class DawarichService {
|
||||
* their journal because they revoked an API key would be the integration
|
||||
* destroying user content on its way out. What goes is the credential.
|
||||
*/
|
||||
disconnect(userId: number, clientIp: string | null): void {
|
||||
async disconnect(userId: number, clientIp: string | null): Promise<void> {
|
||||
this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId);
|
||||
this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
|
||||
await this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,17 +50,17 @@ export class JournalRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('journal.listMine', { permission: 'db:read:journal' })
|
||||
listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async listMine(_params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'reads');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
return this.journey.listJourneys(userId);
|
||||
}
|
||||
|
||||
@PluginMethod('journal.getEntries', { permission: 'db:read:journal' })
|
||||
getEntries(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async getEntries(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'reads');
|
||||
const journeyId = num(params.journeyId, 'journeyId');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
// listEntries self-gates via canAccessJourney and returns null when the user
|
||||
// cannot see it.
|
||||
const entries = this.journey.listEntries(journeyId, userId);
|
||||
@@ -69,32 +69,32 @@ export class JournalRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('journal.createEntry', { permission: 'db:write:journal' })
|
||||
createEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'writes');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.entry_date !== 'string' || input.entry_date === '') throw new BadParams('entry_date is required');
|
||||
const journeyId = num(params.journeyId, 'journeyId');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
const entry = this.journey.createEntry(journeyId, userId, input as never);
|
||||
if (!entry) throw new ForbiddenResource(`no editable journey ${journeyId} for this user`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@PluginMethod('journal.updateEntry', { permission: 'db:write:journal' })
|
||||
updateEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async updateEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'writes');
|
||||
const entryId = num(params.entryId, 'entryId');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
const entry = 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;
|
||||
}
|
||||
|
||||
@PluginMethod('journal.deleteEntry', { permission: 'db:write:journal' })
|
||||
deleteEntry(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async deleteEntry(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'writes');
|
||||
const entryId = num(params.entryId, 'entryId');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
if (!this.journey.deleteEntry(entryId, userId)) {
|
||||
throw new ForbiddenResource(`no editable journal entry ${entryId} for this user`);
|
||||
}
|
||||
@@ -102,10 +102,10 @@ export class JournalRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('journal.createJourney', { permission: 'db:write:journal' })
|
||||
createJourney(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createJourney(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'writes');
|
||||
const input = asPayload(params.input);
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
||||
if (!title) throw new BadParams('journal title is required');
|
||||
return this.journey.createJourney(userId, {
|
||||
@@ -137,7 +137,7 @@ export class JournalRpc {
|
||||
const entryId = num(params.entryId, 'entryId');
|
||||
// num() accepts "3" and 1.5; a row id is neither.
|
||||
if (!Number.isInteger(entryId) || entryId <= 0) throw new BadParams('entryId must be a positive integer');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
|
||||
const parsed = journalPluginPhotoInputSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid photo input: ${parsed.error.issues[0]?.message ?? 'unknown'}`);
|
||||
@@ -188,10 +188,10 @@ export class JournalRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('journal.deleteJourney', { permission: 'db:write:journal' })
|
||||
deleteJourney(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async deleteJourney(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const userId = this.requireJournalUser(ctx, 'writes');
|
||||
const journeyId = num(params.journeyId, 'journeyId');
|
||||
this.requireJourneyAddon();
|
||||
await this.requireJourneyAddon();
|
||||
if (!this.journey.deleteJourney(journeyId, userId)) {
|
||||
throw new ForbiddenResource(`no deletable journal ${journeyId} for this user`);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export class JournalRpc {
|
||||
return ctx.actingUserId;
|
||||
}
|
||||
|
||||
private requireJourneyAddon(): void {
|
||||
this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
private async requireJourneyAddon(): Promise<void> {
|
||||
await this.guards.requireAddon(ADDON_IDS.JOURNEY, 'journey');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class JourneyService {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
journeyAddonEnabled(): boolean {
|
||||
async journeyAddonEnabled(): Promise<boolean> {
|
||||
return this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ export class LlmConfigResolver {
|
||||
* else null. This is the single place the API key is decrypted, and the single
|
||||
* place that decides which endpoint the server is allowed to call (#1772).
|
||||
*/
|
||||
resolve(userId: number): ResolvedLlmConfig | null {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
|
||||
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
|
||||
return this.readInstanceConfig() ?? this.readUserConfig(userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,12 @@ export class LlmParseService {
|
||||
) {}
|
||||
|
||||
/** True when the addon is enabled AND a usable config resolves for this user. */
|
||||
isAvailable(userId: number): boolean {
|
||||
return this.llmConfig.resolve(userId) !== null;
|
||||
async isAvailable(userId: number): Promise<boolean> {
|
||||
return (await this.llmConfig.resolve(userId)) !== null;
|
||||
}
|
||||
|
||||
async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise<LlmParseResult> {
|
||||
const config = this.llmConfig.resolve(userId);
|
||||
const config = await this.llmConfig.resolve(userId);
|
||||
if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] };
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -65,7 +65,7 @@ export class McpToolGuardsService {
|
||||
* matching REST route uses. Returns true when the user may perform `action`
|
||||
* on `tripId`.
|
||||
*/
|
||||
hasTripPermission(action: string, tripId: number | string, userId: number): boolean {
|
||||
async hasTripPermission(action: string, tripId: number | string, userId: number): Promise<boolean> {
|
||||
const trip = this.db.get<{ user_id?: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
|
||||
if (!trip) return false;
|
||||
const userRow = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', userId);
|
||||
|
||||
@@ -159,7 +159,7 @@ export class McpTransportService {
|
||||
}
|
||||
|
||||
async handle(req: Request, res: Response): Promise<void> {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
res.status(403).json({ error: 'MCP is not enabled' });
|
||||
return;
|
||||
}
|
||||
@@ -281,22 +281,25 @@ export class McpTransportService {
|
||||
const transportHolder: { current: StreamableHTTPServerTransport | null } = { current: null };
|
||||
const onInvoke = (info: { kind: string; name: string }): void => {
|
||||
if (info.kind !== 'tool') return;
|
||||
try {
|
||||
// The registry fires this from the SDK's tool callback and cannot await it,
|
||||
// so the now-async write runs on its own and reports its own failure —
|
||||
// which is what the try/catch here always did (recipe R1.5).
|
||||
void (async () => {
|
||||
const sid = transportHolder.current?.sessionId;
|
||||
const ip = (sid ? sessions.get(sid)?.lastClientIp : null) ?? createIp;
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'mcp.tool_call',
|
||||
resource: info.name,
|
||||
details: { clientId: clientId ?? 'native' },
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[MCP] tool-call audit failed:', (err as Error | undefined)?.message ?? err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke);
|
||||
await registerTools(this.registry, server, user.id, scopes, isStaticToken, getDeprecationNotice, onInvoke);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
|
||||
@@ -105,7 +105,7 @@ export class ImmichService {
|
||||
userId
|
||||
);
|
||||
if (ssrf.isPrivate) {
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId,
|
||||
action: 'immich.private_ip_configured',
|
||||
ip: clientIp,
|
||||
|
||||
@@ -38,8 +38,8 @@ const SYNOLOGY_DEFAULT_LIMIT = 100;
|
||||
* provider counts as on, whatever its row says. Which of the two providers is
|
||||
* on is a per-call question, answered by providerRefusal().
|
||||
*/
|
||||
const anyPhotoProviderEnabled = (_ctx: McpContext, self: MemoriesMcp): boolean =>
|
||||
self.enabledProviderIds().length > 0;
|
||||
const anyPhotoProviderEnabled = async (_ctx: McpContext, self: MemoriesMcp): Promise<boolean> =>
|
||||
(await self.enabledProviderIds()).length > 0;
|
||||
|
||||
/**
|
||||
* Memories MCP surface: finding photos in a connected Immich or Synology Photos
|
||||
@@ -73,8 +73,8 @@ export class MemoriesMcp {
|
||||
* Public because the `when:` gate above is a module-level function rather than
|
||||
* a method, the same reason the addon gates need a public `addons`.
|
||||
*/
|
||||
enabledProviderIds(): string[] {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return [];
|
||||
async enabledProviderIds(): Promise<string[]> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
|
||||
return this.db.all<{ id: string }>('SELECT id FROM photo_providers WHERE enabled = 1').map((row) => row.id);
|
||||
}
|
||||
|
||||
@@ -84,10 +84,10 @@ export class MemoriesMcp {
|
||||
* disabled provider refuses the same way on both surfaces. The browse routes
|
||||
* themselves never checked it, so this only ever narrows what REST allows.
|
||||
*/
|
||||
private providerRefusal(provider: ProviderId) {
|
||||
private async providerRefusal(provider: ProviderId) {
|
||||
const row = this.db.get<{ enabled: number }>('SELECT enabled FROM photo_providers WHERE id = ?', provider);
|
||||
if (!row) return errorResult(`Provider: "${provider}" is not supported`);
|
||||
if (row.enabled !== 1 || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))
|
||||
if (row.enabled !== 1 || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)))
|
||||
return errorResult(`Provider: "${provider}" is not enabled, contact server administrator`);
|
||||
return null;
|
||||
}
|
||||
@@ -111,8 +111,8 @@ export class MemoriesMcp {
|
||||
{ provider, from, to, page, size, utc_offset_minutes }: { provider: ProviderId; from?: string; to?: string; page?: number; size?: number; utc_offset_minutes?: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = this.providerRefusal(provider);
|
||||
if (refused) return refused;
|
||||
const refused = await this.providerRefusal(provider);
|
||||
if ((await refused)) return refused;
|
||||
|
||||
if (provider === 'immich') {
|
||||
// Same coercion the REST route performs on the body before calling.
|
||||
@@ -144,8 +144,8 @@ export class MemoriesMcp {
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async listProviderAlbums({ provider }: { provider: ProviderId }, ctx: McpContext) {
|
||||
const refused = this.providerRefusal(provider);
|
||||
if (refused) return refused;
|
||||
const refused = await this.providerRefusal(provider);
|
||||
if ((await refused)) return refused;
|
||||
|
||||
if (provider === 'immich') {
|
||||
const result = await this.immich.listAlbums(ctx.userId);
|
||||
@@ -177,8 +177,8 @@ export class MemoriesMcp {
|
||||
{ provider, album_id, passphrase }: { provider: ProviderId; album_id: string; passphrase?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = this.providerRefusal(provider);
|
||||
if (refused) return refused;
|
||||
const refused = await this.providerRefusal(provider);
|
||||
if ((await refused)) return refused;
|
||||
|
||||
if (provider === 'immich') {
|
||||
const result = await this.immich.getAlbumPhotos(ctx.userId, album_id);
|
||||
|
||||
@@ -37,7 +37,7 @@ export class ThumbnailService {
|
||||
async ensureLocalThumbnail(
|
||||
originalRelPath: string,
|
||||
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return null
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return null
|
||||
|
||||
// The DB stores uploads-relative paths ('journey/<file>'); anything else
|
||||
// is not a local journey photo and has no thumbnail to derive.
|
||||
@@ -88,7 +88,7 @@ export class ThumbnailService {
|
||||
* (airtrail-sync.job.ts precedent).
|
||||
*/
|
||||
async sweepOrphanThumbs(): Promise<number> {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return 0
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0
|
||||
|
||||
const live = new Set<string>()
|
||||
for (const row of this.db.all<{ file_path: string }>(
|
||||
|
||||
@@ -36,17 +36,17 @@ export class UnifiedMemoriesService {
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private _providers(): Array<{id: string; enabled: boolean}> {
|
||||
private async _providers(): Promise<Array<{id: string; enabled: boolean}>> {
|
||||
// A provider only counts as enabled while the journey addon is — its whole
|
||||
// surface lives inside journeys. Covers rows left enabled from before
|
||||
// updateAddon cascaded the journey disable.
|
||||
const journeyOn = this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
const journeyOn = await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
const rows = this.db.prepare('SELECT id, enabled FROM photo_providers').all() as Array<{id: string; enabled: number}>;
|
||||
return rows.map(r => ({ id: r.id, enabled: journeyOn && r.enabled === 1 }));
|
||||
}
|
||||
|
||||
private _validProvider(provider: string): ServiceResult<string> {
|
||||
const providers = this._providers();
|
||||
private async _validProvider(provider: string): Promise<ServiceResult<string>> {
|
||||
const providers = await this._providers();
|
||||
const found = providers.find(p => p.id === provider);
|
||||
if (!found) {
|
||||
return fail(`Provider: "${provider}" is not supported`, 400);
|
||||
@@ -60,7 +60,7 @@ export class UnifiedMemoriesService {
|
||||
|
||||
|
||||
|
||||
listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
|
||||
async listTripPhotos(tripId: string, userId: number): Promise<ServiceResult<any[]>> {
|
||||
const access = this.db.canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
@@ -68,7 +68,7 @@ export class UnifiedMemoriesService {
|
||||
|
||||
try {
|
||||
|
||||
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
|
||||
const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id);
|
||||
|
||||
if (enabledProviders.length === 0) {
|
||||
return fail('No photo providers enabled', 400);
|
||||
@@ -92,14 +92,14 @@ export class UnifiedMemoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
|
||||
async listTripAlbumLinks(tripId: string, userId: number): Promise<ServiceResult<any[]>> {
|
||||
const access = this.db.canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
}
|
||||
|
||||
|
||||
const enabledProviders = this._providers().filter(p => p.enabled).map(p => p.id);
|
||||
const enabledProviders = (await this._providers()).filter(p => p.enabled).map(p => p.id);
|
||||
|
||||
if (enabledProviders.length === 0) {
|
||||
return fail('No photo providers enabled', 400);
|
||||
@@ -133,8 +133,8 @@ export class UnifiedMemoriesService {
|
||||
//-----------------------------------------------
|
||||
// managing photos in trip
|
||||
|
||||
private _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
|
||||
const providerResult = this._validProvider(provider);
|
||||
private async _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): Promise<ServiceResult<boolean>> {
|
||||
const providerResult = await this._validProvider(provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
@@ -169,14 +169,14 @@ export class UnifiedMemoriesService {
|
||||
|
||||
let added = 0;
|
||||
for (const selection of selections) {
|
||||
const providerResult = this._validProvider(selection.provider);
|
||||
const providerResult = await this._validProvider(selection.provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<{ added: number; shared: boolean }>;
|
||||
}
|
||||
for (const raw of selection.asset_ids) {
|
||||
const assetId = String(raw || '').trim();
|
||||
if (!assetId) continue;
|
||||
const result = this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
|
||||
const result = await this._addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
|
||||
if (!result.success) {
|
||||
return result as ServiceResult<{ added: number; shared: boolean }>;
|
||||
}
|
||||
@@ -252,7 +252,7 @@ export class UnifiedMemoriesService {
|
||||
// ----------------------------------------------
|
||||
// managing album links in trip
|
||||
|
||||
createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
|
||||
async createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): Promise<ServiceResult<true>> {
|
||||
const access = this.db.canAccessTrip(tripId, userId);
|
||||
if (!access) {
|
||||
return fail('Trip not found or access denied', 404);
|
||||
@@ -270,7 +270,7 @@ export class UnifiedMemoriesService {
|
||||
}
|
||||
|
||||
|
||||
const providerResult = this._validProvider(provider);
|
||||
const providerResult = await this._validProvider(provider);
|
||||
if (!providerResult.success) {
|
||||
return providerResult as ServiceResult<true>;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ export class UnifiedMemoriesController {
|
||||
constructor(private readonly memories: MemoriesService) {}
|
||||
|
||||
@Get('trips/:tripId/photos')
|
||||
listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
|
||||
const result = this.memories.listTripPhotos(tripId, user.id);
|
||||
async listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise<void> {
|
||||
const result = await this.memories.listTripPhotos(tripId, user.id);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
@@ -81,8 +81,8 @@ export class UnifiedMemoriesController {
|
||||
}
|
||||
|
||||
@Get('trips/:tripId/album-links')
|
||||
listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void {
|
||||
const result = this.memories.listTripAlbumLinks(tripId, user.id);
|
||||
async listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): Promise<void> {
|
||||
const result = await this.memories.listTripAlbumLinks(tripId, user.id);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
@@ -92,14 +92,14 @@ export class UnifiedMemoriesController {
|
||||
|
||||
@Post('trips/:tripId/album-links')
|
||||
@HttpCode(200)
|
||||
createAlbumLink(
|
||||
async createAlbumLink(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: CreateAlbumLinkDto,
|
||||
@Res() res: Response,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
|
||||
const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
|
||||
const result = await this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase);
|
||||
if ('error' in result) {
|
||||
res.status(result.error.status).json({ error: result.error.message });
|
||||
return;
|
||||
|
||||
@@ -25,25 +25,25 @@ const MIN = 60_000;
|
||||
export class OauthApiController {
|
||||
constructor(private readonly oauth: OauthService, private readonly rl: RateLimitService) {}
|
||||
|
||||
private requireMcp403(): void {
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
private async requireMcp403(): Promise<void> {
|
||||
if (!(await this.oauth.mcpEnabled())) {
|
||||
throw new HttpException({ error: 'MCP is not enabled' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('authorize/validate')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
|
||||
async validate(@Req() req: Request, @Query() params: Partial<AuthorizeParams>, @Res({ passthrough: true }) res: Response) {
|
||||
if (!this.rl.check('oauth_validate', req.ip || 'unknown', 30, MIN, Date.now())) {
|
||||
throw new HttpException({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }, 429);
|
||||
}
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
if (!(await this.oauth.mcpEnabled())) {
|
||||
// 404 (not 403) with an empty body so anonymous callers can't fingerprint the feature.
|
||||
res.status(404).end();
|
||||
return undefined;
|
||||
}
|
||||
const userId = (req.user as User | undefined)?.id ?? null;
|
||||
const result = this.oauth.validateAuthorizeRequest(
|
||||
const result = await this.oauth.validateAuthorizeRequest(
|
||||
{
|
||||
response_type: params.response_type || '',
|
||||
client_id: params.client_id || '',
|
||||
@@ -68,9 +68,9 @@ export class OauthApiController {
|
||||
@Post('authorize')
|
||||
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
|
||||
@UseGuards(CookieAuthGuard)
|
||||
authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
|
||||
async authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
if (!this.oauth.mcpEnabled()) {
|
||||
if (!(await this.oauth.mcpEnabled())) {
|
||||
throw new HttpException({ error: 'MCP is not enabled' }, 403);
|
||||
}
|
||||
const params: AuthorizeParams = {
|
||||
@@ -87,7 +87,7 @@ export class OauthApiController {
|
||||
// of body.redirect_uri too, and only validateAuthorizeRequest checks it
|
||||
// against the client's registered URIs. A string that is not a URL at all
|
||||
// would additionally throw out of `new URL()` as a 500.
|
||||
const validation = this.oauth.validateAuthorizeRequest(params, user.id);
|
||||
const validation = await this.oauth.validateAuthorizeRequest(params, user.id);
|
||||
if (!validation.valid) {
|
||||
throw new HttpException({ error: validation.error, error_description: validation.error_description }, 400);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export class OauthApiController {
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
const scopes = validation.scopes!;
|
||||
this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
const code = this.oauth.createAuthCode({
|
||||
clientId: body.client_id,
|
||||
userId: user.id,
|
||||
@@ -120,17 +120,17 @@ export class OauthApiController {
|
||||
|
||||
@Get('clients')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listClients(@CurrentUser() user: User) {
|
||||
this.requireMcp403();
|
||||
async listClients(@CurrentUser() user: User) {
|
||||
await this.requireMcp403();
|
||||
return { clients: this.oauth.listOAuthClients(user.id) };
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
@HttpCode(201)
|
||||
@UseGuards(CookieAuthGuard)
|
||||
createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
|
||||
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
const result = await this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
@@ -140,9 +140,9 @@ export class OauthApiController {
|
||||
@Post('clients/:id/rotate')
|
||||
@HttpCode(200)
|
||||
@UseGuards(CookieAuthGuard)
|
||||
rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
@@ -151,9 +151,9 @@ export class OauthApiController {
|
||||
|
||||
@Delete('clients/:id')
|
||||
@UseGuards(CookieAuthGuard)
|
||||
deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
@@ -162,16 +162,16 @@ export class OauthApiController {
|
||||
|
||||
@Get('sessions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listSessions(@CurrentUser() user: User) {
|
||||
this.requireMcp403();
|
||||
async listSessions(@CurrentUser() user: User) {
|
||||
await this.requireMcp403();
|
||||
return { sessions: this.oauth.listOAuthSessions(user.id) };
|
||||
}
|
||||
|
||||
@Delete('sessions/:id')
|
||||
@UseGuards(CookieAuthGuard)
|
||||
revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
this.requireMcp403();
|
||||
const result = this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ export class OauthPublicController {
|
||||
|
||||
@Post('token')
|
||||
@HttpCode(200) // token success uses res.json without an explicit status; Express defaults to 200 (Nest POST would default to 201).
|
||||
token(@Req() req: Request, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
async token(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
|
||||
|
||||
const body: Record<string, string> = typeof req.body === 'object' && req.body ? req.body : {};
|
||||
if (!this.rl.check('oauth_token', `${req.ip}|${body.client_id ?? ''}`, 30, MIN, Date.now())) {
|
||||
@@ -59,8 +59,8 @@ export class OauthPublicController {
|
||||
return;
|
||||
}
|
||||
const pending = this.oauth.consumeAuthCode(code);
|
||||
const invalidGrant = (reason: string, userId: number | null) => {
|
||||
this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
|
||||
const invalidGrant = async (reason: string, userId: number | null) => {
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.token.grant_failed', details: { client_id, reason }, ip });
|
||||
res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
};
|
||||
if (!pending) return invalidGrant('code_invalid_or_expired', null);
|
||||
@@ -69,13 +69,13 @@ export class OauthPublicController {
|
||||
if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId);
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
|
||||
const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
|
||||
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
|
||||
res.json(tokens);
|
||||
return;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class OauthPublicController {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
|
||||
return;
|
||||
}
|
||||
const result = this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
|
||||
const result = await this.oauth.refreshTokens(refresh_token, client_id, client_secret, ip);
|
||||
if (result.error) {
|
||||
if (result.error === 'invalid_client') logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
res.status(result.status || 400).json({ error: result.error, error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired' });
|
||||
@@ -103,12 +103,12 @@ export class OauthPublicController {
|
||||
const client = this.oauth.authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
|
||||
this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
await this.audit.writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
return;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export class OauthPublicController {
|
||||
}
|
||||
const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`;
|
||||
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
await this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
res.json(tokens);
|
||||
return;
|
||||
}
|
||||
@@ -136,8 +136,8 @@ export class OauthPublicController {
|
||||
}
|
||||
|
||||
@Get('userinfo')
|
||||
userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
async userinfo(@Headers('authorization') auth: string | undefined, @Res() res: Response): Promise<void> {
|
||||
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
|
||||
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
|
||||
res.status(401).json({ error: 'invalid_token' });
|
||||
@@ -153,8 +153,8 @@ export class OauthPublicController {
|
||||
}
|
||||
|
||||
@Post('revoke')
|
||||
revoke(@Req() req: Request, @Res() res: Response): void {
|
||||
if (!this.oauth.mcpEnabled()) { res.status(404).end(); return; }
|
||||
async revoke(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
if (!(await this.oauth.mcpEnabled())) { res.status(404).end(); return; }
|
||||
if (!this.rl.check('oauth_revoke', req.ip || 'unknown', 10, MIN, Date.now())) {
|
||||
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
|
||||
return;
|
||||
@@ -168,11 +168,11 @@ export class OauthPublicController {
|
||||
}
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
|
||||
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
this.oauth.revokeToken(token, client_id, undefined, ip);
|
||||
await this.oauth.revokeToken(token, client_id, undefined, ip);
|
||||
res.status(200).json({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class TrekClientsStore implements OAuthRegisteredClientsStore {
|
||||
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
|
||||
|
||||
const result = this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
||||
const result = await this.oauth.createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
||||
if (result.error) throw new InvalidClientMetadataError(result.error);
|
||||
|
||||
const c = result.client!;
|
||||
@@ -179,7 +179,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: pending.userId,
|
||||
action: 'oauth.token.issue',
|
||||
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
|
||||
@@ -194,7 +194,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
_scopes?: string[],
|
||||
_resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const result = this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null);
|
||||
const result = await this.oauth.refreshTokens(refreshToken, client.client_id, client.client_secret, null);
|
||||
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
|
||||
return result.tokens!;
|
||||
}
|
||||
@@ -214,6 +214,6 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
client: OAuthClientInformationFull,
|
||||
request: OAuthTokenRevocationRequest,
|
||||
): Promise<void> {
|
||||
this.oauth.revokeToken(request.token, client.client_id, undefined, null);
|
||||
await this.oauth.revokeToken(request.token, client.client_id, undefined, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export class OauthService {
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
|
||||
async mcpEnabled(): Promise<boolean> { return this.addons.isAddonEnabled(ADDON_IDS.MCP); }
|
||||
mcpSafeUrl(): string { return getMcpSafeUrl(); }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -106,14 +106,14 @@ export class OauthService {
|
||||
}));
|
||||
}
|
||||
|
||||
createOAuthClient(
|
||||
async createOAuthClient(
|
||||
userId: number | null,
|
||||
name: string,
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
): Promise<{ error?: string; status?: number; client?: Record<string, unknown> }> {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
const isMachineClient = Boolean(options?.allowsClientCredentials);
|
||||
@@ -163,7 +163,7 @@ export class OauthService {
|
||||
id,
|
||||
)!;
|
||||
|
||||
this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
@@ -183,11 +183,11 @@ export class OauthService {
|
||||
};
|
||||
}
|
||||
|
||||
rotateOAuthClientSecret(
|
||||
async rotateOAuthClientSecret(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; client_secret?: string } {
|
||||
): Promise<{ error?: string; status?: number; client_secret?: string }> {
|
||||
const row = this.db.get<OAuthClientRow>('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
|
||||
@@ -203,20 +203,20 @@ export class OauthService {
|
||||
// Terminate active MCP sessions for this (user, client) pair
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
|
||||
|
||||
return { client_secret: rawSecret };
|
||||
}
|
||||
|
||||
deleteOAuthClient(
|
||||
async deleteOAuthClient(
|
||||
userId: number,
|
||||
clientRowId: string,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
const row = this.db.get<OAuthClientRow>('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
|
||||
if (!row) return { error: 'Client not found', status: 404 };
|
||||
this.db.run('DELETE FROM oauth_clients WHERE id = ?', clientRowId);
|
||||
this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export class OauthService {
|
||||
return row ? JSON.parse(row.scopes) : null;
|
||||
}
|
||||
|
||||
saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
|
||||
async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise<void> {
|
||||
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
|
||||
const existing = this.getConsent(clientId, userId) ?? [];
|
||||
const merged = Array.from(new Set([...existing, ...scopes]));
|
||||
@@ -261,7 +261,7 @@ export class OauthService {
|
||||
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
clientId, userId, JSON.stringify(merged),
|
||||
);
|
||||
this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
|
||||
}
|
||||
|
||||
isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
|
||||
@@ -446,12 +446,12 @@ export class OauthService {
|
||||
return !!successor;
|
||||
}
|
||||
|
||||
refreshTokens(
|
||||
async refreshTokens(
|
||||
rawRefreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string | undefined,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> } {
|
||||
): Promise<{ error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> }> {
|
||||
const client = this.db.get<OAuthClientRow>('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId);
|
||||
if (!client) return { error: 'invalid_client', status: 401 };
|
||||
if (!client.is_public) {
|
||||
@@ -478,7 +478,7 @@ export class OauthService {
|
||||
// parent so each client walks away with its own token.
|
||||
if (this.isConcurrentRotation(row)) {
|
||||
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.refresh',
|
||||
details: { client_id: clientId, concurrent: true },
|
||||
@@ -493,7 +493,7 @@ export class OauthService {
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.replay_detected',
|
||||
details: { client_id: clientId },
|
||||
@@ -515,7 +515,7 @@ export class OauthService {
|
||||
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
|
||||
|
||||
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
return { tokens };
|
||||
}
|
||||
@@ -524,7 +524,7 @@ export class OauthService {
|
||||
// Token revocation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
|
||||
async revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): Promise<void> {
|
||||
const hash = hashToken(rawToken);
|
||||
|
||||
// Get the user_id for the token so we can revoke its MCP sessions
|
||||
@@ -542,7 +542,7 @@ export class OauthService {
|
||||
const affectedUserId = row?.user_id ?? userId;
|
||||
if (affectedUserId) {
|
||||
revokeUserSessionsForClient(affectedUserId, clientId);
|
||||
this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
|
||||
await this.audit.writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,11 +564,11 @@ export class OauthService {
|
||||
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
|
||||
}
|
||||
|
||||
revokeSession(
|
||||
async revokeSession(
|
||||
userId: number,
|
||||
sessionId: number,
|
||||
ip?: string | null,
|
||||
): { error?: string; status?: number; success?: boolean } {
|
||||
): Promise<{ error?: string; status?: number; success?: boolean }> {
|
||||
const row = this.db.get<{ id: number; client_id: string }>('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?', sessionId, userId);
|
||||
if (!row) return { error: 'Session not found', status: 404 };
|
||||
|
||||
@@ -576,7 +576,7 @@ export class OauthService {
|
||||
|
||||
revokeUserSessionsForClient(userId, row.client_id);
|
||||
|
||||
this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
|
||||
await this.audit.writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -585,11 +585,11 @@ export class OauthService {
|
||||
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
validateAuthorizeRequest(
|
||||
async validateAuthorizeRequest(
|
||||
params: AuthorizeParams,
|
||||
userId: number | null,
|
||||
): ValidateAuthorizeResult {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
): Promise<ValidateAuthorizeResult> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
|
||||
}
|
||||
|
||||
|
||||
@@ -165,14 +165,14 @@ export class OidcController {
|
||||
userInfo.picture = idVerify.claims.picture;
|
||||
}
|
||||
|
||||
const result = this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
const result = await this.oidc.findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||
if ('error' in result) return f('/login?oidc_error=' + result.error);
|
||||
if (result.roleChange) {
|
||||
// The claim mapping changing someone's privileges is a security event, and
|
||||
// the row is written here because this is where the client IP is. The claim
|
||||
// NAME goes in the details, never its value: that column is readable by
|
||||
// every admin and a claim can carry group memberships and worse.
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: result.user.id,
|
||||
action: 'oidc.role_change',
|
||||
resource: String(result.user.id),
|
||||
@@ -251,14 +251,14 @@ export class AdminOidcController {
|
||||
|
||||
@ManagedForbidden('an instance-supplied issuer could assert any address as verified')
|
||||
@Put()
|
||||
update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
|
||||
async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) {
|
||||
const result = this.oidc.updateOidcSettings(body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
}
|
||||
// Only whether an issuer was set, never the value: the details column is readable
|
||||
// by every admin and the issuer identifies the customer's IdP tenant.
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TripMembershipService } from '../trip-membership/trip-membership.servic
|
||||
import { setAuthCookie, RememberOption } from '../common/cookie';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,6 +229,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
private readonly db: DatabaseService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly membership: TripMembershipService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {
|
||||
this.stateSweeper = setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -607,11 +609,11 @@ export class OidcService implements OnModuleDestroy {
|
||||
// Find or create user by OIDC sub / email
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
findOrCreateUser(
|
||||
async findOrCreateUser(
|
||||
userInfo: OidcUserInfo,
|
||||
config: OidcConfig,
|
||||
inviteToken?: string,
|
||||
): { user: User; roleChange?: OidcRoleChange } | { error: string } {
|
||||
): Promise<{ user: User; roleChange?: OidcRoleChange } | { error: string }> {
|
||||
// Defense-in-depth for direct callers — the controller redirects on a
|
||||
// missing email before it ever calls this; the same code flows through its
|
||||
// `oidc_error=' + result.error` pass-through if reached here.
|
||||
@@ -731,7 +733,7 @@ export class OidcService implements OnModuleDestroy {
|
||||
// both pass the earlier SELECT-based check and each create a user.
|
||||
const inviteRaceError = new Error('invite_exhausted');
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const result = (await this.uow.transactional(async () => {
|
||||
if (validInvite) {
|
||||
const updated = this.db.prepare(
|
||||
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
|
||||
@@ -744,10 +746,10 @@ export class OidcService implements OnModuleDestroy {
|
||||
// Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the
|
||||
// same atomic step as the invite consume. Idempotent + owner-safe.
|
||||
if (validInvite?.trip_id) {
|
||||
this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null);
|
||||
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null);
|
||||
}
|
||||
return ins;
|
||||
}) as { lastInsertRowid: number | bigint };
|
||||
})) as { lastInsertRowid: number | bigint };
|
||||
// Re-select so the returned User carries the real row (password_version,
|
||||
// is_guest, created_at, …) instead of a hand-built partial — same shape
|
||||
// the existing-user branch returns.
|
||||
|
||||
@@ -50,9 +50,9 @@ export class AdminPackingTemplatesController {
|
||||
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
|
||||
async create(@CurrentUser() user: User, @Body() body: AdminTemplateNameDto, @Req() req: Request) {
|
||||
const result = ok(this.packing.createPackingTemplate(body.name, user.id));
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.packing_template_create',
|
||||
resource: String((result.template as { id?: number } | undefined)?.id ?? ''),
|
||||
@@ -68,9 +68,9 @@ export class AdminPackingTemplatesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
async remove(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.packing.deletePackingTemplate(id));
|
||||
this.audit.writeAudit({
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.packing_template_delete',
|
||||
resource: String(id),
|
||||
|
||||
@@ -81,7 +81,7 @@ export class PackingMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const item = this.packing.createItem(tripId, {
|
||||
name,
|
||||
category: category || 'General',
|
||||
@@ -118,7 +118,7 @@ export class PackingMcp {
|
||||
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
|
||||
if (!item) return errorResult('Packing item not found.');
|
||||
// Scoped to the people who may see it, exactly as the REST route does
|
||||
@@ -141,7 +141,7 @@ export class PackingMcp {
|
||||
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId);
|
||||
if (!deleted) return errorResult('Packing item not found.');
|
||||
// deleteItem hands back the row it removed, so the delete can be scoped to
|
||||
@@ -176,7 +176,7 @@ export class PackingMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
|
||||
// The service reads presence from bodyKeys, so a field has to be named there
|
||||
// for an explicit null to clear it rather than read as "leave it alone".
|
||||
@@ -232,7 +232,7 @@ export class PackingMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
|
||||
if (!item) return errorResult('Packing item not found.');
|
||||
if ((item as { forbidden?: boolean }).forbidden) return errorResult('Only the owner can change sharing.');
|
||||
@@ -260,7 +260,7 @@ export class PackingMcp {
|
||||
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
this.packing.reorderItems(tripId, orderedIds);
|
||||
this.guards.safeBroadcast(tripId, 'packing:reordered', { orderedIds });
|
||||
return ok({ success: true });
|
||||
@@ -298,7 +298,7 @@ export class PackingMcp {
|
||||
async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// createBag returns a bare row; hydrate with the empty members array that
|
||||
// listBags and the schema always carry, so the client/AI consumer matches.
|
||||
const bag = { ...(this.packing.createBag(tripId, { name, color, weight_limit_grams }) as object), members: [] };
|
||||
@@ -327,7 +327,7 @@ export class PackingMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
@@ -358,7 +358,7 @@ export class PackingMcp {
|
||||
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
this.packing.deleteBag(tripId, bagId);
|
||||
// { bagId } matches the REST route and the plugin host (the legacy
|
||||
// registrar's { id } was the odd one out).
|
||||
@@ -384,7 +384,7 @@ export class PackingMcp {
|
||||
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const members = this.packing.setBagMembers(tripId, bagId, userIds);
|
||||
if (!members) return errorResult('Bag not found.');
|
||||
this.guards.safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, members });
|
||||
@@ -422,7 +422,7 @@ export class PackingMcp {
|
||||
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
|
||||
this.guards.safeBroadcast(tripId, 'packing:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
@@ -442,7 +442,7 @@ export class PackingMcp {
|
||||
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const items = this.packing.applyTemplate(tripId, templateId);
|
||||
if (items === null) return errorResult('Template not found.');
|
||||
this.guards.safeBroadcast(tripId, 'packing:template-applied', { items });
|
||||
@@ -479,7 +479,7 @@ export class PackingMcp {
|
||||
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Templates are global; the REST route restricts saving to admins. Match it.
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
|
||||
@@ -530,7 +530,7 @@ export class PackingMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const created = this.packing.bulkImport(tripId, items, ctx.userId);
|
||||
for (const item of created) {
|
||||
this.guards.safeBroadcast(tripId, 'packing:created', { item }, this.packing.viewersOf(item));
|
||||
|
||||
@@ -41,12 +41,12 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.create', { permission: 'db:write:packing' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing item');
|
||||
const parsed = packingCreateItemRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
const item = this.packing.createItem(String(tripId), parsed.data as never, actor) as PrivacyItem;
|
||||
// A referenced bag must exist on this trip (#2154), as on the REST route.
|
||||
if (isInvalidBagRef(item)) throw new BadParams(`no packing bag ${parsed.data.bag_id} on trip ${tripId}`);
|
||||
@@ -56,13 +56,13 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.update', { permission: 'db:write:packing' })
|
||||
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async update(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const itemId = num(params.itemId, 'itemId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing item');
|
||||
const parsed = packingUpdateItemRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid packing item: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
// Read the privacy BEFORE the write, so a public/private toggle routes correctly.
|
||||
const before = this.packing.getItemPrivacy(tripId, itemId);
|
||||
const input = parsed.data as Record<string, unknown>;
|
||||
@@ -79,11 +79,11 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.delete', { permission: 'db:write:packing' })
|
||||
delete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const itemId = num(params.itemId, 'itemId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing item');
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
const deleted = this.packing.deleteItem(String(tripId), String(itemId), actor) as PrivacyItem | null;
|
||||
if (!deleted) throw new ForbiddenResource(`no packing item ${itemId} on trip ${tripId}`);
|
||||
this.packing.emitToViewers(String(tripId), 'packing:deleted', { itemId }, deleted, undefined);
|
||||
@@ -99,12 +99,12 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
|
||||
createBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async createBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing bag');
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('bag name is required');
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
const bag = this.packing.createBag(String(tripId), {
|
||||
name: input.name,
|
||||
color: typeof input.color === 'string' ? input.color : undefined,
|
||||
@@ -115,11 +115,11 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.updateBag', { permission: 'db:write:packing' })
|
||||
updateBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async updateBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const bagId = num(params.bagId, 'bagId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing bag');
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
const input = asPayload(params.input);
|
||||
const bag = this.packing.updateBag(String(tripId), String(bagId), input as never, Object.keys(input));
|
||||
if (!bag) throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
|
||||
@@ -128,11 +128,11 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.deleteBag', { permission: 'db:write:packing' })
|
||||
deleteBag(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async deleteBag(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const bagId = num(params.bagId, 'bagId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing bag');
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
if (!this.packing.deleteBag(String(tripId), String(bagId))) {
|
||||
throw new ForbiddenResource(`no packing bag ${bagId} on trip ${tripId}`);
|
||||
}
|
||||
@@ -143,11 +143,11 @@ export class PackingRpc {
|
||||
}
|
||||
|
||||
@PluginMethod('packing.setBagMembers', { permission: 'db:write:packing' })
|
||||
setBagMembers(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async setBagMembers(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const bagId = num(params.bagId, 'bagId');
|
||||
const actor = this.guards.requireActor(ctx, 'packing bag');
|
||||
this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PACKING_EDIT_ACTION);
|
||||
// userIds sits on the params object itself, not under `input`.
|
||||
const raw = asPayload(params).userIds;
|
||||
const userIds = Array.isArray(raw) ? raw.filter((x): x is number => typeof x === 'number') : [];
|
||||
|
||||
@@ -67,7 +67,7 @@ export class PackingService {
|
||||
}
|
||||
|
||||
/** Mirrors the inline checkPermission('packing_edit', ...) the legacy route runs. */
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('packing_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { logError } from '../audit/audit-log.logger';
|
||||
import {
|
||||
getPermissionsCache,
|
||||
@@ -73,9 +74,12 @@ const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
private loadPermissions(): Map<string, PermissionLevel> {
|
||||
private async loadPermissions(): Promise<Map<string, PermissionLevel>> {
|
||||
const cached = getPermissionsCache();
|
||||
if (cached) return cached;
|
||||
const cache = new Map<string, PermissionLevel>();
|
||||
@@ -113,16 +117,16 @@ export class PermissionsService {
|
||||
invalidateSharedCache();
|
||||
}
|
||||
|
||||
getPermissionLevel(actionKey: string): PermissionLevel {
|
||||
const perms = this.loadPermissions();
|
||||
async getPermissionLevel(actionKey: string): Promise<PermissionLevel> {
|
||||
const perms = await this.loadPermissions();
|
||||
const stored = perms.get(actionKey);
|
||||
if (stored) return stored;
|
||||
const action = ACTIONS_MAP.get(actionKey);
|
||||
return action?.defaultLevel ?? 'trip_owner';
|
||||
}
|
||||
|
||||
getAllPermissions(): Record<string, PermissionLevel> {
|
||||
const perms = this.loadPermissions();
|
||||
async getAllPermissions(): Promise<Record<string, PermissionLevel>> {
|
||||
const perms = await this.loadPermissions();
|
||||
const result: Record<string, PermissionLevel> = {};
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
result[action.key] = perms.get(action.key) ?? action.defaultLevel;
|
||||
@@ -130,7 +134,7 @@ export class PermissionsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
savePermissions(settings: Record<string, string>): { skipped: string[] } {
|
||||
async savePermissions(settings: Record<string, string>): Promise<{ skipped: string[] }> {
|
||||
const skipped: string[] = [];
|
||||
const valid: Array<[string, string]> = [];
|
||||
for (const [actionKey, level] of Object.entries(settings)) {
|
||||
@@ -144,7 +148,7 @@ export class PermissionsService {
|
||||
// Nothing valid to write → no prepare, no transaction, no cache flush.
|
||||
if (valid.length === 0) return { skipped };
|
||||
const upsert = this.dbs.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
this.dbs.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const [actionKey, level] of valid) {
|
||||
upsert.run(`perm_${actionKey}`, level);
|
||||
}
|
||||
@@ -162,17 +166,17 @@ export class PermissionsService {
|
||||
* @param userId - The requesting user's ID
|
||||
* @param isMember - Whether the user is a trip member (not owner)
|
||||
*/
|
||||
checkPermission(
|
||||
async checkPermission(
|
||||
actionKey: string,
|
||||
userRole: string,
|
||||
tripUserId: number | null,
|
||||
userId: number,
|
||||
isMember: boolean
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
// Admins always pass
|
||||
if (userRole === 'admin') return true;
|
||||
|
||||
const required = this.getPermissionLevel(actionKey);
|
||||
const required = await this.getPermissionLevel(actionKey);
|
||||
|
||||
switch (required) {
|
||||
case 'admin':
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripAccessGuard implements CanActivate {
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<TripRequest>();
|
||||
const user = request.user;
|
||||
// JwtAuthGuard runs first and 401s an anonymous request, so a missing user here
|
||||
@@ -67,7 +67,7 @@ export class TripAccessGuard implements CanActivate {
|
||||
]);
|
||||
if (action) {
|
||||
const shared = trip.user_id !== user.id;
|
||||
if (!this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared)) {
|
||||
if (!(await this.permissions.checkPermission(action, user.role, trip.user_id, user.id, shared))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class TripOwnerGuard implements CanActivate {
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<TripRequest>();
|
||||
const user = request.user;
|
||||
// JwtAuthGuard runs first and 401s an anonymous request, so a missing user
|
||||
|
||||
@@ -126,26 +126,26 @@ export class PlacesController {
|
||||
return trip;
|
||||
}
|
||||
|
||||
private requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): void {
|
||||
if (!this.places.canEdit(trip, user)) {
|
||||
private async requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): Promise<void> {
|
||||
if (!(await this.places.canEdit(trip, user))) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(TripAccessGuard)
|
||||
list(
|
||||
async list(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('category') category?: string,
|
||||
@Query('tag') tag?: string,
|
||||
) {
|
||||
return { places: this.places.list(tripId, { search, category, tag }) };
|
||||
return { places: await this.places.list(tripId, { search, category, tag }) };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: PlaceCreateDto,
|
||||
@@ -155,7 +155,7 @@ export class PlacesController {
|
||||
validateLengths(body);
|
||||
validateRouteColor(body);
|
||||
validateUrlFields(body);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
const place = this.places.create(tripId, body as never);
|
||||
this.places.broadcast(tripId, 'place:created', { place }, socketId);
|
||||
this.places.onCreated(tripId, place.id);
|
||||
@@ -164,7 +164,7 @@ export class PlacesController {
|
||||
|
||||
@Post('import/gpx')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
importGpx(
|
||||
async importGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@@ -172,7 +172,7 @@ export class PlacesController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export class PlacesController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
if (!file) {
|
||||
throw new HttpException({ error: 'No file uploaded' }, 400);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export class PlacesController {
|
||||
/** Shared google/naver list import — identical flow, different provider + error string. */
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, body: PlaceImportListDto, socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
const { url, enrich } = body;
|
||||
// Opt-in: re-resolve each imported place via the Places API to fill in
|
||||
// photo / address / website / phone and persist a google_place_id (#886).
|
||||
@@ -307,7 +307,7 @@ export class PlacesController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (ids.length === 0) {
|
||||
return { deleted: [], count: 0 };
|
||||
@@ -351,7 +351,7 @@ export class PlacesController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (ids.length === 0) {
|
||||
return { updated: [], count: 0 };
|
||||
@@ -390,7 +390,7 @@ export class PlacesController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
// Inline rather than DemoWriteGuard: requireEdit above answers 404/403 for a
|
||||
// trip the caller cannot reach, and a guard would run before it.
|
||||
if (isDemoWriteBlocked(this.env, user.email)) {
|
||||
@@ -473,7 +473,7 @@ export class PlacesController {
|
||||
validateLengths(body);
|
||||
validateRouteColor(body);
|
||||
validateUrlFields(body);
|
||||
this.requireEdit(trip, user);
|
||||
await this.requireEdit(trip, user);
|
||||
const result = await this.places.update(tripId, id, body as never, ifMatch);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Place not found' }, 404);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { JourneyDomainService } from '../journey/journey-domain.service';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { PlacesService } from './places.service';
|
||||
import { isDirectionsUrl } from './maps-dir.helpers';
|
||||
@@ -57,6 +58,7 @@ export class PlacesMcp {
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
@@ -96,7 +98,7 @@ export class PlacesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
@@ -141,12 +143,12 @@ export class PlacesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const result = await this.uow.transactional(async () => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type });
|
||||
const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
const assignment = await this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
@@ -204,7 +206,7 @@ export class PlacesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
@@ -248,7 +250,7 @@ export class PlacesMcp {
|
||||
async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Scope the id to the trip before the hook: onPlaceDeleted keys on the place
|
||||
// id alone, so a foreign id would detach that trip's journey entries even
|
||||
// though the delete below refuses it.
|
||||
@@ -290,7 +292,7 @@ export class PlacesMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
const places = this.places.list(String(tripId), { search, category, tag, assignment });
|
||||
const places = await this.places.list(String(tripId), { search, category, tag, assignment });
|
||||
return ok({ places });
|
||||
}
|
||||
|
||||
@@ -342,7 +344,7 @@ export class PlacesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
// Same opts the REST route builds: the enrichment pass is keyed on the calling
|
||||
// user because it spends that user's Places credential.
|
||||
@@ -373,7 +375,7 @@ export class PlacesMcp {
|
||||
async importGpx(input: RoadtripGpxImport, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied();
|
||||
if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.');
|
||||
try {
|
||||
const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name });
|
||||
@@ -424,7 +426,7 @@ export class PlacesMcp {
|
||||
async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
// Trip-scoped, and ahead of the DELETE: journey_entries.source_place_id is
|
||||
// ON DELETE SET NULL, so a hook that ran afterwards found nothing left to
|
||||
@@ -481,7 +483,7 @@ export class PlacesMcp {
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent };
|
||||
if (Object.values(fields).every(v => v === undefined)) {
|
||||
@@ -512,7 +514,7 @@ export class PlacesMcp {
|
||||
};
|
||||
}
|
||||
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
|
||||
const places = this.places.list(String(id), { assignment: assignment ?? undefined });
|
||||
const places = await this.places.list(String(id), { assignment: assignment ?? undefined });
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -52,14 +52,14 @@ export class PlacesRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('places.create', { permission: 'db:write:places' })
|
||||
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
async create(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const actor = this.guards.requireActor(ctx, 'place');
|
||||
const parsed = placeCreateRequestSchema.safeParse(params.input);
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
|
||||
capUrls(parsed.data as Record<string, unknown>);
|
||||
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
const place = this.places.create(String(tripId), parsed.data as unknown as PlaceCreateInput);
|
||||
this.realtime.broadcast(tripId, 'place:created', { place });
|
||||
this.mirrorJourneys(() => this.journey.onPlaceCreated(tripId, place.id));
|
||||
@@ -75,7 +75,7 @@ export class PlacesRpc {
|
||||
if (!parsed.success) throw new BadParams(`invalid place: ${schemaMessage(parsed.error)}`);
|
||||
this.guards.capStrings(parsed.data as Record<string, unknown>, PLACE_STR_LIMITS);
|
||||
capUrls(parsed.data as Record<string, unknown>);
|
||||
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
// update is async (it may delete a superseded storage object): await it so the
|
||||
// null-refusal check compares against the resolved place, not an always-truthy
|
||||
// Promise, and the broadcast below carries the actual place.
|
||||
@@ -91,7 +91,7 @@ export class PlacesRpc {
|
||||
const tripId = num(params.tripId, 'tripId');
|
||||
const placeId = num(params.placeId, 'placeId');
|
||||
const actor = this.guards.requireActor(ctx, 'place');
|
||||
this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
await this.guards.requireTripEdit(tripId, actor, PLACE_EDIT_ACTION);
|
||||
// Scope the id to the trip before anything else: onPlaceDeleted keys on the place
|
||||
// alone, so an id belonging to a foreign trip would detach THAT trip's journey
|
||||
// entries even though the delete below refuses it.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@t
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { PlaceWithTags } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service';
|
||||
@@ -133,13 +134,14 @@ export class PlacesService {
|
||||
private readonly journey: JourneyDomainService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly accommodations: AccommodationsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return this.dbs.canAccessTrip(Number(tripId), userId);
|
||||
}
|
||||
|
||||
canEdit(trip: Trip, user: User): boolean {
|
||||
async canEdit(trip: Trip, user: User): Promise<boolean> {
|
||||
return this.permissions.checkPermission('place_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
@@ -174,7 +176,7 @@ export class PlacesService {
|
||||
// List places
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
list(
|
||||
async list(
|
||||
tripId: string,
|
||||
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
|
||||
) {
|
||||
@@ -217,8 +219,8 @@ export class PlacesService {
|
||||
const places = this.dbs.prepare(query).all(...params) as PlaceWithCategory[];
|
||||
|
||||
const placeIds = places.map(p => p.id);
|
||||
const tagsByPlaceId = this.queryHelpers.loadTagsByPlaceIds(placeIds);
|
||||
const ratingsByPlaceId = this.queryHelpers.loadRatingsByPlaceIds(placeIds);
|
||||
const tagsByPlaceId = await this.queryHelpers.loadTagsByPlaceIds(placeIds);
|
||||
const ratingsByPlaceId = await this.queryHelpers.loadRatingsByPlaceIds(placeIds);
|
||||
|
||||
return places.map(p => ({
|
||||
...p,
|
||||
@@ -449,12 +451,12 @@ export class PlacesService {
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): void {
|
||||
private async cancelStaysAt(tripId: string | number, placeId: string | number, into: CancelledStays): Promise<void> {
|
||||
const stays = this.dbs.all<{ id: number }>(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ? AND place_id = ?', tripId, placeId,
|
||||
);
|
||||
for (const stay of stays) {
|
||||
const gone = this.accommodations.deleteAccommodation(stay.id);
|
||||
const gone = await this.accommodations.deleteAccommodation(stay.id);
|
||||
// What went down with the night is what the caller has to announce. The
|
||||
// partner booking and its expense are rows the Bookings list and the Costs
|
||||
// total are still holding; place:deleted says nothing about either, and a
|
||||
@@ -473,8 +475,8 @@ export class PlacesService {
|
||||
// The linked expense goes with the place, the same way a booking takes its
|
||||
// expense with it (#1298). One transaction, so a place can never survive
|
||||
// half-detached from its money.
|
||||
this.dbs.transaction(() => {
|
||||
this.cancelStaysAt(tripId, placeId, cancelled);
|
||||
await this.uow.transactional(async () => {
|
||||
await this.cancelStaysAt(tripId, placeId, cancelled);
|
||||
this.dbs.run('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?', tripId, placeId);
|
||||
this.dbs.run('DELETE FROM places WHERE id = ?', placeId);
|
||||
});
|
||||
@@ -491,11 +493,11 @@ export class PlacesService {
|
||||
const deleteExpenseStmt = this.dbs.prepare('DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?');
|
||||
const deleted: number[] = [];
|
||||
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
|
||||
this.dbs.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const id of ids) {
|
||||
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!row) continue;
|
||||
this.cancelStaysAt(tripId, id, cancelled);
|
||||
await this.cancelStaysAt(tripId, id, cancelled);
|
||||
deleteExpenseStmt.run(tripId, id);
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
|
||||
@@ -44,8 +44,8 @@ export class DiscoveryController {
|
||||
// them with invalid_target — showing the user the TREK home page instead of the
|
||||
// consent form.
|
||||
@Get('oauth-protected-resource')
|
||||
protectedResource(@Res() res: Response): void {
|
||||
if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
async protectedResource(@Res() res: Response): Promise<void> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,17 @@ export function createMcpMetadataMiddleware(
|
||||
meta: DiscoveryMetadataService,
|
||||
addons: AddonsService,
|
||||
): RequestHandler {
|
||||
// Express cannot await a middleware, so the now-async addon check runs in a
|
||||
// helper and its rejection is handed to next() — the same error path a
|
||||
// synchronous throw took before (recipe R1.5).
|
||||
return (req, res, next) => {
|
||||
if (req.path.startsWith('/.well-known/') && !addons.isAddonEnabled(ADDON_IDS.MCP)) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
meta.getMetaRouter()(req, res, next);
|
||||
void (async () => {
|
||||
if (req.path.startsWith('/.well-known/') && !(await addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
meta.getMetaRouter()(req, res, next);
|
||||
})().catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export class JournalEntryRowsController {
|
||||
@Param('entryId') entryIdRaw: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ providers: ProviderResult[] }> {
|
||||
if (!pluginsEnabled() || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] };
|
||||
if (!pluginsEnabled() || !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return { providers: [] };
|
||||
const entryId = Number(entryIdRaw);
|
||||
const userId = req.user?.id;
|
||||
if (!Number.isFinite(entryId) || userId == null) return { providers: [] };
|
||||
|
||||
@@ -52,8 +52,17 @@ export function parseDependencies(raw: string | null | undefined): PluginDepende
|
||||
}
|
||||
|
||||
/** Required addon ids that are currently NOT enabled — these block activation. */
|
||||
export function disabledRequiredAddons(deps: PluginDependencies, isAddonEnabled: (id: string) => boolean): string[] {
|
||||
return deps.requiredAddons.filter((a) => !isAddonEnabled(a));
|
||||
export async function disabledRequiredAddons(
|
||||
deps: PluginDependencies,
|
||||
isAddonEnabled: (id: string) => Promise<boolean>,
|
||||
): Promise<string[]> {
|
||||
// A `filter` cannot await, so the same predicate runs as an explicit loop —
|
||||
// same order, same result.
|
||||
const disabled: string[] = [];
|
||||
for (const a of deps.requiredAddons) {
|
||||
if (!(await isAddonEnabled(a))) disabled.push(a);
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
export interface VersionMismatch {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user