File size: 4,370 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import {
  Body,
  Controller,
  Delete,
  Get,
  Headers,
  HttpException,
  Param,
  Post,
  Put,
  UseGuards,
} from '@nestjs/common';
import type { User } from '../../types';
import { DayNotesService } from './day-notes.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';

type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };

// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
// which runs BEFORE the trip-access check — so an over-long field 400s first.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };

function validateLengths(body: Record<string, unknown>): void {
  for (const [field, max] of Object.entries(MAX_LENGTHS)) {
    const value = body[field];
    if (value && typeof value === 'string' && value.length > max) {
      throw new HttpException({ error: `${field} must be ${max} characters or less` }, 400);
    }
  }
}

/**
 * /api/trips/:tripId/days/:dayId/notes — free-text annotations on a day.
 *
 * Byte-identical to the legacy Express route (server/src/routes/dayNotes.ts):
 * the string-length guard runs first (400), then trip access (404), then the
 * 'day_edit' permission (403); create 201 / rest 200; the bespoke "Day not
 * found" / "Note not found" / "Text required" bodies; WebSocket broadcasts with
 * the forwarded X-Socket-Id.
 */
@Controller('api/trips/:tripId/days/:dayId/notes')
@UseGuards(JwtAuthGuard)
export class DayNotesController {
  constructor(private readonly notes: DayNotesService) {}

  private requireTrip(tripId: string, user: User) {
    const trip = this.notes.verifyTripAccess(tripId, user.id);
    if (!trip) {
      throw new HttpException({ error: 'Trip not found' }, 404);
    }
    return trip;
  }

  private requireEdit(trip: NonNullable<ReturnType<DayNotesService['verifyTripAccess']>>, user: User): void {
    if (!this.notes.canEdit(trip, user)) {
      throw new HttpException({ error: 'No permission' }, 403);
    }
  }

  @Get()
  list(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string) {
    this.requireTrip(tripId, user);
    return { notes: this.notes.list(dayId, tripId) };
  }

  @Post()
  create(
    @CurrentUser() user: User,
    @Param('tripId') tripId: string,
    @Param('dayId') dayId: string,
    @Body() body: DayNoteBody,
    @Headers('x-socket-id') socketId?: string,
  ) {
    validateLengths(body);
    const trip = this.requireTrip(tripId, user);
    this.requireEdit(trip, user);
    if (!this.notes.dayExists(dayId, tripId)) {
      throw new HttpException({ error: 'Day not found' }, 404);
    }
    if (!body.text?.trim()) {
      throw new HttpException({ error: 'Text required' }, 400);
    }
    const note = this.notes.create(dayId, tripId, body.text, body.time, body.icon, body.sort_order);
    this.notes.broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, socketId);
    return { note };
  }

  @Put(':id')
  update(
    @CurrentUser() user: User,
    @Param('tripId') tripId: string,
    @Param('dayId') dayId: string,
    @Param('id') id: string,
    @Body() body: DayNoteBody,
    @Headers('x-socket-id') socketId?: string,
  ) {
    validateLengths(body);
    const trip = this.requireTrip(tripId, user);
    this.requireEdit(trip, user);
    const current = this.notes.getNote(id, dayId, tripId);
    if (!current) {
      throw new HttpException({ error: 'Note not found' }, 404);
    }
    const note = this.notes.update(id, current as never, { text: body.text, time: body.time, icon: body.icon, sort_order: body.sort_order });
    this.notes.broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note }, socketId);
    return { note };
  }

  @Delete(':id')
  remove(
    @CurrentUser() user: User,
    @Param('tripId') tripId: string,
    @Param('dayId') dayId: string,
    @Param('id') id: string,
    @Headers('x-socket-id') socketId?: string,
  ) {
    const trip = this.requireTrip(tripId, user);
    this.requireEdit(trip, user);
    if (!this.notes.getNote(id, dayId, tripId)) {
      throw new HttpException({ error: 'Note not found' }, 404);
    }
    this.notes.remove(id);
    this.notes.broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, socketId);
    return { success: true };
  }
}