From 8e01dbb92fa47857aa5570023986a499416d75b1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 15:54:15 +0200 Subject: [PATCH] feat(contracts): todo, settings, collab and file routes declare their response schemas shared gains the response schemas these routes answer with (rows in their storage spelling, as the routes have always sent them) plus the common success and empty answers, all exported for the client to type its calls with. Every JSON handler in the four controllers carries its @ResponseContract; the file download, which streams, is marked exempt. The files e2e fixture table gains the note_id column the real one has. --- .../scripts/response-contract-baseline.json | 5 - server/src/nest/collab/collab.controller.ts | 35 +++++ .../nest/files/files-download.controller.ts | 1 + server/src/nest/files/files.controller.ts | 20 +++ .../src/nest/settings/settings.controller.ts | 14 +- server/src/nest/todo/todo.controller.ts | 15 ++ server/tests/e2e/files.e2e.test.ts | 2 +- shared/src/collab/collab.schema.ts | 141 ++++++++++++++++++ shared/src/common/primitives.schema.ts | 7 + shared/src/file/file.schema.ts | 68 +++++++++ shared/src/settings/settings.schema.ts | 32 ++++ shared/src/todo/todo.schema.ts | 45 ++++++ 12 files changed, 378 insertions(+), 7 deletions(-) diff --git a/server/scripts/response-contract-baseline.json b/server/scripts/response-contract-baseline.json index 963276f85..4c42663d1 100644 --- a/server/scripts/response-contract-baseline.json +++ b/server/scripts/response-contract-baseline.json @@ -15,7 +15,6 @@ "src/nest/backup/backup.controller.ts": 8, "src/nest/budget/budget.controller.ts": 16, "src/nest/categories/categories.controller.ts": 4, - "src/nest/collab/collab.controller.ts": 20, "src/nest/collections/collections.controller.ts": 40, "src/nest/config/config.controller.ts": 1, "src/nest/day-notes/day-notes.controller.ts": 4, @@ -23,8 +22,6 @@ "src/nest/doc-sync/doc-sync-webhook.controller.ts": 1, "src/nest/doc-sync/doc-sync.controller.ts": 15, "src/nest/feeds/feeds.controller.ts": 10, - "src/nest/files/files-download.controller.ts": 1, - "src/nest/files/files.controller.ts": 11, "src/nest/google-quota/google-quota.controller.ts": 2, "src/nest/health/features.controller.ts": 3, "src/nest/help/help.controller.ts": 4, @@ -89,12 +86,10 @@ "src/nest/roadtrip/roadtrip.controller.ts": 7, "src/nest/route-usage/route-usage.controller.ts": 3, "src/nest/school-holidays/school-holidays.controller.ts": 8, - "src/nest/settings/settings.controller.ts": 5, "src/nest/share/share.controller.ts": 5, "src/nest/storage/storage-admin.controller.ts": 8, "src/nest/system-notices/system-notices.controller.ts": 2, "src/nest/tags/tags.controller.ts": 4, - "src/nest/todo/todo.controller.ts": 7, "src/nest/tours/tours-import.controller.ts": 1, "src/nest/tours/tours.controller.ts": 4, "src/nest/transit/transit.controller.ts": 2, diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index fa0b3b818..5de0e85cb 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -37,6 +37,21 @@ import { CurrentUser } from '../auth-core/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants'; import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor'; +import { ResponseContract } from '../common/response-contract'; +import { + collabLinkPreviewResponseSchema, + collabLinkResponseSchema, + collabLinksResponseSchema, + collabMessageResponseSchema, + collabMessagesResponseSchema, + collabNoteFileResponseSchema, + collabNoteResponseSchema, + collabNotesResponseSchema, + collabPollResponseSchema, + collabPollsResponseSchema, + collabReactionsResponseSchema, + successResponseSchema, +} from '@trek/shared'; // Note attachments follow the same limit as the file manager (#1364). export const MAX_NOTE_FILE_SIZE = MAX_FILE_SIZE; @@ -128,6 +143,7 @@ export class CollabController { // ── Notes ─────────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('notes') + @ResponseContract(collabNotesResponseSchema) async listNotes(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { notes: await this.collab.listNotes(tripId) }; } @@ -135,6 +151,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('notes') + @ResponseContract(collabNoteResponseSchema) async createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) { const note = await this.collab.createNote(tripId, user.id, { title: body.title, @@ -151,6 +168,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('notes/:id') + @ResponseContract(collabNoteResponseSchema) async updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) { const note = await this.collab.updateNote(tripId, id, { title: body.title, @@ -170,6 +188,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('notes/:id') + @ResponseContract(successResponseSchema) async deleteNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { if (!(await this.collab.deleteNote(tripId, id))) { throw new HttpException({ error: 'Note not found' }, 404); @@ -180,6 +199,7 @@ export class CollabController { @Post('notes/:id/files') @UseInterceptors(FileInterceptor('file')) + @ResponseContract(collabNoteFileResponseSchema) async addNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { // multer has already written the upload to the spool dir by the time any of // these checks run, and nothing sweeps orphans, so every refusal takes the @@ -215,6 +235,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('notes/:id/files/:fileId') + @ResponseContract(successResponseSchema) async deleteNoteFile(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Param('fileId') fileId: string, @Headers('x-socket-id') socketId?: string) { if (!(await this.collab.deleteNoteFile(tripId, id, fileId))) { throw new HttpException({ error: 'File not found' }, 404); @@ -226,6 +247,7 @@ export class CollabController { // ── Shared links ──────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('links') + @ResponseContract(collabLinksResponseSchema) async listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { links: await this.collab.listLinks(tripId) }; } @@ -233,6 +255,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('links') + @ResponseContract(collabLinkResponseSchema) async createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) { const link = await this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) }); this.collab.broadcast(tripId, 'collab:link:created', { link }, socketId); @@ -242,6 +265,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('links/:id') + @ResponseContract(collabLinkResponseSchema) async updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) { const link = await this.collab.updateLink(tripId, id, { title: body.title, @@ -256,6 +280,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('links/:id') + @ResponseContract(successResponseSchema) async deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { if (!(await this.collab.deleteLink(tripId, id))) throw new HttpException({ error: 'Link not found' }, 404); this.collab.broadcast(tripId, 'collab:link:deleted', { linkId: Number(id) }, socketId); @@ -265,6 +290,7 @@ export class CollabController { // ── Polls ─────────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('polls') + @ResponseContract(collabPollsResponseSchema) async listPolls(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { polls: await this.collab.listPolls(tripId) }; } @@ -272,6 +298,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Post('polls') + @ResponseContract(collabPollResponseSchema) async createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) { const poll = await this.collab.createPoll(tripId, user.id, { question: body.question, @@ -288,6 +315,7 @@ export class CollabController { @RequirePermission('collab_edit') @Post('polls/:id/vote') @HttpCode(200) + @ResponseContract(collabPollResponseSchema) async votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) { const result = await this.collab.votePoll(tripId, id, user.id, body.option_index); if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404); @@ -300,6 +328,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Put('polls/:id/close') + @ResponseContract(collabPollResponseSchema) async closePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { const poll = await this.collab.closePoll(tripId, id); if (!poll) { @@ -312,6 +341,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('polls/:id') + @ResponseContract(successResponseSchema) async deletePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { if (!(await this.collab.deletePoll(tripId, id))) { throw new HttpException({ error: 'Poll not found' }, 404); @@ -323,6 +353,7 @@ export class CollabController { // ── Messages ──────────────────────────────────────────────────────────── @UseGuards(TripAccessGuard) @Get('messages') + @ResponseContract(collabMessagesResponseSchema) async listMessages(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('before') before?: string) { return { messages: await this.collab.listMessages(tripId, before) }; } @@ -340,6 +371,7 @@ export class CollabController { FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }), SpoolCleanupInterceptor, ) + @ResponseContract(collabMessageResponseSchema) async createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @UploadedFiles() files: Express.Multer.File[] | undefined, @Headers('x-socket-id') socketId?: string) { const uploaded = files || []; const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } }); @@ -398,6 +430,7 @@ export class CollabController { @RequirePermission('collab_edit') @Post('messages/:id/react') @HttpCode(200) + @ResponseContract(collabReactionsResponseSchema) async react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) { const result = await this.collab.reactMessage(id, tripId, user.id, body.emoji); if (!result.found) { @@ -410,6 +443,7 @@ export class CollabController { @UseGuards(TripAccessGuard) @RequirePermission('collab_edit') @Delete('messages/:id') + @ResponseContract(successResponseSchema) async deleteMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { const result = await this.collab.deleteMessage(tripId, id, user.id); if (result.error === 'not_found') throw new HttpException({ error: 'Message not found' }, 404); @@ -428,6 +462,7 @@ export class CollabController { // inside the service, not a write permission. @UseGuards(TripAccessGuard) @Get('link-preview') + @ResponseContract(collabLinkPreviewResponseSchema) async linkPreview(@CurrentUser() user: User, @Param('tripId') tripId: string, @Query('url') url?: string) { // Unlike the legacy route, this verifies trip access — any authed user // could otherwise drive the SSRF-guarded fetcher through arbitrary trip URLs. diff --git a/server/src/nest/files/files-download.controller.ts b/server/src/nest/files/files-download.controller.ts index 280da5c9b..6ce7a2d4d 100644 --- a/server/src/nest/files/files-download.controller.ts +++ b/server/src/nest/files/files-download.controller.ts @@ -24,6 +24,7 @@ export class FilesDownloadController { private readonly storage: StorageService, ) {} + // response-contract-exempt: streams the file through @Res(), there is no JSON body to check. @Get(':id/download') async download( @Req() req: Request, diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index 2d1c4812a..5e874e4fb 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -33,6 +33,15 @@ import { MAX_FILE_SIZE, BLOCKED_EXTENSIONS, isUploadTypeAllowed, isVideoExtensio import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto'; import { AllowedFileTypesService } from './allowed-file-types.service'; import { logError } from '../audit/audit-log.logger'; +import { ResponseContract } from '../common/response-contract'; +import { + fileLinkCreateResponseSchema, + fileLinksResponseSchema, + fileTrashEmptyResponseSchema, + successResponseSchema, + tripFileResponseSchema, + tripFilesResponseSchema, +} from '@trek/shared'; /** * Trip-file upload filter, built from the container. @@ -126,12 +135,14 @@ export class FilesController { @UseGuards(TripAccessGuard) @Get() + @ResponseContract(tripFilesResponseSchema) async list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) { return { files: await this.files.listFiles(tripId, trash === 'true') }; } @Post() @UseInterceptors(FileInterceptor('file')) + @ResponseContract(tripFileResponseSchema) async upload( @CurrentUser() user: User, @Param('tripId') tripId: string, @@ -197,6 +208,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Put(':id') + @ResponseContract(tripFileResponseSchema) 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); @@ -218,6 +230,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Patch(':id/star') + @ResponseContract(tripFileResponseSchema) 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); @@ -233,6 +246,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete('trash/empty') + @ResponseContract(fileTrashEmptyResponseSchema) async emptyTrash(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string) { if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); @@ -243,6 +257,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id/permanent') + @ResponseContract(successResponseSchema) async permanent(@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); @@ -258,6 +273,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id') + @ResponseContract(successResponseSchema) 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); @@ -274,6 +290,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Post(':id/restore') @HttpCode(200) // Express answers restore with res.json (200), not the POST-default 201. + @ResponseContract(tripFileResponseSchema) 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); @@ -290,6 +307,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Post(':id/link') @HttpCode(200) // Express answers link with res.json (200). + @ResponseContract(fileLinkCreateResponseSchema) 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); @@ -305,6 +323,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Delete(':id/link/:linkId') + @ResponseContract(successResponseSchema) 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); @@ -322,6 +341,7 @@ export class FilesController { @UseGuards(TripAccessGuard) @Get(':id/links') + @ResponseContract(fileLinksResponseSchema) async links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) { const file = await this.files.getFileById(id, tripId); if (!file) { diff --git a/server/src/nest/settings/settings.controller.ts b/server/src/nest/settings/settings.controller.ts index 3eb5628f8..456ee4b9f 100644 --- a/server/src/nest/settings/settings.controller.ts +++ b/server/src/nest/settings/settings.controller.ts @@ -1,6 +1,12 @@ import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; -import { MASKED_SETTING_VALUE } from '@trek/shared'; +import { + MASKED_SETTING_VALUE, + adminDefaultUserSettingsResponseSchema, + settingUpsertResponseSchema, + settingsBulkResponseSchema, + settingsListResponseSchema, +} from '@trek/shared'; import type { User } from '../../types'; import { SettingsService, isAdminOnlyEndpointSetting } from './settings.service'; import { AdminDefaultUserSettingsDto, SettingUpsertDto, SettingsBulkDto } from './settings.dto'; @@ -11,6 +17,7 @@ import { getClientIp } from '../audit/client-ip'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { isManagedLockedKey, splitManagedKeys } from '../common/managed'; +import { ResponseContract } from '../common/response-contract'; /** * /api/settings — per-user key/value preferences: get-all, single upsert @@ -54,11 +61,13 @@ export class SettingsController { } @Get() + @ResponseContract(settingsListResponseSchema) async list(@CurrentUser() user: User) { return { settings: await this.settings.getUserSettings(user.id) }; } @Put() + @ResponseContract(settingUpsertResponseSchema) async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) { this.assertMayWriteInstanceEndpoint({ [body.key]: body.value }); // assertMayWriteInstanceEndpoint only covers llm_base_url and provider 'local'. @@ -77,6 +86,7 @@ export class SettingsController { @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) { this.assertMayWriteInstanceEndpoint(body.settings); const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged()); @@ -107,11 +117,13 @@ export class AdminDefaultUserSettingsController { ) {} @Get() + @ResponseContract(adminDefaultUserSettingsResponseSchema) async get() { return this.settings.getAdminUserDefaults(); } @Put() + @ResponseContract(adminDefaultUserSettingsResponseSchema) async update(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) { try { // Deliberately no managed_keys in the response here: the route answers diff --git a/server/src/nest/todo/todo.controller.ts b/server/src/nest/todo/todo.controller.ts index 4c7216fe7..c75d7416a 100644 --- a/server/src/nest/todo/todo.controller.ts +++ b/server/src/nest/todo/todo.controller.ts @@ -17,6 +17,14 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { toRowId } from '../common/row-id'; +import { ResponseContract } from '../common/response-contract'; +import { + successResponseSchema, + todoCategoryAssigneesResponseSchema, + todoCategoryAssigneesUpdateResponseSchema, + todoItemResponseSchema, + todoListResponseSchema, +} from '@trek/shared'; /** * /api/trips/:tripId/todo — trip-scoped task list. @@ -40,12 +48,14 @@ export class TodoController { @Get() + @ResponseContract(todoListResponseSchema) async list(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { items: await this.todo.listItems(tripId) }; } @RequirePermission('packing_edit') @Post() + @ResponseContract(todoItemResponseSchema) async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @@ -60,6 +70,7 @@ export class TodoController { @RequirePermission('packing_edit') @Put('reorder') + @ResponseContract(successResponseSchema) async reorder( @CurrentUser() user: User, @Param('tripId') tripId: string, @@ -71,6 +82,7 @@ export class TodoController { @RequirePermission('packing_edit') @Put(':id') + @ResponseContract(todoItemResponseSchema) async update( @CurrentUser() user: User, @Param('tripId') tripId: string, @@ -105,6 +117,7 @@ export class TodoController { @RequirePermission('packing_edit') @Delete(':id') + @ResponseContract(successResponseSchema) async remove( @CurrentUser() user: User, @Param('tripId') tripId: string, @@ -124,12 +137,14 @@ export class TodoController { } @Get('category-assignees') + @ResponseContract(todoCategoryAssigneesResponseSchema) async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) { return { assignees: await this.todo.getCategoryAssignees(tripId) }; } @RequirePermission('packing_edit') @Put('category-assignees/:categoryName') + @ResponseContract(todoCategoryAssigneesUpdateResponseSchema) async updateCategoryAssignees( @CurrentUser() user: User, @Param('tripId') tripId: string, diff --git a/server/tests/e2e/files.e2e.test.ts b/server/tests/e2e/files.e2e.test.ts index 99ad72f98..dba0ef791 100644 --- a/server/tests/e2e/files.e2e.test.ts +++ b/server/tests/e2e/files.e2e.test.ts @@ -36,7 +36,7 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE trip_files (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, place_id INTEGER, reservation_id INTEGER, message_id INTEGER, filename TEXT NOT NULL, original_name TEXT NOT NULL, file_size INTEGER, mime_type TEXT, description TEXT, - uploaded_by INTEGER, starred INTEGER DEFAULT 0, + note_id INTEGER, uploaded_by INTEGER, starred INTEGER DEFAULT 0, deleted_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); tmp.exec(`CREATE TABLE file_links (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL, reservation_id INTEGER, assignment_id INTEGER, place_id INTEGER, budget_item_id INTEGER, diff --git a/shared/src/collab/collab.schema.ts b/shared/src/collab/collab.schema.ts index 7d9a2a497..bf7df652f 100644 --- a/shared/src/collab/collab.schema.ts +++ b/shared/src/collab/collab.schema.ts @@ -81,3 +81,144 @@ export const collabReactionRequestSchema = z.object({ emoji: z.string().min(1), }); export type CollabReactionRequest = z.infer; + +// ── Responses ─────────────────────────────────────────────────────────────── +// Rows keep SQLite's storage spelling (0/1 flags, TEXT times), as the routes +// have always answered; the derived booleans the service adds are booleans. + +/** A file attached to a note or a message, with the download route the client links to. */ +export const collabAttachmentSchema = z.object({ + id: z.number(), + filename: z.string(), + original_name: z.string(), + file_size: z.number().nullable(), + mime_type: z.string().nullable(), + url: z.string(), +}); +export type CollabAttachment = z.infer; + +/** A collab note with its author and attachments. */ +export const collabNoteSchema = z.object({ + id: z.number(), + trip_id: z.number(), + user_id: z.number(), + category: z.string().nullable(), + title: z.string(), + content: z.string().nullable(), + color: z.string().nullable(), + pinned: z.number().nullable(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + website: z.string().nullable(), + username: z.string(), + avatar: z.string().nullable(), + avatar_url: z.string().nullable(), + attachments: z.array(collabAttachmentSchema), +}); +export type CollabNote = z.infer; + +export const collabNotesResponseSchema = z.object({ notes: z.array(collabNoteSchema) }); +export type CollabNotesResponse = z.infer; +export const collabNoteResponseSchema = z.object({ note: collabNoteSchema }); +export type CollabNoteResponse = z.infer; +/** POST notes/:id/files */ +export const collabNoteFileResponseSchema = z.object({ file: collabAttachmentSchema }); +export type CollabNoteFileResponse = z.infer; + +/** A shared link with the name of whoever added it. */ +export const collabLinkSchema = z.object({ + id: z.number(), + trip_id: z.number(), + user_id: z.number(), + title: z.string(), + url: z.string(), + pinned: z.number().nullable(), + created_at: z.string().nullable(), + updated_at: z.string().nullable(), + username: z.string(), +}); +export type CollabLink = z.infer; + +export const collabLinksResponseSchema = z.object({ links: z.array(collabLinkSchema) }); +export type CollabLinksResponse = z.infer; +export const collabLinkResponseSchema = z.object({ link: collabLinkSchema }); +export type CollabLinkResponse = z.infer; + +/** Someone who voted for a poll option. */ +export const collabPollVoterSchema = z.object({ + id: z.number(), + user_id: z.number(), + username: z.string(), + avatar: z.string().nullable(), + avatar_url: z.string().nullable(), +}); + +/** A poll with its options and their voters. `options` is replaced by the formatted list; the stored JSON never leaves. */ +export const collabPollSchema = z.object({ + id: z.number(), + trip_id: z.number(), + user_id: z.number(), + question: z.string(), + options: z.array(z.object({ text: z.unknown(), label: z.unknown(), voters: z.array(collabPollVoterSchema) })), + multiple: z.number().nullable(), + closed: z.number().nullable(), + deadline: z.string().nullable(), + created_at: z.string().nullable(), + username: z.string(), + avatar: z.string().nullable(), + avatar_url: z.string().nullable(), + is_closed: z.boolean(), + multiple_choice: z.boolean(), +}); +export type CollabPoll = z.infer; + +export const collabPollsResponseSchema = z.object({ polls: z.array(collabPollSchema) }); +export type CollabPollsResponse = z.infer; +export const collabPollResponseSchema = z.object({ poll: collabPollSchema }); +export type CollabPollResponse = z.infer; + +/** One emoji on a message, with who reacted. */ +export const collabReactionSchema = z.object({ + emoji: z.string(), + users: z.array(z.object({ user_id: z.number(), username: z.string() })), + count: z.number(), +}); +export type CollabReaction = z.infer; + +/** A chat message with its author, the message it replies to, reactions and attachments. A deleted one keeps its row with empty text. */ +export const collabMessageSchema = z.object({ + id: z.number(), + trip_id: z.number(), + user_id: z.number(), + text: z.string(), + reply_to: z.number().nullable(), + created_at: z.string().nullable(), + deleted: z.number().nullable(), + username: z.string(), + avatar: z.string().nullable(), + reply_text: z.string().nullable(), + reply_username: z.string().nullable(), + user_avatar: z.string().nullable(), + avatar_url: z.string().nullable(), + reactions: z.array(collabReactionSchema), + attachments: z.array(collabAttachmentSchema), +}); +export type CollabMessage = z.infer; + +export const collabMessagesResponseSchema = z.object({ messages: z.array(collabMessageSchema) }); +export type CollabMessagesResponse = z.infer; +export const collabMessageResponseSchema = z.object({ message: collabMessageSchema }); +export type CollabMessageResponse = z.infer; +/** POST messages/:id/react: the message's reactions after the toggle. */ +export const collabReactionsResponseSchema = z.object({ reactions: z.array(collabReactionSchema) }); +export type CollabReactionsResponse = z.infer; + +/** GET link-preview: what the page's Open Graph tags (or its title) say; nulls when nothing could be read. */ +export const collabLinkPreviewResponseSchema = z.object({ + title: z.string().nullable(), + description: z.string().nullable(), + image: z.string().nullable(), + site_name: z.string().nullable().optional(), + url: z.string(), +}); +export type CollabLinkPreviewResponse = z.infer; diff --git a/shared/src/common/primitives.schema.ts b/shared/src/common/primitives.schema.ts index 6fddfd295..464e897a5 100644 --- a/shared/src/common/primitives.schema.ts +++ b/shared/src/common/primitives.schema.ts @@ -20,3 +20,10 @@ export const nonEmptyString = z.string().trim().min(1); /** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */ export const isoDateTime = z.string().datetime({ offset: true }); + +/** The `{ success: true }` body a write answers with when it has nothing else to say. */ +export const successResponseSchema = z.object({ success: z.literal(true) }); +export type SuccessResponse = z.infer; + +/** A 204 answer: the handler returns nothing, so no body goes out. */ +export const emptyResponseSchema = z.undefined(); diff --git a/shared/src/file/file.schema.ts b/shared/src/file/file.schema.ts index 7dcb4a4fd..a4f7d3ffd 100644 --- a/shared/src/file/file.schema.ts +++ b/shared/src/file/file.schema.ts @@ -45,3 +45,71 @@ export type FileLinkRequest = z.infer; /** Variants the photo streaming endpoints accept. */ export const photoVariantSchema = z.enum(['thumbnail', 'original']); export type PhotoVariant = z.infer; + +// ── Responses ─────────────────────────────────────────────────────────────── + +/** A trip_files row as the file routes return it: the stored columns, the joins, and the download URL. */ +export const tripFileSchema = z.object({ + id: z.number(), + trip_id: z.number(), + place_id: z.number().nullable(), + reservation_id: z.number().nullable(), + filename: z.string(), + original_name: z.string(), + file_size: z.number().nullable(), + mime_type: z.string().nullable(), + description: z.string().nullable(), + created_at: z.string().nullable(), + note_id: z.number().nullable(), + uploaded_by: z.number().nullable(), + starred: z.number().nullable(), + deleted_at: z.string().nullable(), + message_id: z.number().nullable(), + reservation_title: z.string().nullable(), + uploaded_by_name: z.string().nullable(), + uploaded_by_avatar: z.string().nullable(), + url: z.string(), +}); +export type TripFile = z.infer; + +/** A file in the list, with the ids of what it is linked to. */ +export const tripFileListItemSchema = tripFileSchema.extend({ + linked_reservation_ids: z.array(z.number()), + linked_place_ids: z.array(z.number()), + linked_budget_item_ids: z.array(z.number()), +}); +export type TripFileListItem = z.infer; + +/** GET /files */ +export const tripFilesResponseSchema = z.object({ files: z.array(tripFileListItemSchema) }); +export type TripFilesResponse = z.infer; + +/** POST /files, PUT /files/:id, PATCH :id/star, POST :id/restore */ +export const tripFileResponseSchema = z.object({ file: tripFileSchema }); +export type TripFileResponse = z.infer; + +/** A file_links row. */ +export const fileLinkSchema = z.object({ + id: z.number(), + file_id: z.number(), + reservation_id: z.number().nullable(), + assignment_id: z.number().nullable(), + place_id: z.number().nullable(), + created_at: z.string().nullable(), + budget_item_id: z.number().nullable(), +}); +export type FileLink = z.infer; + +/** POST :id/link: the file's links after the write. */ +export const fileLinkCreateResponseSchema = z.object({ success: z.literal(true), links: z.array(fileLinkSchema) }); +export type FileLinkCreateResponse = z.infer; + +/** GET :id/links: the file's links with the linked reservation's title. */ +export const fileLinksResponseSchema = z.object({ + links: z.array(fileLinkSchema.extend({ reservation_title: z.string().nullable() })), +}); +export type FileLinksResponse = z.infer; + +/** DELETE trash/empty: how many files were removed for good. */ +export const fileTrashEmptyResponseSchema = z.object({ success: z.literal(true), deleted: z.number() }); +export type FileTrashEmptyResponse = z.infer; diff --git a/shared/src/settings/settings.schema.ts b/shared/src/settings/settings.schema.ts index 1fef1b9fc..0bd96b1e6 100644 --- a/shared/src/settings/settings.schema.ts +++ b/shared/src/settings/settings.schema.ts @@ -34,3 +34,35 @@ export const weekStartSchema = z.enum(WEEK_START_VALUES); export const DEFAULT_WEEK_START: WeekStart = 'monday'; /** Date#getDay() of each value, the number a calendar grid computes with. */ export const WEEK_START_DAY: Record = { monday: 1, sunday: 0, saturday: 6 }; + +// ── Responses ─────────────────────────────────────────────────────────────── + +/** GET /api/settings: the user's preferences by key, each value as getUserSettings parsed it (secrets masked). */ +export const settingsListResponseSchema = z.object({ settings: z.record(z.string(), z.unknown()) }); +export type SettingsListResponse = z.infer; + +/** + * PUT /api/settings: the key echoed back with what happened to it. `value` when it was + * written, `unchanged` when the masked secret came back as it was, `managed` when a + * managed install keeps the key. + */ +export const settingUpsertResponseSchema = z.object({ + success: z.literal(true), + key: z.string(), + value: z.unknown().optional(), + unchanged: z.literal(true).optional(), + managed: z.literal(true).optional(), +}); +export type SettingUpsertResponse = z.infer; + +/** POST /api/settings/bulk: how many keys were written, and the managed keys left alone (only when there were any). */ +export const settingsBulkResponseSchema = z.object({ + success: z.literal(true), + updated: z.number(), + managed_keys: z.array(z.string()).optional(), +}); +export type SettingsBulkResponse = z.infer; + +/** GET/PUT /api/admin/default-user-settings: the stored defaults a new account starts with, by key. */ +export const adminDefaultUserSettingsResponseSchema = z.record(z.string(), z.unknown()); +export type AdminDefaultUserSettingsResponse = z.infer; diff --git a/shared/src/todo/todo.schema.ts b/shared/src/todo/todo.schema.ts index e8e68bad0..ede39d89d 100644 --- a/shared/src/todo/todo.schema.ts +++ b/shared/src/todo/todo.schema.ts @@ -45,3 +45,48 @@ export const todoCategoryAssigneesRequestSchema = z.object({ user_ids: z.array(z.number()), }); export type TodoCategoryAssigneesRequest = z.infer; + +// ── Responses ─────────────────────────────────────────────────────────────── + +/** A todo_items row as the routes return it (`SELECT *`); flags and times stay as SQLite stores them. */ +export const todoItemSchema = z.object({ + id: z.number(), + trip_id: z.number(), + name: z.string(), + checked: z.number().nullable(), + category: z.string().nullable(), + sort_order: z.number().nullable(), + due_date: z.string().nullable(), + description: z.string().nullable(), + assigned_user_id: z.number().nullable(), + priority: z.number().nullable(), + created_at: z.string().nullable(), + reminded_at: z.string().nullable(), +}); +export type TodoItem = z.infer; + +/** GET /todo */ +export const todoListResponseSchema = z.object({ items: z.array(todoItemSchema) }); +export type TodoListResponse = z.infer; + +/** POST /todo, PUT /todo/:id */ +export const todoItemResponseSchema = z.object({ item: todoItemSchema }); +export type TodoItemResponse = z.infer; + +/** A person assigned to a todo category. */ +export const todoCategoryAssigneeSchema = z.object({ + user_id: z.number(), + username: z.string(), + avatar: z.string().nullable(), +}); +export type TodoCategoryAssignee = z.infer; + +/** GET /todo/category-assignees: the assignees keyed by category name. */ +export const todoCategoryAssigneesResponseSchema = z.object({ + assignees: z.record(z.string(), z.array(todoCategoryAssigneeSchema)), +}); +export type TodoCategoryAssigneesResponse = z.infer; + +/** PUT /todo/category-assignees/:categoryName: the category's assignees after the write. */ +export const todoCategoryAssigneesUpdateResponseSchema = z.object({ assignees: z.array(todoCategoryAssigneeSchema) }); +export type TodoCategoryAssigneesUpdateResponse = z.infer;