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;