mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-07 21:16:44 +00:00
feat(server): adopt zod DTOs for the collab body contracts
This commit is contained in:
@@ -21,6 +21,14 @@ import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { User } from '../../types';
|
||||
import { CollabService } from './collab.service';
|
||||
import {
|
||||
CollabNoteCreateDto,
|
||||
CollabNoteUpdateDto,
|
||||
CollabPollCreateDto,
|
||||
CollabPollVoteDto,
|
||||
CollabMessageCreateDto,
|
||||
CollabReactionDto,
|
||||
} from './collab.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
|
||||
@@ -53,7 +61,12 @@ const NOTE_UPLOAD = {
|
||||
* access (404), 'collab_edit' (403) on mutations + 'file_upload' on note files,
|
||||
* create 201 / rest 200 (vote + react POST stay 200), the bespoke 400/403/404
|
||||
* bodies, the chat/note notifications, and all WebSocket broadcasts with the
|
||||
* forwarded X-Socket-Id.
|
||||
* forwarded X-Socket-Id. Bodies validate against the @trek/shared collab
|
||||
* schemas via the DTO classes in collab.dto.ts + the global ZodValidationPipe
|
||||
* (400 with the standard `{ error }` envelope on mismatch — this replaced the
|
||||
* legacy bespoke 'Title is required' / 'Question is required' / '... 2 options
|
||||
* ...' / 5000-char / 'Emoji is required' checks; the whitespace-only 'Message
|
||||
* text is required' check stays, since min(1) doesn't trim).
|
||||
*/
|
||||
@Controller('api/trips/:tripId/collab')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -82,12 +95,9 @@ export class CollabController {
|
||||
}
|
||||
|
||||
@Post('notes')
|
||||
createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { title?: string; content?: string; category?: string; color?: string; website?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
createNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabNoteCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.title) {
|
||||
throw new HttpException({ error: 'Title is required' }, 400);
|
||||
}
|
||||
const note = this.collab.createNote(tripId, user.id, {
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
@@ -101,7 +111,7 @@ export class CollabController {
|
||||
}
|
||||
|
||||
@Put('notes/:id')
|
||||
updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
updateNote(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabNoteUpdateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const note = this.collab.updateNote(tripId, id, {
|
||||
@@ -167,15 +177,9 @@ export class CollabController {
|
||||
}
|
||||
|
||||
@Post('polls')
|
||||
createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { question?: string; options?: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }, @Headers('x-socket-id') socketId?: string) {
|
||||
createPoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabPollCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.question) {
|
||||
throw new HttpException({ error: 'Question is required' }, 400);
|
||||
}
|
||||
if (!Array.isArray(body.options) || body.options.length < 2) {
|
||||
throw new HttpException({ error: 'At least 2 options are required' }, 400);
|
||||
}
|
||||
const poll = this.collab.createPoll(tripId, user.id, {
|
||||
question: body.question,
|
||||
options: body.options,
|
||||
@@ -189,10 +193,10 @@ export class CollabController {
|
||||
|
||||
@Post('polls/:id/vote')
|
||||
@HttpCode(200)
|
||||
votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body('option_index') optionIndex: number, @Headers('x-socket-id') socketId?: string) {
|
||||
votePoll(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabPollVoteDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const result = this.collab.votePoll(tripId, id, user.id, optionIndex);
|
||||
const result = this.collab.votePoll(tripId, id, user.id, body.option_index);
|
||||
if (result.error === 'not_found') throw new HttpException({ error: 'Poll not found' }, 404);
|
||||
if (result.error === 'closed') throw new HttpException({ error: 'Poll is closed' }, 400);
|
||||
if (result.error === 'invalid_index') throw new HttpException({ error: 'Invalid option index' }, 400);
|
||||
@@ -231,13 +235,13 @@ export class CollabController {
|
||||
}
|
||||
|
||||
@Post('messages')
|
||||
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: { text?: string; reply_to?: number | null }, @Headers('x-socket-id') socketId?: string) {
|
||||
if (body.text && body.text.length > 5000) {
|
||||
throw new HttpException({ error: 'text must be 5000 characters or less' }, 400);
|
||||
}
|
||||
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
// The pipe's min(1)/max(5000) replaced the bespoke length checks (and still
|
||||
// rejects before the trip-access check, like the legacy pre-access check
|
||||
// did); min(1) doesn't trim, so whitespace-only text keeps its bespoke 400.
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!body.text || !body.text.trim()) {
|
||||
if (!body.text.trim()) {
|
||||
throw new HttpException({ error: 'Message text is required' }, 400);
|
||||
}
|
||||
const result = this.collab.createMessage(tripId, user.id, body.text, body.reply_to);
|
||||
@@ -252,13 +256,10 @@ export class CollabController {
|
||||
|
||||
@Post('messages/:id/react')
|
||||
@HttpCode(200)
|
||||
react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body('emoji') emoji: string, @Headers('x-socket-id') socketId?: string) {
|
||||
react(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabReactionDto, @Headers('x-socket-id') socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!emoji) {
|
||||
throw new HttpException({ error: 'Emoji is required' }, 400);
|
||||
}
|
||||
const result = this.collab.reactMessage(id, tripId, user.id, emoji);
|
||||
const result = this.collab.reactMessage(id, tripId, user.id, body.emoji);
|
||||
if (!result.found) {
|
||||
throw new HttpException({ error: 'Message not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
collabNoteCreateRequestSchema,
|
||||
collabNoteUpdateRequestSchema,
|
||||
collabPollCreateRequestSchema,
|
||||
collabPollVoteRequestSchema,
|
||||
collabMessageCreateRequestSchema,
|
||||
collabReactionRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Body DTOs for the collab controller. The global ZodValidationPipe (APP_PIPE
|
||||
* in app.module.ts) validates any @Body() typed with these by metatype; the
|
||||
* shared Zod schemas remain the single source of truth for the wire contract.
|
||||
*/
|
||||
export class CollabNoteCreateDto extends createZodDto(collabNoteCreateRequestSchema) {}
|
||||
export class CollabNoteUpdateDto extends createZodDto(collabNoteUpdateRequestSchema) {}
|
||||
export class CollabPollCreateDto extends createZodDto(collabPollCreateRequestSchema) {}
|
||||
export class CollabPollVoteDto extends createZodDto(collabPollVoteRequestSchema) {}
|
||||
export class CollabMessageCreateDto extends createZodDto(collabMessageCreateRequestSchema) {}
|
||||
export class CollabReactionDto extends createZodDto(collabReactionRequestSchema) {}
|
||||
@@ -62,12 +62,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
|
||||
'BudgetController.updateSettlement',
|
||||
'CategoriesController.create',
|
||||
'CategoriesController.update',
|
||||
'CollabController.createMessage',
|
||||
'CollabController.createNote',
|
||||
'CollabController.createPoll',
|
||||
'CollabController.react',
|
||||
'CollabController.updateNote',
|
||||
'CollabController.votePoll',
|
||||
'CollectionsController.deleteMany',
|
||||
'CollectionsController.reorder',
|
||||
'DaysController.create',
|
||||
|
||||
@@ -662,7 +662,9 @@ describe('Collab validation', () => {
|
||||
.send({ question: 'Only one option?', options: ['Option A'] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/2 options/i);
|
||||
// The Zod pipe's standard envelope replaced the bespoke 'At least 2 options
|
||||
// are required' string when the collab bodies adopted DTOs.
|
||||
expect(res.body.error).toMatch(/options/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,9 +46,8 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
expect(new CollabController(s).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST 403 without collab_edit, 400 without title, else creates + broadcasts + notifies', () => {
|
||||
it('POST 403 without collab_edit, else creates + broadcasts + notifies (empty title now 400s in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) })).createNote(user, '5', { title: 'T' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new CollabController(svc()).createNote(user, '5', {}))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
const createNote = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
@@ -96,20 +95,18 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
});
|
||||
|
||||
describe('polls', () => {
|
||||
it('POST 400 without question / <2 options, else creates', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', {}))).toEqual({ status: 400, body: { error: 'Question is required' } });
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', { question: 'q', options: ['only'] }))).toEqual({ status: 400, body: { error: 'At least 2 options are required' } });
|
||||
it('POST creates (missing question / <2 options now 400 in the Zod pipe)', () => {
|
||||
const s = svc({ createPoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', () => {
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'closed' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 400, body: { error: 'Poll is closed' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'invalid_index' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 9))).toEqual({ status: 400, body: { error: 'Invalid option index' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'closed' }) } as Partial<CollabService>)).votePoll(user, '5', '7', { option_index: 0 }))).toEqual({ status: 400, body: { error: 'Poll is closed' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'invalid_index' }) } as Partial<CollabService>)).votePoll(user, '5', '7', { option_index: 9 }))).toEqual({ status: 400, body: { error: 'Invalid option index' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ votePoll: vi.fn().mockReturnValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).votePoll(user, '5', '7', 0, 'sock')).toEqual({ poll: { id: 7 } });
|
||||
expect(new CollabController(s).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({ poll: { id: 7 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
@@ -127,8 +124,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('POST 400 over 5000 chars (before access), 400 empty, 400 reply_not_found, else creates + notifies', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: 'x'.repeat(5001) }))).toEqual({ status: 400, body: { error: 'text must be 5000 characters or less' } });
|
||||
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial<CollabService>)).createMessage(user, '5', { text: 'hi', reply_to: 99 }))).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
@@ -139,12 +135,11 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user, 'hello');
|
||||
});
|
||||
|
||||
it('react 400 without emoji, 404 unknown, else broadcasts reactions', () => {
|
||||
expect(thrown(() => new CollabController(svc()).react(user, '5', '3', ''))).toEqual({ status: 400, body: { error: 'Emoji is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial<CollabService>)).react(user, '5', '3', '👍'))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial<CollabService>)).react(user, '5', '3', { emoji: '👍' }))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ reactMessage: vi.fn().mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).react(user, '5', '3', '👍', 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
|
||||
expect(new CollabController(s).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:reacted', { messageId: 3, reactions: [{ emoji: '👍', count: 1 }] }, 'sock');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user