Spaces:
Sleeping
Sleeping
| import { | |
| Body, | |
| Controller, | |
| Delete, | |
| Get, | |
| Headers, | |
| HttpException, | |
| Param, | |
| Post, | |
| Put, | |
| Query, | |
| UseGuards, | |
| } from '@nestjs/common'; | |
| import type { User } from '../../types'; | |
| import { BudgetService } from './budget.service'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { CurrentUser } from '../auth/current-user.decorator'; | |
| /** | |
| * /api/trips/:tripId/budget — trip-scoped expense planner. | |
| * | |
| * Byte-identical to the legacy Express route (server/src/routes/budget.ts): | |
| * every handler verifies trip access (404); mutations check 'budget_edit' (403); | |
| * create is 201, the rest 200; bespoke 400/404 bodies reproduced; mutations | |
| * broadcast over WebSocket with the forwarded X-Socket-Id. Static sub-routes | |
| * (summary, settlement, reorder/*) are declared before /:id so they win over the | |
| * param. Updating total_price on a reservation-linked item syncs the price back. | |
| */ | |
| ('api/trips/:tripId/budget') | |
| (JwtAuthGuard) | |
| export class BudgetController { | |
| constructor(private readonly budget: BudgetService) {} | |
| private requireTrip(tripId: string, user: User) { | |
| const trip = this.budget.verifyTripAccess(tripId, user.id); | |
| if (!trip) { | |
| throw new HttpException({ error: 'Trip not found' }, 404); | |
| } | |
| return trip; | |
| } | |
| private requireEdit(trip: ReturnType<BudgetService['verifyTripAccess']>, user: User): void { | |
| if (!this.budget.canEdit(trip!, user)) { | |
| throw new HttpException({ error: 'No permission' }, 403); | |
| } | |
| } | |
| () | |
| list(() user: User, ('tripId') tripId: string) { | |
| this.requireTrip(tripId, user); | |
| return { items: this.budget.list(tripId) }; | |
| } | |
| ('summary/per-person') | |
| perPerson(() user: User, ('tripId') tripId: string) { | |
| this.requireTrip(tripId, user); | |
| return { summary: this.budget.perPersonSummary(tripId) }; | |
| } | |
| ('settlement') | |
| settlement( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('base') base?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| return this.budget.settlement(tripId, base, (trip as { currency?: string }).currency || 'EUR'); | |
| } | |
| ('settlements') | |
| listSettlements(() user: User, ('tripId') tripId: string) { | |
| this.requireTrip(tripId, user); | |
| return { settlements: this.budget.listSettlements(tripId) }; | |
| } | |
| ('settlements') | |
| createSettlement( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| () body: { from_user_id?: number; to_user_id?: number; amount?: number }, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (body.from_user_id == null || body.to_user_id == null || body.amount == null) { | |
| throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400); | |
| } | |
| const settlement = this.budget.createSettlement( | |
| tripId, | |
| { from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount }, | |
| user.id, | |
| ); | |
| this.budget.broadcast(tripId, 'budget:settlement-created', { settlement }, socketId); | |
| return { settlement }; | |
| } | |
| ('settlements/:settlementId') | |
| deleteSettlement( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('settlementId') settlementId: string, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!this.budget.deleteSettlement(settlementId, tripId)) { | |
| throw new HttpException({ error: 'Settlement not found' }, 404); | |
| } | |
| this.budget.broadcast(tripId, 'budget:settlement-deleted', { settlementId: Number(settlementId) }, socketId); | |
| return { success: true }; | |
| } | |
| () | |
| create( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| () body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null }, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!body.name) { | |
| throw new HttpException({ error: 'Name is required' }, 400); | |
| } | |
| const item = this.budget.create(tripId, body as { name: string }); | |
| this.budget.broadcast(tripId, 'budget:created', { item }, socketId); | |
| return { item }; | |
| } | |
| ('reorder/items') | |
| reorderItems( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('orderedIds') orderedIds: number[], | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| this.budget.reorderItems(tripId, orderedIds); | |
| this.budget.broadcast(tripId, 'budget:reordered', { orderedIds }, socketId); | |
| return { success: true }; | |
| } | |
| ('reorder/categories') | |
| reorderCategories( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('orderedCategories') orderedCategories: string[], | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| this.budget.reorderCategories(tripId, orderedCategories); | |
| this.budget.broadcast(tripId, 'budget:reordered', { orderedCategories }, socketId); | |
| return { success: true }; | |
| } | |
| (':id') | |
| update( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('id') id: string, | |
| () body: Record<string, unknown>, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| const updated = this.budget.update(id, tripId, body); | |
| if (!updated) { | |
| throw new HttpException({ error: 'Budget item not found' }, 404); | |
| } | |
| if (updated.reservation_id && body.total_price !== undefined) { | |
| this.budget.syncReservationPrice(tripId, updated.reservation_id, updated.total_price, socketId); | |
| } | |
| this.budget.broadcast(tripId, 'budget:updated', { item: updated }, socketId); | |
| return { item: updated }; | |
| } | |
| (':id/members') | |
| updateMembers( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('id') id: string, | |
| ('user_ids') userIds: unknown, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!Array.isArray(userIds)) { | |
| throw new HttpException({ error: 'user_ids must be an array' }, 400); | |
| } | |
| const result = this.budget.updateMembers(id, tripId, userIds); | |
| if (!result) { | |
| throw new HttpException({ error: 'Budget item not found' }, 404); | |
| } | |
| this.budget.broadcast(tripId, 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, socketId); | |
| return { members: result.members, item: result.item }; | |
| } | |
| (':id/payers') | |
| setPayers( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('id') id: string, | |
| ('payers') payers: unknown, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!Array.isArray(payers)) { | |
| throw new HttpException({ error: 'payers must be an array' }, 400); | |
| } | |
| const item = this.budget.setPayers(id, tripId, payers as { user_id: number; amount: number }[]); | |
| if (!item) { | |
| throw new HttpException({ error: 'Budget item not found' }, 404); | |
| } | |
| this.budget.broadcast(tripId, 'budget:updated', { item }, socketId); | |
| return { item }; | |
| } | |
| (':id/members/:userId/paid') | |
| toggleMemberPaid( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('id') id: string, | |
| ('userId') userId: string, | |
| ('paid') paid: boolean, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| const member = this.budget.toggleMemberPaid(id, tripId, userId, paid); | |
| this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, socketId); | |
| return { member }; | |
| } | |
| (':id') | |
| remove( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('id') id: string, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!this.budget.remove(id, tripId)) { | |
| throw new HttpException({ error: 'Budget item not found' }, 404); | |
| } | |
| this.budget.broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, socketId); | |
| return { success: true }; | |
| } | |
| } | |