mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,3 +81,144 @@ export const collabReactionRequestSchema = z.object({
|
||||
emoji: z.string().min(1),
|
||||
});
|
||||
export type CollabReactionRequest = z.infer<typeof collabReactionRequestSchema>;
|
||||
|
||||
// ── 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<typeof collabAttachmentSchema>;
|
||||
|
||||
/** 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<typeof collabNoteSchema>;
|
||||
|
||||
export const collabNotesResponseSchema = z.object({ notes: z.array(collabNoteSchema) });
|
||||
export type CollabNotesResponse = z.infer<typeof collabNotesResponseSchema>;
|
||||
export const collabNoteResponseSchema = z.object({ note: collabNoteSchema });
|
||||
export type CollabNoteResponse = z.infer<typeof collabNoteResponseSchema>;
|
||||
/** POST notes/:id/files */
|
||||
export const collabNoteFileResponseSchema = z.object({ file: collabAttachmentSchema });
|
||||
export type CollabNoteFileResponse = z.infer<typeof collabNoteFileResponseSchema>;
|
||||
|
||||
/** 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<typeof collabLinkSchema>;
|
||||
|
||||
export const collabLinksResponseSchema = z.object({ links: z.array(collabLinkSchema) });
|
||||
export type CollabLinksResponse = z.infer<typeof collabLinksResponseSchema>;
|
||||
export const collabLinkResponseSchema = z.object({ link: collabLinkSchema });
|
||||
export type CollabLinkResponse = z.infer<typeof collabLinkResponseSchema>;
|
||||
|
||||
/** 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<typeof collabPollSchema>;
|
||||
|
||||
export const collabPollsResponseSchema = z.object({ polls: z.array(collabPollSchema) });
|
||||
export type CollabPollsResponse = z.infer<typeof collabPollsResponseSchema>;
|
||||
export const collabPollResponseSchema = z.object({ poll: collabPollSchema });
|
||||
export type CollabPollResponse = z.infer<typeof collabPollResponseSchema>;
|
||||
|
||||
/** 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<typeof collabReactionSchema>;
|
||||
|
||||
/** 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<typeof collabMessageSchema>;
|
||||
|
||||
export const collabMessagesResponseSchema = z.object({ messages: z.array(collabMessageSchema) });
|
||||
export type CollabMessagesResponse = z.infer<typeof collabMessagesResponseSchema>;
|
||||
export const collabMessageResponseSchema = z.object({ message: collabMessageSchema });
|
||||
export type CollabMessageResponse = z.infer<typeof collabMessageResponseSchema>;
|
||||
/** 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<typeof collabReactionsResponseSchema>;
|
||||
|
||||
/** 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<typeof collabLinkPreviewResponseSchema>;
|
||||
|
||||
@@ -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<typeof successResponseSchema>;
|
||||
|
||||
/** A 204 answer: the handler returns nothing, so no body goes out. */
|
||||
export const emptyResponseSchema = z.undefined();
|
||||
|
||||
@@ -45,3 +45,71 @@ export type FileLinkRequest = z.infer<typeof fileLinkRequestSchema>;
|
||||
/** Variants the photo streaming endpoints accept. */
|
||||
export const photoVariantSchema = z.enum(['thumbnail', 'original']);
|
||||
export type PhotoVariant = z.infer<typeof photoVariantSchema>;
|
||||
|
||||
// ── 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<typeof tripFileSchema>;
|
||||
|
||||
/** 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<typeof tripFileListItemSchema>;
|
||||
|
||||
/** GET /files */
|
||||
export const tripFilesResponseSchema = z.object({ files: z.array(tripFileListItemSchema) });
|
||||
export type TripFilesResponse = z.infer<typeof tripFilesResponseSchema>;
|
||||
|
||||
/** POST /files, PUT /files/:id, PATCH :id/star, POST :id/restore */
|
||||
export const tripFileResponseSchema = z.object({ file: tripFileSchema });
|
||||
export type TripFileResponse = z.infer<typeof tripFileResponseSchema>;
|
||||
|
||||
/** 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<typeof fileLinkSchema>;
|
||||
|
||||
/** 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<typeof fileLinkCreateResponseSchema>;
|
||||
|
||||
/** 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<typeof fileLinksResponseSchema>;
|
||||
|
||||
/** 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<typeof fileTrashEmptyResponseSchema>;
|
||||
|
||||
@@ -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<WeekStart, number> = { 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<typeof settingsListResponseSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof settingUpsertResponseSchema>;
|
||||
|
||||
/** 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<typeof settingsBulkResponseSchema>;
|
||||
|
||||
/** 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<typeof adminDefaultUserSettingsResponseSchema>;
|
||||
|
||||
@@ -45,3 +45,48 @@ export const todoCategoryAssigneesRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type TodoCategoryAssigneesRequest = z.infer<typeof todoCategoryAssigneesRequestSchema>;
|
||||
|
||||
// ── 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<typeof todoItemSchema>;
|
||||
|
||||
/** GET /todo */
|
||||
export const todoListResponseSchema = z.object({ items: z.array(todoItemSchema) });
|
||||
export type TodoListResponse = z.infer<typeof todoListResponseSchema>;
|
||||
|
||||
/** POST /todo, PUT /todo/:id */
|
||||
export const todoItemResponseSchema = z.object({ item: todoItemSchema });
|
||||
export type TodoItemResponse = z.infer<typeof todoItemResponseSchema>;
|
||||
|
||||
/** 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<typeof todoCategoryAssigneeSchema>;
|
||||
|
||||
/** 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<typeof todoCategoryAssigneesResponseSchema>;
|
||||
|
||||
/** 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<typeof todoCategoryAssigneesUpdateResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user