fix(realtime): trip, member, chat, file, settings, tag and notification writes in parity

This commit is contained in:
Maurice
2026-10-22 12:14:27 +02:00
parent 94b8c3194c
commit ce7b5bf453
51 changed files with 2055 additions and 382 deletions
@@ -11,9 +11,6 @@ import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import { COLLAB_MESSAGE_ORIGIN_ASSISTANT } from '@trek/shared';
/** How much of the message the chat notification previews, as for one typed in the chat. */
const PREVIEW_CHARS = 80;
/** The row a message id on the wire (`msg-<row>`) names, or null for anything else. */
export function messageRowOf(messageId: string): number | null {
const match = /^msg-([1-9]\d{0,15})$/.exec(messageId);
@@ -26,7 +23,9 @@ export function messageRowOf(messageId: string): number | null {
* as shared from the assistant (`origin`), which only this path sets: the
* mark says the user shared an answer the assistant gave them in this trip,
* so the server checks that answer, not the words. It reaches the chat like a
* typed message, broadcast and notified the same way.
* typed message, broadcast and notified the same way, with one difference: the
* asking tab is not left out. It never put the message into its own chat, so it
* hears it like every other tab (the chat drops a message it already holds).
*/
@Injectable()
export class AssistantShareService {
@@ -48,7 +47,8 @@ export class AssistantShareService {
threadId: number,
messageId: string,
text: string,
socketId: string | undefined,
// The asking tab, which the route hands on; deliberately not skipped (see the class comment).
_askingSocketId: string | undefined,
) {
const collabOn = await this.addons.isAddonEnabled(ADDON_IDS.COLLAB);
if (!collabOn || !(await this.addons.getCollabFeatures()).chat) throw aiRefusal('forbidden', 'Collab chat is off');
@@ -59,13 +59,13 @@ export class AssistantShareService {
const row = rowId === null ? null : await this.messages.findInThread(rowId, threadId);
if (row?.role !== 'assistant') throw aiRefusal('not_found', 'Message not found');
const created = await this.collab.createMessage(tripId, user.id, text, null, [], COLLAB_MESSAGE_ORIGIN_ASSISTANT);
const created = await this.collab.postMessage(
tripId,
{ text, origin: COLLAB_MESSAGE_ORIGIN_ASSISTANT },
this.collab.restWriter(String(tripId), user, undefined),
);
const message = created.message;
if (!message) throw aiRefusal('generic', 'The message could not be posted');
this.collab.broadcast(String(tripId), 'collab:message:created', { message }, socketId);
const posted = message.text;
const preview = posted.length > PREVIEW_CHARS ? `${posted.substring(0, PREVIEW_CHARS)}...` : posted;
await this.collab.notifyCollab(String(tripId), user, preview);
return { message };
}
}
+17 -22
View File
@@ -167,15 +167,11 @@ export class CollabController {
@Body() body: CollabNoteCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const note = await this.collab.createNote(tripId, user.id, {
title: body.title,
content: body.content,
category: body.category,
color: body.color,
website: body.website,
});
this.collab.broadcast(tripId, 'collab:note:created', { note }, socketId);
await this.collab.notifyCollab(tripId, user);
const note = await this.collab.postNote(
tripId,
{ title: body.title, content: body.content, category: body.category, color: body.color, website: body.website },
this.collab.restWriter(tripId, user, socketId),
);
return { note };
}
@@ -525,25 +521,24 @@ export class CollabController {
}
const replyTo =
body.reply_to === undefined || body.reply_to === '' || body.reply_to === null ? null : Number(body.reply_to);
const result = await this.collab.createMessage(
const result = await this.collab.postMessage(
tripId,
user.id,
text,
Number.isFinite(replyTo as number) ? replyTo : null,
uploaded.map((file) => ({
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
})),
{
text,
replyTo: Number.isFinite(replyTo as number) ? replyTo : null,
files: uploaded.map((file) => ({
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
})),
},
this.collab.restWriter(tripId, user, socketId),
);
if (result.error === 'reply_not_found') {
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw new HttpException({ error: 'Reply target message not found' }, 400);
}
this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId);
const preview = text || 'sent an image';
await this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
return { message: result.message };
}
+8 -4
View File
@@ -129,8 +129,12 @@ export class CollabMcp {
) {
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const note = await this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned });
this.guards.safeBroadcast(tripId, 'collab:note:created', { note });
// Through the route's own use case, so the fellow travellers get the same notification.
const note = await this.collab.postNote(
tripId,
{ title, content, category, color, website, pinned },
await this.guards.tripWriter(tripId, ctx.userId),
);
return ok({ note });
}
@@ -364,9 +368,9 @@ export class CollabMcp {
) {
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null);
const writer = await this.guards.tripWriter(tripId, ctx.userId);
const result = await this.collab.postMessage(tripId, { text, replyTo: replyTo ?? null }, writer);
if (result.error) return errorResult(result.error);
this.guards.safeBroadcast(tripId, 'collab:message:created', { message: result.message });
return ok({ message: result.message });
}
+65 -12
View File
@@ -21,6 +21,7 @@ import { checkSsrf, createOutboundDispatcher } from '../../utils/ssrfGuard';
import { avatarUrl } from '../common/avatarUrl';
import { RateLimitService } from '../common/rate-limit.service';
import { toRowId } from '../common/row-id';
import { restTripWriter, type TripWriter } from '../common/trip-writer';
import { UnitOfWork } from '../database/unit-of-work';
import { NotificationsService } from '../notifications/notifications.service';
import { PermissionsService } from '../permissions/permissions.service';
@@ -70,6 +71,20 @@ export interface LinkPreviewResult {
rateLimited?: boolean;
}
/** What a chat notification shows of a message: its first 80 characters, or that it was an image. */
function chatPreview(text: string): string {
const shown = text.trim() || 'sent an image';
return shown.length > 80 ? `${shown.substring(0, 80)}...` : shown;
}
/** A chat message as postMessage takes it; only the assistant's share sets `origin`. */
export interface ChatMessageInput {
text: string;
replyTo?: number | null;
files?: Array<{ filename: string; originalname: string; size: number; mimetype: string }>;
origin?: string | null;
}
/**
* Outbound fetches one user may trigger per minute. Deliberately generous: the
* client asks for a preview per rendered message (`LIMIT 100`) and per note with
@@ -140,13 +155,13 @@ function scrapeOpenGraph(html: string): Omit<LinkPreviewResult, 'url'> {
* numeric value feeds both the trip-scoping SELECT and the write that
* follows it.
*
* **`deleteMessage`'s pre-existing `username` gap.** The legacy statement
* behind `findInTrip` (CB51) is `SELECT * FROM collab_messages WHERE id = ?
* AND trip_id = ?` — no join to `users` — yet the legacy code returned
* `{ username: message.username }`, a column that statement never selected.
* That has always evaluated to `undefined` at runtime; preserved exactly
* below (parity is law), not fixed — flagged in the task-5 report as a
* pre-existing defect for a ruling, the same as `§17`'s other "surprises".
* **`deleteMessage`'s `username`.** The legacy statement behind `findInTrip`
* (CB51) never joined `users`, so the legacy `{ username: message.username }`
* was always `undefined` and REST fell back to the caller's own name. The
* author's name is now read through the joined shape before the delete, so
* MCP (which has no session user to fall back on) names the author too. REST
* sends the same bytes as before: only the author may delete a message, so
* the author's name is the caller's name.
*/
@Injectable()
export class CollabService {
@@ -204,6 +219,45 @@ export class CollabService {
this.realtime.broadcast(tripId, event, payload, socketId);
}
/* ------------------------------------------------------------------ */
/* Posting (every surface) */
/* ------------------------------------------------------------------ */
/** REST's writer for a post: the trip room minus the tab that sent the request. */
restWriter(tripId: string | number, user: Pick<User, 'id' | 'role'>, socketId: string | undefined): TripWriter {
return restTripWriter(this.realtime, tripId, user, socketId);
}
/**
* A note posted to the trip: every tab gets it, every fellow traveller a
* notification. The route always notified; through this, MCP does too.
*/
async postNote(tripId: string | number, data: Parameters<CollabService['createNote']>[2], writer: TripWriter) {
const note = await this.createNote(tripId, writer.userId, data);
writer.events.emit('collab:note:created', { note });
await this.notifyCollab(String(tripId), await this.actorOf(writer));
return note;
}
/**
* A chat message posted to the trip, sent and notified like a typed one
* wherever it came from. A reply to a message that is gone comes back as
* `{ error }` with nothing sent, for the caller to refuse in its own words.
*/
async postMessage(tripId: string | number, input: ChatMessageInput, writer: TripWriter) {
const { text, replyTo = null, files = [], origin = null } = input;
const result = await this.createMessage(tripId, writer.userId, text, replyTo, files, origin);
if (!result.message) return result;
writer.events.emit('collab:message:created', { message: result.message });
await this.notifyCollab(String(tripId), await this.actorOf(writer), chatPreview(text));
return result;
}
/** The writer as a notification names them: by id, and by the address the route read off its session. */
private async actorOf(writer: TripWriter): Promise<Pick<User, 'id' | 'email'>> {
return { id: writer.userId, email: await this.notifications.actorEmail(writer.userId) };
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
@@ -686,6 +740,7 @@ export class CollabService {
const message = await this.messagesRepo.findInTrip(idNum, tripIdNum);
if (!message) return { error: 'not_found' };
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
const author = await this.messagesRepo.findWithReply(message.id);
const attachments = await this.messagesRepo.listFilenamesForMessage(message.id, tripIdNum);
await this.uow.transactional(async () => {
@@ -700,10 +755,8 @@ export class CollabService {
/* best effort */
});
}
// `message.username` — see the class docstring's "pre-existing `username`
// gap" note: `findInTrip` never joins `users`, so this has always been
// `undefined` at runtime. Preserved exactly, not fixed.
return { username: undefined };
// Read before the delete; see the class docstring's `username` note.
return { username: author?.username };
}
/* ------------------------------------------------------------------ */
@@ -854,7 +907,7 @@ export class CollabService {
}
/** Fire-and-forget collab notification (mirrors the legacy route's dynamic import). */
async notifyCollab(tripId: string, actor: User, preview?: string): Promise<void> {
async notifyCollab(tripId: string, actor: Pick<User, 'id' | 'email'>, preview?: string): Promise<void> {
// Injected, not a lazy import of the old notifications bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
+13 -7
View File
@@ -374,6 +374,7 @@ export class FilesController {
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: FileLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
@@ -388,12 +389,16 @@ export class FilesController {
place_id: body.place_id,
budget_item_id: body.budget_item_id,
});
const links = await this.files.createFileLink(id, {
reservation_id: body.reservation_id,
assignment_id: body.assignment_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
});
const links = await this.files.linkFile(
id,
{
reservation_id: body.reservation_id,
assignment_id: body.assignment_id,
place_id: body.place_id,
budget_item_id: body.budget_item_id,
},
this.files.restWriter(tripId, user, socketId),
);
return { success: true, links };
}
@@ -406,6 +411,7 @@ export class FilesController {
@Param('tripId') tripId: string,
@Param('id') id: string,
@Param('linkId') linkId: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.files.can('file_edit', trip, user))) {
throw new HttpException({ error: 'No permission' }, 403);
@@ -417,7 +423,7 @@ export class FilesController {
if (!file) {
throw new HttpException({ error: 'File not found' }, 404);
}
await this.files.deleteFileLink(linkId, id);
await this.files.unlinkFile(id, linkId, this.files.restWriter(tripId, user, socketId));
return { success: true };
}
+2 -2
View File
@@ -268,7 +268,7 @@ export class FilesMcp {
}
const foreign = await this.files.findForeignLinkTarget(tripId, targets);
if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`);
const links = await this.files.createFileLink(fileId, targets);
const links = await this.files.linkFile(fileId, targets, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true, links });
}
@@ -294,7 +294,7 @@ export class FilesMcp {
// 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.
if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.');
await this.files.deleteFileLink(linkId, fileId);
await this.files.unlinkFile(fileId, linkId, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true });
}
+49 -11
View File
@@ -19,6 +19,7 @@ import { verifyJwtAndLoadUser } from '../auth-core/jwt-verify';
import { avatarUrl } from '../common/avatarUrl';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { restTripWriter, type TripWriter } from '../common/trip-writer';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -47,6 +48,18 @@ function formatFile(file: TripFileJoinRow) {
};
}
/** A file as the file list shows it: the row plus the ids of everything its links point at. */
function withLinkedIds(file: TripFileJoinRow, fileLinks: FileLinkTargetRow[]) {
return {
...formatFile(file),
linked_reservation_ids: fileLinks.filter((l) => l.reservation_id).map((l) => l.reservation_id),
linked_place_ids: fileLinks.filter((l) => l.place_id).map((l) => l.place_id),
// MCP's link_trip_file and plugins can pin a file to one stop of the plan.
linked_assignment_ids: fileLinks.filter((l) => l.assignment_id).map((l) => l.assignment_id),
linked_budget_item_ids: fileLinks.filter((l) => l.budget_item_id).map((l) => l.budget_item_id),
};
}
export interface FileLink {
file_id: number;
reservation_id: number | null;
@@ -343,17 +356,7 @@ export class FilesService {
linksMap[link.file_id].push(link);
}
return files.map((f) => {
const fileLinks = linksMap[f.id] || [];
return {
...formatFile(f),
linked_reservation_ids: fileLinks.filter((l) => l.reservation_id).map((l) => l.reservation_id),
linked_place_ids: fileLinks.filter((l) => l.place_id).map((l) => l.place_id),
// MCP's link_trip_file and plugins can pin a file to one stop of the plan.
linked_assignment_ids: fileLinks.filter((l) => l.assignment_id).map((l) => l.assignment_id),
linked_budget_item_ids: fileLinks.filter((l) => l.budget_item_id).map((l) => l.budget_item_id),
};
});
return files.map((f) => withLinkedIds(f, linksMap[f.id] || []));
}
/** R2: `createFile`'s `trip_files` insert and its conditional `file_links` insert (FL9+FL10) run inside one transaction. */
@@ -546,4 +549,39 @@ export class FilesService {
async getFileLinks(fileId: string | number) {
return await this.fileLinksRepo.listForFileWithReservationTitle(toRowId(fileId) ?? -1);
}
// ---------------------------------------------------------------------------
// Link changes, for every surface
// ---------------------------------------------------------------------------
/** REST's writer for a file use case: the trip room minus the tab that sent the request. */
restWriter(tripId: string | number, user: Pick<User, 'id' | 'role'>, socketId: string | undefined): TripWriter {
return restTripWriter(this.realtime, tripId, user, socketId);
}
/**
* Attaches a file to one more booking, place, stop or expense. Every tab of the
* trip gets the file again with all its links, the way the file list reads it,
* so the attachment shows without a reload. The callers have checked the file
* and the targets against the trip.
*/
async linkFile(fileId: string | number, targets: Parameters<FilesService['createFileLink']>[1], writer: TripWriter) {
const links = await this.createFileLink(fileId, targets);
await this.announceLinks(fileId, writer);
return links;
}
/** Removes one attachment of a file; the file stays, and every tab gets it with the links it has left. */
async unlinkFile(fileId: string | number, linkId: string | number, writer: TripWriter): Promise<void> {
await this.deleteFileLink(linkId, fileId);
await this.announceLinks(fileId, writer);
}
private async announceLinks(fileId: string | number, writer: TripWriter): Promise<void> {
const idNum = toRowId(fileId) ?? -1;
const row = await this.tripFilesRepo.findByIdWithJoins(idNum);
if (!row) return;
const file = withLinkedIds(row, await this.fileLinksRepo.listForFiles([idNum]));
writer.events.emit('file:updated', { file });
}
}
@@ -19,6 +19,7 @@ import {
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
@@ -149,8 +150,11 @@ export class NotificationsController {
}
@Put('in-app/read-all')
async readAll(@CurrentUser() user: User): Promise<{ success: boolean; count: number }> {
return { success: true, count: await this.notifications.markAllRead(user.id) };
async readAll(
@CurrentUser() user: User,
@Headers('x-socket-id') socketId?: string,
): Promise<{ success: boolean; count: number }> {
return { success: true, count: await this.notifications.markAllRead(user.id, socketId) };
}
@Delete('in-app/all')
@@ -159,18 +163,26 @@ export class NotificationsController {
}
@Put('in-app/:id/read')
async markRead(@CurrentUser() user: User, @Param('id') idParam: string): Promise<{ success: boolean }> {
async markRead(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Headers('x-socket-id') socketId?: string,
): Promise<{ success: boolean }> {
const id = this.parseId(idParam);
if (!(await this.notifications.markRead(id, user.id))) {
if (!(await this.notifications.markRead(id, user.id, socketId))) {
throw new HttpException({ error: 'Not found' }, 404);
}
return { success: true };
}
@Put('in-app/:id/unread')
async markUnread(@CurrentUser() user: User, @Param('id') idParam: string): Promise<{ success: boolean }> {
async markUnread(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Headers('x-socket-id') socketId?: string,
): Promise<{ success: boolean }> {
const id = this.parseId(idParam);
if (!(await this.notifications.markUnread(id, user.id))) {
if (!(await this.notifications.markUnread(id, user.id, socketId))) {
throw new HttpException({ error: 'Not found' }, 404);
}
return { success: true };
@@ -85,7 +85,8 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) {
const success = await this.notifications.markRead(notificationId, ctx.userId);
// No socket to leave out: every tab of the user, the asking one included, follows the bell.
const success = await this.notifications.markRead(notificationId, ctx.userId, undefined);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
@@ -100,7 +101,7 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) {
const success = await this.notifications.markUnread(notificationId, ctx.userId);
const success = await this.notifications.markUnread(notificationId, ctx.userId, undefined);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
@@ -113,7 +114,7 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markAllNotificationsRead(_input: Record<string, never>, ctx: McpContext) {
const count = await this.notifications.markAllRead(ctx.userId);
const count = await this.notifications.markAllRead(ctx.userId, undefined);
return ok({ success: true, count });
}
@@ -1,6 +1,6 @@
import { readEnv, getAppUrl } from '../../app-config';
import { Notifications } from '../../db/entities/Notifications.entity';
import type { NotificationsRepository } from '../../db/repositories/Notifications.repository';
import type { NotificationJoinRow, NotificationsRepository } from '../../db/repositories/Notifications.repository';
import { logDebug, logError } from '../audit/audit-log.logger';
import { avatarUrl } from '../common/avatarUrl';
import { UnitOfWork } from '../database/unit-of-work';
@@ -94,6 +94,20 @@ interface NotificationRow {
type RespondResult = { success: boolean; error?: string; notification?: NotificationRow };
/**
* A stored notification as the bell reads it: the time as ISO UTC, the sender's
* avatar as a URL. The repository row's `type`/`scope`/`response` are plain
* TEXT; narrowing them to the unions above is the same unchecked boundary the
* legacy `db.all<NotificationRow>(...)` generic drew.
*/
function withSender(row: NotificationJoinRow): NotificationRow {
return {
...row,
created_at: toUtcIso(row.created_at!),
sender_avatar: avatarUrl({ avatar: row.sender_avatar }),
} as NotificationRow;
}
// ── Event config map ───────────────────────────────────────────────────────
interface EventNotifConfig {
@@ -612,34 +626,45 @@ export class NotificationsService {
const total = await this.notificationsRepo.countForRecipient(userId, unreadOnly);
const unread_count = await this.notificationsRepo.countUnreadForRecipient(userId);
// The repository row's `type`/`scope`/`response` are the physical TEXT
// columns (plain `string`); this service's own `NotificationRow` narrows
// them to the literal unions it writes — the same unchecked boundary the
// legacy `db.all<NotificationRow>(...)` generic drew (SQLite has no enum
// type to check against either way).
const mapped = rows.map((r) => ({
...r,
created_at: toUtcIso(r.created_at!),
sender_avatar: avatarUrl({ avatar: r.sender_avatar }),
})) as NotificationRow[];
return { notifications: mapped, total, unread_count };
return { notifications: rows.map(withSender), total, unread_count };
}
async unreadCount(userId: number): Promise<number> {
return await this.notificationsRepo.countUnreadForRecipient(userId);
}
async markRead(notificationId: number, userId: number): Promise<boolean> {
return (await this.notificationsRepo.setRead(notificationId, userId, 1)) > 0;
/**
* The read state of one of the user's notifications. The user's other tabs and
* devices get the row as it now reads (`socketId`, REST's requesting tab, is
* left out: it changed its bell itself), so their bell and count follow.
*/
async markRead(notificationId: number, userId: number, socketId: string | undefined): Promise<boolean> {
return this.setReadState(notificationId, userId, 1, socketId);
}
async markUnread(notificationId: number, userId: number): Promise<boolean> {
return (await this.notificationsRepo.setRead(notificationId, userId, 0)) > 0;
async markUnread(notificationId: number, userId: number, socketId: string | undefined): Promise<boolean> {
return this.setReadState(notificationId, userId, 0, socketId);
}
async markAllRead(userId: number): Promise<number> {
return await this.notificationsRepo.markAllRead(userId);
/** Every notification of the user read at once; the other tabs read their list again when anything changed. */
async markAllRead(userId: number, socketId: string | undefined): Promise<number> {
const count = await this.notificationsRepo.markAllRead(userId);
if (count > 0) this.realtime.broadcastToUser(userId, { type: 'notification:read-all' }, socketId);
return count;
}
private async setReadState(id: number, userId: number, read: 0 | 1, socketId: string | undefined) {
if ((await this.notificationsRepo.setRead(id, userId, read)) === 0) return false;
const row = await this.notificationsRepo.findWithSenderById(id);
if (row) {
this.realtime.broadcastToUser(userId, { type: 'notification:updated', notification: withSender(row) }, socketId);
}
return true;
}
/** The address a notification names its actor by, as the routes read it off their session user. */
async actorEmail(userId: number): Promise<string> {
return (await this.mailer.getUserEmail(userId)) ?? '';
}
async deleteOne(notificationId: number, userId: number): Promise<boolean> {
@@ -691,14 +716,7 @@ export class NotificationsService {
return { success: false, error: err instanceof Error ? err.message : 'Action failed' };
}
const updated = (await this.notificationsRepo.findWithSenderById(notificationId))!;
// Same unchecked-narrowing boundary as `listInApp`'s `mapped` above.
const mappedUpdated = {
...updated,
created_at: toUtcIso(updated.created_at!),
sender_avatar: avatarUrl({ avatar: updated.sender_avatar }),
} as NotificationRow;
const mappedUpdated = withSender((await this.notificationsRepo.findWithSenderById(notificationId))!);
this.realtime.broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
@@ -0,0 +1,41 @@
import { RealtimeService } from '../realtime/realtime.service';
import { SettingsService } from './settings.service';
import { Injectable } from '@nestjs/common';
import { MASKED_SETTING_VALUE } from '@trek/shared';
/**
* A user's own settings saved, for every surface, and their other tabs and
* devices told which keys changed so they read their settings again. The event
* names keys only, never a value: some keys hold secrets, and the receiving tab
* reads the values through its own session anyway.
*
* `socketId` is REST's requesting tab, which applied the change itself and is
* left out. MCP passes none, so the asking tab follows a change the assistant made.
*/
@Injectable()
export class SettingsWritesService {
constructor(
private readonly settings: SettingsService,
private readonly realtime: RealtimeService,
) {}
async saveOne(userId: number, key: string, value: unknown, socketId: string | undefined): Promise<void> {
await this.settings.upsertSetting(userId, key, value);
this.announce(userId, [key], socketId);
}
/** Answers how many rows were written. A masked secret echoed back is skipped, and named in no event. */
async saveMany(userId: number, values: Record<string, unknown>, socketId: string | undefined): Promise<number> {
const written = await this.settings.bulkUpsertSettings(userId, values);
this.announce(
userId,
Object.keys(values).filter((key) => values[key] !== MASKED_SETTING_VALUE),
socketId,
);
return written;
}
private announce(userId: number, keys: string[], socketId: string | undefined): void {
if (keys.length > 0) this.realtime.broadcastToUser(userId, { type: 'settings:updated', keys }, socketId);
}
}
@@ -7,9 +7,10 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { isManagedLockedKey, splitManagedKeys } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { SettingsWritesService } from './settings-writes.service';
import { AdminDefaultUserSettingsDto, SettingUpsertDto, SettingsBulkDto } from './settings.dto';
import { SettingsService, isAdminOnlyEndpointSetting } from './settings.service';
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
import {
MASKED_SETTING_VALUE,
PLANNER_TRIP_VIEWS_KEY,
@@ -40,6 +41,8 @@ export class SettingsController {
constructor(
private readonly settings: SettingsService,
private readonly env: RuntimeEnvService,
// The writes, which tell the user's other tabs which keys changed.
private readonly writes: SettingsWritesService,
) {}
/**
@@ -87,7 +90,7 @@ export class SettingsController {
@Put()
@ResponseContract(settingUpsertResponseSchema)
async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) {
async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto, @Headers('x-socket-id') socketId?: string) {
this.assertMayWriteInstanceEndpoint({ [body.key]: body.value });
this.assertValidValues({ [body.key]: body.value });
// assertMayWriteInstanceEndpoint only covers llm_base_url and provider 'local'.
@@ -100,19 +103,19 @@ export class SettingsController {
if (body.value === MASKED_SETTING_VALUE) {
return { success: true, key: body.key, unchanged: true };
}
await this.settings.upsertSetting(user.id, body.key, body.value);
await this.writes.saveOne(user.id, body.key, body.value, socketId);
return { success: true, key: body.key, value: body.value };
}
@Post('bulk')
@HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201.
@ResponseContract(settingsBulkResponseSchema)
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) {
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) {
this.assertMayWriteInstanceEndpoint(body.settings);
this.assertValidValues(body.settings);
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
try {
const updated = await this.settings.bulkUpsertSettings(user.id, allowed);
const updated = await this.writes.saveMany(user.id, allowed, socketId);
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
} catch (err) {
console.error('Error saving settings:', err);
+7 -2
View File
@@ -7,6 +7,7 @@ import {
errorResult,
ok,
} from '../../nest-mcp';
import { SettingsWritesService } from './settings-writes.service';
import { SettingsService } from './settings.service';
import {
MASKED_SETTING_VALUE,
@@ -110,7 +111,10 @@ const KEY_LIST = DISPLAY_PREFERENCE_KEYS.join(', ');
*/
@McpController()
export class SettingsMcp {
constructor(private readonly settings: SettingsService) {}
constructor(
private readonly settings: SettingsService,
private readonly writes: SettingsWritesService,
) {}
/**
* getUserSettings resolves admin defaults and the managed-install token
@@ -182,7 +186,8 @@ export class SettingsMcp {
validated[key] = parsed.data;
}
const updated = await this.settings.bulkUpsertSettings(ctx.userId, validated);
// Every tab of the user, the asking one included, reads its settings again.
const updated = await this.writes.saveMany(ctx.userId, validated, undefined);
// Read back rather than echoing the input: an admin default or the managed
// token injection can still shape what the user ends up seeing.
return ok({ success: true, updated, settings: await this.readDisplayPreferences(ctx.userId) });
+11 -2
View File
@@ -2,7 +2,9 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { SettingsWritesService } from './settings-writes.service';
import { AdminDefaultUserSettingsController, SettingsController } from './settings.controller';
import { SettingsMcp } from './settings.mcp';
import { SettingsService } from './settings.service';
@@ -26,9 +28,16 @@ import { Module } from '@nestjs/common';
// `@InjectRepository(Users)`. TripMembershipModule is here for the planner
// view row (#2639), which keeps only trips the user can still open: the
// trips table belongs to another domain, so the ids come from its service.
imports: [AppConfigModule, AuditModule, TripMembershipModule, MikroOrmModule.forFeature([AppSettings, Settings])],
// RealtimeModule for SettingsWritesService, which tells a user's other tabs which keys changed.
imports: [
AppConfigModule,
AuditModule,
TripMembershipModule,
RealtimeModule,
MikroOrmModule.forFeature([AppSettings, Settings]),
],
controllers: [SettingsController, AdminDefaultUserSettingsController],
providers: [SettingsService, SettingsMcp],
providers: [SettingsService, SettingsWritesService, SettingsMcp],
exports: [SettingsService],
})
export class SettingsModule {}
@@ -0,0 +1,46 @@
import { RealtimeService } from '../realtime/realtime.service';
import { TagsService } from './tags.service';
import { Injectable } from '@nestjs/common';
import type { Tag } from '@trek/shared';
/**
* A user's tag writes, for REST and MCP alike, each followed by `tags:changed`
* to the user's sockets so every tag picker they have open reads the list again.
* A ping rather than the tag: a delete has nothing left to send.
*
* `socketId` is REST's requesting tab, which updated its own list and is left
* out; MCP passes none. The callers keep their ownership checks.
*/
@Injectable()
export class TagWritesService {
constructor(
private readonly tags: TagsService,
private readonly realtime: RealtimeService,
) {}
async create(userId: number, name: string, color: string | undefined, socketId: string | undefined): Promise<Tag> {
const tag = await this.tags.create(userId, name, color);
this.announce(userId, socketId);
return tag;
}
async update(
userId: number,
id: string | number,
changes: { name?: string; color?: string },
socketId: string | undefined,
): Promise<Tag> {
const tag = await this.tags.update(id, changes.name, changes.color);
this.announce(userId, socketId);
return tag;
}
async remove(userId: number, id: string | number, socketId: string | undefined): Promise<void> {
await this.tags.remove(id);
this.announce(userId, socketId);
}
private announce(userId: number, socketId: string | undefined): void {
this.realtime.broadcastToUser(userId, { type: 'tags:changed' }, socketId);
}
}
+26 -8
View File
@@ -1,9 +1,10 @@
import type { User } from '../../types';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { TagWritesService } from './tag-writes.service';
import { TagCreateDto, TagUpdateDto } from './tags.dto';
import { TagsService } from './tags.service';
import { Body, Controller, Delete, Get, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Headers, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import type { Tag, TagListResponse } from '@trek/shared';
/**
@@ -18,7 +19,11 @@ import type { Tag, TagListResponse } from '@trek/shared';
@Controller('api/tags')
@UseGuards(JwtAuthGuard)
export class TagsController {
constructor(private readonly tags: TagsService) {}
constructor(
private readonly tags: TagsService,
// The writes, which tell the user's other tabs that their tags changed.
private readonly writes: TagWritesService,
) {}
@Get()
async list(@CurrentUser() user: User): Promise<TagListResponse> {
@@ -26,7 +31,11 @@ export class TagsController {
}
@Post()
async create(@CurrentUser() user: User, @Body() body: TagCreateDto): Promise<{ tag: Tag }> {
async create(
@CurrentUser() user: User,
@Body() body: TagCreateDto,
@Headers('x-socket-id') socketId?: string,
): Promise<{ tag: Tag }> {
// Cast rather than validate: the legacy route bound whatever arrived
// straight into SQLite, and a contract that rejected a numeric name here
// would be a new 400 for anything already sending one.
@@ -34,24 +43,33 @@ export class TagsController {
if (!name) {
throw new HttpException({ error: 'Tag name is required' }, 400);
}
return { tag: await this.tags.create(user.id, name, color) };
return { tag: await this.writes.create(user.id, name, color, socketId) };
}
@Put(':id')
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TagUpdateDto): Promise<{ tag: Tag }> {
async update(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: TagUpdateDto,
@Headers('x-socket-id') socketId?: string,
): Promise<{ tag: Tag }> {
const { name, color } = body as { name?: string; color?: string };
if (!(await this.tags.getByIdAndUser(id, user.id))) {
throw new HttpException({ error: 'Tag not found' }, 404);
}
return { tag: await this.tags.update(id, name, color) };
return { tag: await this.writes.update(user.id, id, { name, color }, socketId) };
}
@Delete(':id')
async remove(@CurrentUser() user: User, @Param('id') id: string): Promise<{ success: boolean }> {
async remove(
@CurrentUser() user: User,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
): Promise<{ success: boolean }> {
if (!(await this.tags.getByIdAndUser(id, user.id))) {
throw new HttpException({ error: 'Tag not found' }, 404);
}
await this.tags.remove(id);
await this.writes.remove(user.id, id, socketId);
return { success: true };
}
}
+10 -5
View File
@@ -9,6 +9,7 @@ import {
errorResult,
ok,
} from '../../nest-mcp';
import { TagWritesService } from './tag-writes.service';
import { TagsService } from './tags.service';
import { idSchema } from '@trek/shared';
@@ -22,7 +23,11 @@ import { z } from 'zod';
*/
@McpController()
export class TagsMcp {
constructor(private readonly tags: TagsService) {}
// The writes go through TagWritesService with no socket to leave out: every tab of the caller reads its tags again.
constructor(
private readonly tags: TagsService,
private readonly writes: TagWritesService,
) {}
@Tool({
name: 'list_tags',
@@ -47,7 +52,7 @@ export class TagsMcp {
access: { group: 'places', mode: 'write' },
})
async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) {
const tag = await this.tags.create(ctx.userId, name, color);
const tag = await this.writes.create(ctx.userId, name, color, undefined);
return ok({ tag });
}
@@ -64,8 +69,8 @@ export class TagsMcp {
})
async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) {
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
const tag = await this.tags.update(tagId, name, color);
if (!(await tag)) return errorResult('Tag not found.');
const tag = await this.writes.update(ctx.userId, tagId, { name, color }, undefined);
if (!tag) return errorResult('Tag not found.');
return ok({ tag });
}
@@ -80,7 +85,7 @@ export class TagsMcp {
})
async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) {
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
await this.tags.remove(tagId);
await this.writes.remove(ctx.userId, tagId, undefined);
return ok({ success: true });
}
}
+5 -2
View File
@@ -1,4 +1,6 @@
import { Tags } from '../../db/entities/Tags.entity';
import { RealtimeModule } from '../realtime/realtime.module';
import { TagWritesService } from './tag-writes.service';
import { TagsController } from './tags.controller';
import { TagsMcp } from './tags.mcp';
import { TagsRpc } from './tags.rpc';
@@ -15,12 +17,13 @@ import { Module } from '@nestjs/common';
* precedent comment).
*/
@Module({
imports: [MikroOrmModule.forFeature([Tags])],
// RealtimeModule for TagWritesService, which tells a user's other tabs that their tags changed.
imports: [RealtimeModule, MikroOrmModule.forFeature([Tags])],
controllers: [TagsController],
// TagsRpc must stay in providers: the plugin RPC registry discovers marked
// PROVIDERS only, and a missing entry here would leave tags.* answering
// PERMISSION_DENIED with no other symptom.
providers: [TagsService, TagsMcp, TagsRpc],
providers: [TagsService, TagWritesService, TagsMcp, TagsRpc],
// For in-container consumers (TagsRpc).
exports: [TagsService],
})
@@ -0,0 +1,88 @@
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import type { User } from '../../types';
import { BudgetService } from '../budget/budget.service';
import { restTripWriter, type TripWriter } from '../common/trip-writer';
import { RealtimeService } from '../realtime/realtime.service';
import { TripMembersService } from './trip-members.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
/**
* The roster writes of a trip, one method per use case, for REST and MCP alike.
*
* Each runs the write through TripMembersService, whose methods stay silent
* building blocks, and sends what the room needs once it is committed. Before
* this only the MCP tools announced a member coming or going, and only REST sent
* the invitation, so a tab never learnt of a member added in the planner, and a
* person added through MCP was never told.
*
* The callers keep their own gates (trip access, member_manage, owner only),
* which already agreed.
*/
@Injectable()
export class TripMemberWritesService {
constructor(
private readonly roster: TripMembersService,
private readonly budget: BudgetService,
private readonly realtime: RealtimeService,
@InjectRepository(Users) private readonly users: UsersRepository,
) {}
/** REST's writer for a roster change: the room minus the tab that sent the request. */
restWriter(tripId: string | number, user: Pick<User, 'id' | 'role'>, socketId: string | undefined): TripWriter {
return restTripWriter(this.realtime, tripId, user, socketId);
}
/** Puts an account on the trip: the room gets the member, the person an invitation. */
async addMember(tripId: string | number, identifier: string, tripOwnerId: number, writer: TripWriter) {
const result = await this.roster.addMember(tripId, identifier, tripOwnerId, writer.userId);
writer.events.emit('member:added', { member: result.member });
// The invitation names who sent it by address, as the route always did with its session user.
const actor = { id: writer.userId, email: (await this.users.getEmail(writer.userId)) ?? '' };
this.roster.notifyInvite(String(tripId), actor, result.targetUserId, result.tripTitle, result.member.email);
return result;
}
/** Takes somebody off the trip, the writer themselves included when they leave. */
async removeMember(tripId: number, targetUserId: number, writer: TripWriter): Promise<void> {
await this.roster.removeMember(tripId, targetUserId);
writer.events.emit('member:removed', { userId: targetUserId });
}
/** A companion without an account. No invitation: a guest has no inbox. */
async createGuest(tripId: string | number, name: string, writer: TripWriter) {
const created = await this.roster.createGuest(tripId, name, writer.userId);
writer.events.emit('member:added', { member: created.member });
return created;
}
/** False for somebody who is no guest of this trip. The event carries the id and the new name, never the row. */
async renameGuest(tripId: string | number, guestId: number, name: string, writer: TripWriter): Promise<boolean> {
if (!(await this.roster.renameGuest(tripId, guestId, name))) return false;
writer.events.emit('member:updated', { member: { id: guestId, username: name.trim() } });
return true;
}
/**
* False for somebody who is no guest of this trip. Deleting a guest hands their
* share of every expense they were part of to the rest of its split, and clears
* them as the legacy payer of any expense, so each of those expenses goes out
* again after the member, as it now reads.
*/
async deleteGuest(tripId: string | number, guestId: number, writer: TripWriter): Promise<boolean> {
// Read first: the rows that name the guest go with the guest.
const shared = (await this.budget.listBudgetItems(tripId))
.filter(
(item) => item.paid_by_user_id === guestId || item.members?.some((member) => member.user_id === guestId),
)
.map((item) => item.id);
if (!(await this.roster.deleteGuest(tripId, guestId))) return false;
writer.events.emit('member:removed', { userId: guestId });
const resplit = await Promise.all(shared.map((itemId) => this.budget.getBudgetItem(itemId, tripId)));
for (const item of resplit) {
if (item) writer.events.emit('budget:updated', { item });
}
return true;
}
}
@@ -7,6 +7,7 @@ import { NotFoundError, ValidationError } from '../common/domain-errors';
import { toRowId } from '../common/row-id';
import { RequireTripOwner, TripOwnerGuard } from '../permissions/trip-owner.guard';
import { TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto } from '../trips/trips.dto';
import { TripMemberWritesService } from './trip-member-writes.service';
import { TripMembersService } from './trip-members.service';
import {
Body,
@@ -49,6 +50,8 @@ export class TripMembersController {
constructor(
private readonly roster: TripMembersService,
private readonly audit: AuditService,
// The roster changes, which announce themselves to the room the way the MCP tools do.
private readonly writes: TripMemberWritesService,
) {}
@Get(':id/members')
@@ -63,7 +66,12 @@ export class TripMembersController {
@Post(':id/members')
@HttpCode(201)
async addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) {
async addMember(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: TripAddMemberDto,
@Headers('x-socket-id') socketId?: string,
) {
const { identifier } = body;
const access = await this.roster.canAccessTrip(id, user.id);
if (!access) {
@@ -73,8 +81,8 @@ export class TripMembersController {
throw new HttpException({ error: 'No permission to manage members' }, 403);
}
try {
const result = await this.roster.addMember(id, identifier, access.user_id, user.id);
this.roster.notifyInvite(id, user, result.targetUserId, result.tripTitle, result.member.email);
const writer = this.writes.restWriter(id, user, socketId);
const result = await this.writes.addMember(id, identifier, access.user_id, writer);
return { member: result.member };
} catch (e: unknown) {
if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404);
@@ -84,7 +92,12 @@ export class TripMembersController {
}
@Delete(':id/members/:userId')
async removeMember(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
async removeMember(
@CurrentUser() user: User,
@Param('id') id: string,
@Param('userId') userId: string,
@Headers('x-socket-id') socketId?: string,
) {
// Plan 4 Task 8a — parsed ONCE here (toRowId, not Number(): rule 15's
// NaN-into-SQL trap), and the parsed number is what both the access
// check and the removal itself use below — this route has no class-level
@@ -105,7 +118,7 @@ export class TripMembersController {
) {
throw new HttpException({ error: 'No permission to remove members' }, 403);
}
await this.roster.removeMember(tripId, targetId);
await this.writes.removeMember(tripId, targetId, this.writes.restWriter(tripId, user, socketId));
return { success: true };
}
@@ -144,12 +157,16 @@ export class TripMembersController {
@HttpCode(201)
@UseGuards(TripOwnerGuard)
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
async createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) {
async createGuest(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: TripCreateGuestDto,
@Headers('x-socket-id') socketId?: string,
) {
// Whitespace-only names still 400 with the legacy body — the service throws
// ValidationError('Guest name is required') after trimming.
try {
// No notifyInvite: a guest has no inbox.
return await this.roster.createGuest(id, body.name, user.id);
return await this.writes.createGuest(id, body.name, this.writes.restWriter(id, user, socketId));
} catch (e: unknown) {
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -164,9 +181,11 @@ export class TripMembersController {
@Param('id') id: string,
@Param('userId') userId: string,
@Body() body: TripRenameGuestDto,
@Headers('x-socket-id') socketId?: string,
) {
try {
if (!(await this.roster.renameGuest(id, Number.parseInt(userId), body.name))) {
const writer = this.writes.restWriter(id, user, socketId);
if (!(await this.writes.renameGuest(id, Number.parseInt(userId), body.name, writer))) {
throw new HttpException({ error: 'Guest not found' }, 404);
}
return { success: true };
@@ -180,8 +199,14 @@ export class TripMembersController {
@Delete(':id/guests/:userId')
@UseGuards(TripOwnerGuard)
@RequireTripOwner('Only the owner can manage guests', { param: 'id' })
async deleteGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
if (!(await this.roster.deleteGuest(id, Number.parseInt(userId)))) {
async deleteGuest(
@CurrentUser() user: User,
@Param('id') id: string,
@Param('userId') userId: string,
@Headers('x-socket-id') socketId?: string,
) {
const writer = this.writes.restWriter(id, user, socketId);
if (!(await this.writes.deleteGuest(id, Number.parseInt(userId), writer))) {
throw new HttpException({ error: 'Guest not found' }, 404);
}
return { success: true };
@@ -7,6 +7,7 @@ import { BudgetModule } from '../budget/budget.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { TripMemberWritesService } from './trip-member-writes.service';
import { TripMembersController } from './trip-members.controller';
import { TripMembersService } from './trip-members.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
@@ -37,7 +38,8 @@ import { Module } from '@nestjs/common';
MikroOrmModule.forFeature([Trips, TripMembers, Users]),
],
controllers: [TripMembersController],
providers: [TripMembersService],
exports: [TripMembersService],
providers: [TripMembersService, TripMemberWritesService],
// The writes for TripsMcp, whose roster tools announce through the same use cases as these routes.
exports: [TripMembersService, TripMemberWritesService],
})
export class TripMembersModule {}
@@ -118,7 +118,13 @@ export class TripMembersService {
}
/** Fire-and-forget trip-invite notification (mirrors the route's dynamic import). */
notifyInvite(tripId: string, actor: User, targetUserId: number, tripTitle: string, inviteeEmail: string): void {
notifyInvite(
tripId: string,
actor: Pick<User, 'id' | 'email'>,
targetUserId: number,
tripTitle: string,
inviteeEmail: string,
): void {
// Injected, not a lazy import of the old notifications bridge. The laziness bought
// nothing the module graph does not already give — NotificationsModule
// reaches nothing in this direction — and it hid the edge while handing the
@@ -151,6 +157,13 @@ export class TripMembersService {
};
}
/** One member as the roster lists them, for a write that joined them by id (the plugin RPC). */
async findMember(tripId: number, userId: number) {
const ownerId = await this.tripsRepo.getOwnerId(tripId);
if (ownerId === null) return undefined;
return (await this.listMembers(tripId, ownerId)).members.find((m) => m.id === userId);
}
async addMember(
tripId: string | number,
identifier: string,
@@ -0,0 +1,84 @@
import type { User } from '../../types';
import { DomainError } from '../common/domain-error';
import { restTripWriter, type TripWriter } from '../common/trip-writer';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TripsService, type DeleteTripInfo } from './trips.service';
import { Injectable } from '@nestjs/common';
/** The fields a new trip takes, as TripsService.create reads them. */
type TripCreateInput = Parameters<TripsService['create']>[1];
/**
* Who adds a trip to their own list. `role` is for the trip_create check; REST's
* `socketId` is the tab that asked, which shows the new trip from its own answer
* and is left out of the ping. MCP has none, so every tab of the user hears it.
*/
export interface TripListWriter {
userId: number;
role: string;
socketId: string | undefined;
}
/**
* The trip writes whose events every surface sends the same way: a trip created
* or copied tells its owner's other tabs that the trip list changed, and a
* deleted trip tells the trip room. TripsService holds the writes themselves and
* sends nothing; the controller and the MCP tools are adapters over this.
*
* The update broadcast stays with its callers, which already sent it alike. What
* they share for it is the permission list (tripUpdatePermissions in
* trips.helpers.ts).
*/
@Injectable()
export class TripWritesService {
constructor(
private readonly trips: TripsService,
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
) {}
/** REST's writer for a trip: the room minus the tab that sent the request. */
restWriter(tripId: string | number, user: Pick<User, 'id' | 'role'>, socketId: string | undefined): TripWriter {
return restTripWriter(this.realtime, tripId, user, socketId);
}
/**
* Creates a trip with its days. REST checks trip_create before its own date
* checks, so the gate here only ever refuses an MCP caller.
*/
async create(input: TripCreateInput, writer: TripListWriter) {
await this.requireCreate(writer);
const created = await this.trips.create(writer.userId, input);
this.announceList(writer);
return created;
}
/** Copies a trip the writer can open into a new one they own, and returns its id. */
async copy(sourceTripId: string | number, title: string | undefined, writer: TripListWriter): Promise<number> {
await this.requireCreate(writer);
const newTripId = await this.trips.copy(sourceTripId, writer.userId, title);
this.announceList(writer);
return newTripId;
}
/** Deletes a trip and tells everyone in its room, once the delete is committed. */
async remove(tripId: string | number, writer: TripWriter): Promise<DeleteTripInfo> {
const info = await this.trips.remove(tripId, writer.userId, writer.role);
writer.events.emit('trip:deleted', { id: info.tripId });
return info;
}
private async requireCreate(writer: TripListWriter): Promise<void> {
if (!(await this.permissions.checkPermission('trip_create', writer.role, null, writer.userId, false))) {
throw new DomainError(403, 'No permission to create trips', {
mcpMessage: 'You do not have permission to create trips.',
});
}
}
/** A ping, not the trip: the dashboard of every other tab reads its list again. */
private announceList(writer: TripListWriter): void {
this.realtime.broadcastToUser(writer.userId, { type: 'trips:changed' }, writer.socketId);
}
}
+29 -33
View File
@@ -11,6 +11,7 @@ import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { StorageService } from '../storage/storage.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
import { UnsplashService } from '../unsplash/unsplash.service';
import { TripWritesService } from './trip-writes.service';
import {
TripCreateDto,
TripUpdateDto,
@@ -20,6 +21,7 @@ import {
TripCreateGuestDto,
TripRenameGuestDto,
} from './trips.dto';
import { tripUpdatePermissions } from './trips.helpers';
import { TripsService } from './trips.service';
import { NotFoundError, ValidationError } from './trips.service';
import {
@@ -81,6 +83,7 @@ export class TripsController {
private readonly calendar: CalendarService,
private readonly readModel: TripReadModelService,
private readonly storage: StorageService,
private readonly tripWrites: TripWritesService,
) {}
@Get()
@@ -120,7 +123,12 @@ export class TripsController {
@Post()
@HttpCode(201)
async create(@CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request) {
async create(
@CurrentUser() user: User,
@Body() body: TripCreateDto,
@Req() req: Request,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
@@ -133,15 +141,10 @@ export class TripsController {
}
const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
try {
const { trip, tripId, reminderDays } = await this.trips.create(user.id, {
title,
description,
start_date,
end_date,
currency,
reminder_days,
day_count: parsedDayCount,
});
const { trip, tripId, reminderDays } = await this.tripWrites.create(
{ title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount },
{ userId: user.id, role: user.role, socketId },
);
await this.audit.writeAudit({
userId: user.id,
action: 'trip.create',
@@ -179,25 +182,13 @@ export class TripsController {
}
const ownerId = access.user_id;
const isMember = ownerId !== user.id;
if (
body.is_archived !== undefined &&
!(await this.trips.can('trip_archive', user.role, ownerId, user.id, isMember))
) {
throw new HttpException({ error: 'No permission to archive/unarchive this trip' }, 403);
}
if (
body.cover_image !== undefined &&
!(await this.trips.can('trip_cover_upload', user.role, ownerId, user.id, isMember))
) {
throw new HttpException({ error: 'No permission to change cover image' }, 403);
}
const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count'];
if (
editFields.some((f) => body[f] !== undefined) &&
!(await this.trips.can('trip_edit', user.role, ownerId, user.id, isMember))
) {
throw new HttpException({ error: 'No permission to edit this trip' }, 403);
}
// A body the caller may only partly write is refused on the first right it lacks, in the list's order.
const needed = tripUpdatePermissions(body);
const granted = await Promise.all(
needed.map(({ action }) => this.trips.can(action, user.role, ownerId, user.id, isMember)),
);
const lacking = needed.find((_, i) => !granted[i]);
if (lacking) throw new HttpException({ error: lacking.refusal }, 403);
// A chosen Unsplash cover arrives as an images.unsplash.com hot-link; download
// it into uploads/covers so the cover survives offline + CDN link-rot (#1277).
if (this.unsplash.isUnsplashCoverUrl(body.cover_image)) {
@@ -282,7 +273,13 @@ export class TripsController {
@Post(':id/copy')
@HttpCode(201)
async copy(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request) {
async copy(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: TripCopyDto,
@Req() req: Request,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.trips.can('trip_create', user.role, null, user.id, false))) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
@@ -291,7 +288,7 @@ export class TripsController {
}
const { title } = body;
try {
const newTripId = await this.trips.copy(id, user.id, title);
const newTripId = await this.tripWrites.copy(id, title, { userId: user.id, role: user.role, socketId });
await this.audit.writeAudit({
userId: user.id,
action: 'trip.copy',
@@ -325,7 +322,7 @@ export class TripsController {
if (!(await this.trips.can('trip_delete', user.role, owner.user_id, user.id, owner.user_id !== user.id))) {
throw new HttpException({ error: 'No permission to delete this trip' }, 403);
}
const info = await this.trips.remove(id, user.id, user.role);
const info = await this.tripWrites.remove(id, this.tripWrites.restWriter(id, user, socketId));
await this.audit.writeAudit({
userId: user.id,
action: 'trip.delete',
@@ -334,7 +331,6 @@ export class TripsController {
});
if (info.isAdminDelete && info.ownerEmail)
logInfo(`Admin ${user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`);
this.trips.broadcast(String(info.tripId), 'trip:deleted', { id: info.tripId }, socketId);
return { success: true };
}
+24 -1
View File
@@ -1,5 +1,5 @@
import { ValidationError } from '../common/domain-errors';
import { MAX_TRIP_DAYS, tripSpanDays } from '@trek/shared';
import { MAX_TRIP_DAYS, tripSpanDays, type PermissionKey } from '@trek/shared';
/**
* Pure helpers of the trips domain, moved out of trips.service.ts. Nothing
@@ -33,3 +33,26 @@ export function withoutFeedToken<T>(row: T): T {
if (row && typeof row === 'object') delete (row as { feed_token?: unknown }).feed_token;
return row;
}
/** The fields of a trip update that count as an edit, as against archiving it or changing its cover. */
const TRIP_EDIT_FIELDS = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count'];
/**
* The permissions a trip update needs, in the order PUT /api/trips/:id checks
* them, each with the refusal that route answers. Archiving and the cover have
* their own action; any other field present is an edit. update_trip runs the
* same list, so an MCP caller cannot archive a trip or swap its cover on the
* strength of trip_edit alone.
*/
export function tripUpdatePermissions(body: object): { action: PermissionKey; refusal: string }[] {
const sent = (field: string) => (body as Record<string, unknown>)[field] !== undefined;
const needed: { action: PermissionKey; refusal: string }[] = [];
if (sent('is_archived')) {
needed.push({ action: 'trip_archive', refusal: 'No permission to archive/unarchive this trip' });
}
if (sent('cover_image')) {
needed.push({ action: 'trip_cover_upload', refusal: 'No permission to change cover image' });
}
if (TRIP_EDIT_FIELDS.some(sent)) needed.push({ action: 'trip_edit', refusal: 'No permission to edit this trip' });
return needed;
}
+53 -35
View File
@@ -23,8 +23,11 @@ import { DomainError } from '../common/domain-error';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { tripIdPromptArg } from '../mcp-shared/prompt-args';
import { TodoService } from '../todo/todo.service';
import { TripMemberWritesService } from '../trip-members/trip-member-writes.service';
import { TripMembersService } from '../trip-members/trip-members.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
import { TripWritesService, type TripListWriter } from './trip-writes.service';
import { tripUpdatePermissions } from './trips.helpers';
import { TripsService, NotFoundError, ValidationError } from './trips.service';
import { idSchema, MAX_TRIP_DAYS } from '@trek/shared';
@@ -89,8 +92,19 @@ export class TripsMcp {
private readonly readModel: TripReadModelService,
private readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
private readonly tripWrites: TripWritesService,
private readonly memberWrites: TripMemberWritesService,
) {}
/**
* The caller adding a trip to their own list. Only the admin role changes what
* the trip_create check answers. No socket to leave out: every tab of the
* caller hears that the list changed.
*/
private async listWriter(userId: number): Promise<TripListWriter> {
return { userId, role: (await this.guards.isAdminUser(userId)) ? 'admin' : 'user', socketId: undefined };
}
// --- TRIPS ---
@Tool({
@@ -174,15 +188,10 @@ export class TripsMcp {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
try {
const { trip } = await this.trips.create(ctx.userId, {
title,
description,
start_date,
end_date,
currency,
day_count,
reminder_days,
});
const { trip } = await this.tripWrites.create(
{ title, description, start_date, end_date, currency, day_count, reminder_days },
await this.listWriter(ctx.userId),
);
return ok({ trip });
} catch (err) {
if (err instanceof ValidationError) return errorResult(err.message);
@@ -276,7 +285,19 @@ export class TripsMcp {
ctx: McpContext,
) {
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('trip_edit', tripId, ctx.userId))) return permissionDenied();
// updateTrip reads every field with a `!== undefined` presence sentinel, so a
// dated trip only goes back to dateless once an explicit null reaches it. A flag
// rather than a nullable start_date/end_date: clients that serialise an omitted
// optional as null would otherwise wipe the dates of every trip they touched.
const dates: { start_date?: string | null; end_date?: string | null } = clear_dates
? { start_date: null, end_date: null }
: { start_date, end_date };
const fields = { title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days };
// The rights PUT /api/trips/:id asks for, field by field: archiving and the cover have their own.
const granted = await Promise.all(
tripUpdatePermissions(fields).map(({ action }) => this.guards.hasTripPermission(action, tripId, ctx.userId)),
);
if (granted.includes(false)) return permissionDenied();
if (clear_dates && (start_date || end_date))
return errorResult('clear_dates cannot be combined with start_date or end_date.');
if (start_date) {
@@ -292,21 +313,16 @@ export class TripsMcp {
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
// updateTrip reads every field with a `!== undefined` presence sentinel, so a
// dated trip only goes back to dateless once an explicit null reaches it. A flag
// rather than a nullable start_date/end_date: clients that serialise an omitted
// optional as null would otherwise wipe the dates of every trip they touched.
const dates: { start_date?: string | null; end_date?: string | null } = clear_dates
? { start_date: null, end_date: null }
: { start_date, end_date };
// update() re-anchors the budget before the trip row moves off the old
// currency (#1543) and then runs the legacy updateTrip core.
// currency (#1543) and then runs the legacy updateTrip core. The caller's own
// role, as REST hands it on, so an admin's edit of a trip they do not own is one.
const { role } = await this.guards.tripWriter(tripId, ctx.userId);
try {
const { updatedTrip, removedDays } = await this.trips.update(
tripId,
ctx.userId,
{ title, description, ...dates, currency, is_archived, cover_image, day_count, reminder_days, date_shift_mode },
'user',
{ ...fields, date_shift_mode },
role,
);
this.guards.safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
// Only when days went, so the answer to a rename or a longer trip stays as it was.
@@ -351,7 +367,8 @@ export class TripsMcp {
})
async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.trips.isOwner(tripId, ctx.userId))) return noAccess();
await this.trips.remove(tripId, ctx.userId, 'user');
// trip:deleted goes to the room as on REST, so open tabs leave the trip.
await this.tripWrites.remove(tripId, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true, tripId });
}
@@ -488,9 +505,10 @@ export class TripsMcp {
// in the permission matrix, and POST /api/trips/:id/members has always honoured
// that setting (and the admin bypass inside checkPermission) where this did not.
if (!(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId))) return permissionDenied();
const writer = await this.guards.tripWriter(tripId, ctx.userId);
try {
const result = await this.members.addMember(tripId, identifier, ownerRow.user_id, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:added', { member: result.member });
// Invited the way the planner invites: the room hears of the member, the person gets the notification.
const result = await this.memberWrites.addMember(tripId, identifier, ownerRow.user_id, writer);
return ok({ member: result.member });
} catch (err) {
const msg =
@@ -516,8 +534,7 @@ export class TripsMcp {
// requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has.
if (memberId !== ctx.userId && !(await this.guards.hasTripPermission('member_manage', tripId, ctx.userId)))
return permissionDenied();
await this.members.removeMember(tripId, memberId);
this.guards.safeBroadcast(tripId, 'member:removed', { userId: memberId });
await this.memberWrites.removeMember(tripId, memberId, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true });
}
@@ -539,8 +556,7 @@ export class TripsMcp {
// owner calling this would get a success that changed nothing. Say so instead.
if (ownerRow.user_id === ctx.userId)
return errorResult('You own this trip, so you cannot leave it. Hand it to another member first, or delete it.');
await this.members.removeMember(tripId, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:removed', { userId: ctx.userId });
await this.memberWrites.removeMember(tripId, ctx.userId, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true });
}
@@ -570,10 +586,9 @@ export class TripsMcp {
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true };
const writer = await this.guards.tripWriter(tripId, ctx.userId);
try {
// No notifyInvite: a guest has no inbox.
const { member } = await this.members.createGuest(tripId, name, ctx.userId);
this.guards.safeBroadcast(tripId, 'member:added', { member });
const { member } = await this.memberWrites.createGuest(tripId, name, writer);
return ok({ member });
} catch (err) {
const msg = err instanceof ValidationError ? err.message : 'Failed to create guest.';
@@ -597,8 +612,9 @@ export class TripsMcp {
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true };
const writer = await this.guards.tripWriter(tripId, ctx.userId);
try {
if (!(await this.members.renameGuest(tripId, guestId, name)))
if (!(await this.memberWrites.renameGuest(tripId, guestId, name, writer)))
return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true };
} catch (err) {
const msg = err instanceof ValidationError ? err.message : 'Failed to rename guest.';
@@ -623,9 +639,9 @@ export class TripsMcp {
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true };
if (!(await this.members.deleteGuest(tripId, guestId)))
// The member goes, then every expense the guest shared, split anew.
if (!(await this.memberWrites.deleteGuest(tripId, guestId, await this.guards.tripWriter(tripId, ctx.userId))))
return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'member:removed', { userId: guestId });
return ok({ success: true });
}
@@ -643,10 +659,12 @@ export class TripsMcp {
async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) {
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
try {
const newTripId = await this.trips.copy(tripId, ctx.userId, title);
const newTripId = await this.tripWrites.copy(tripId, title, await this.listWriter(ctx.userId));
const newTrip = await this.trips.canAccessTrip(newTripId, ctx.userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
} catch (err) {
// A refusal (no trip_create) keeps its own words; anything else stays the generic failure.
if (err instanceof DomainError) throw err;
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
+2 -1
View File
@@ -25,6 +25,7 @@ import { TripReadModelModule } from '../trip-read-model/trip-read-model.module';
import { UnsplashModule } from '../unsplash/unsplash.module';
import { VacayModule } from '../vacay/vacay.module';
import { TripPromptsMcp } from './trip-prompts.mcp';
import { TripWritesService } from './trip-writes.service';
import { TripsController } from './trips.controller';
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
import { TripsMcp } from './trips.mcp';
@@ -73,7 +74,7 @@ import { MulterModule } from '@nestjs/platform-express';
SettingsModule,
],
controllers: [TripsController],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
providers: [TripsService, TripWritesService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
exports: [TripsService],
})
+9 -1
View File
@@ -204,7 +204,14 @@ export class TripsRpc {
const target = await this.em.getRepository(Users).findIdAndEmail(targetUserId);
if (!target) throw new ForbiddenResource(`no user ${targetUserId}`);
// The acting user is recorded as the inviter.
return this.membership.joinTripAsMember(tripId, targetUserId, actor);
const result = await this.membership.joinTripAsMember(tripId, targetUserId, actor);
// The open planners learn of the member as they do from REST and MCP. A
// plugin has no socket of its own, so the whole room hears it.
if (result.joined) {
const member = await this.roster.findMember(tripId, targetUserId);
if (member) this.realtime.broadcast(tripId, 'member:added', { member });
}
return result;
}
@PluginMethod('trips.removeMember', { permission: 'db:write:members' })
@@ -218,6 +225,7 @@ export class TripsRpc {
const ownerId = await this.em.getRepository(Trips).getOwnerId(tripId); // RP6 — TripsRepository.getOwnerId
if (ownerId !== null && ownerId === targetUserId) throw new ForbiddenResource('cannot remove the trip owner');
await this.roster.removeMember(tripId, targetUserId);
this.realtime.broadcast(tripId, 'member:removed', { userId: targetUserId });
return { removed: true };
}
}
+2 -2
View File
@@ -142,8 +142,8 @@ export interface GuestMember {
* the member rows, and create() defaults the currency to the owner's display
* currency instead of the legacy EUR literal. Auth (canAccessTrip), per-field permission checks and
* audit logging stay in the controller (1:1 with the legacy route);
* trip:updated / trip:deleted broadcasts stay in the controller too — this
* service emits none.
* trip:updated goes out from its callers, trip:deleted and the trips:changed ping
* from TripWritesService; this service emits none.
*/
@Injectable()
export class TripsService {