Spaces:
Sleeping
Sleeping
| import { | |
| Body, | |
| Controller, | |
| Delete, | |
| Get, | |
| Headers, | |
| HttpCode, | |
| HttpException, | |
| Param, | |
| Post, | |
| Put, | |
| Query, | |
| UploadedFile, | |
| UseGuards, | |
| UseInterceptors, | |
| } from '@nestjs/common'; | |
| import { FileInterceptor } from '@nestjs/platform-express'; | |
| import { memoryStorage } from 'multer'; | |
| import type { User } from '../../types'; | |
| import { PlacesService } from './places.service'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { CurrentUser } from '../auth/current-user.decorator'; | |
| const STRING_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 }; | |
| const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }; | |
| function validateLengths(body: Record<string, unknown>): void { | |
| for (const [field, max] of Object.entries(STRING_LIMITS)) { | |
| const value = body[field]; | |
| if (value && typeof value === 'string' && value.length > max) { | |
| throw new HttpException({ error: `${field} must be ${max} characters or less` }, 400); | |
| } | |
| } | |
| } | |
| function parseBool(v: unknown, defaultVal: boolean): boolean { | |
| return v === undefined || v === null ? defaultVal : String(v) === 'true'; | |
| } | |
| /** | |
| * /api/trips/:tripId/places — the trip's place pool + importers. | |
| * | |
| * Byte-identical to the legacy Express route (server/src/routes/places.ts): | |
| * trip access (404) runs first, then the string-length guard (400), then the | |
| * 'place_edit' permission (403); create 201 / rest 200; the bespoke 400/404 | |
| * bodies; the journey create/update/delete hooks; and WebSocket broadcasts with | |
| * the forwarded X-Socket-Id. The /import/* and /bulk-delete routes are declared | |
| * before /:id so the static segments win over the param. | |
| */ | |
| ('api/trips/:tripId/places') | |
| (JwtAuthGuard) | |
| export class PlacesController { | |
| constructor(private readonly places: PlacesService) {} | |
| private requireTrip(tripId: string, user: User) { | |
| const trip = this.places.verifyTripAccess(tripId, user.id); | |
| if (!trip) { | |
| throw new HttpException({ error: 'Trip not found' }, 404); | |
| } | |
| return trip; | |
| } | |
| private requireEdit(trip: NonNullable<ReturnType<PlacesService['verifyTripAccess']>>, user: User): void { | |
| if (!this.places.canEdit(trip, user)) { | |
| throw new HttpException({ error: 'No permission' }, 403); | |
| } | |
| } | |
| () | |
| list( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('search') search?: string, | |
| ('category') category?: string, | |
| ('tag') tag?: string, | |
| ) { | |
| this.requireTrip(tripId, user); | |
| return { places: this.places.list(tripId, { search, category, tag }) }; | |
| } | |
| () | |
| create( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| () body: Record<string, unknown> & { name?: string }, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| validateLengths(body); | |
| this.requireEdit(trip, user); | |
| if (!body.name) { | |
| throw new HttpException({ error: 'Place name is required' }, 400); | |
| } | |
| const place = this.places.create(tripId, body as never); | |
| this.places.broadcast(tripId, 'place:created', { place }, socketId); | |
| this.places.onCreated(tripId, place.id); | |
| return { place }; | |
| } | |
| ('import/gpx') | |
| (FileInterceptor('file', UPLOAD)) | |
| importGpx( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| () file: Express.Multer.File | undefined, | |
| () body: Record<string, unknown>, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!file) { | |
| throw new HttpException({ error: 'No file uploaded' }, 400); | |
| } | |
| const importWaypoints = parseBool(body.importWaypoints, true); | |
| const importRoutes = parseBool(body.importRoutes, true); | |
| const importTracks = parseBool(body.importTracks, true); | |
| if (!importWaypoints && !importRoutes && !importTracks) { | |
| throw new HttpException({ error: 'No import types selected' }, 400); | |
| } | |
| const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname }); | |
| if (!result) { | |
| throw new HttpException({ error: 'No matching places found in GPX file' }, 400); | |
| } | |
| for (const place of result.places) { | |
| this.places.broadcast(tripId, 'place:created', { place }, socketId); | |
| } | |
| return { places: result.places, count: result.count, skipped: result.skipped }; | |
| } | |
| ('import/map') | |
| (FileInterceptor('file', UPLOAD)) | |
| async importMap( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| () file: Express.Multer.File | undefined, | |
| () body: Record<string, unknown>, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!file) { | |
| throw new HttpException({ error: 'No file uploaded' }, 400); | |
| } | |
| const importPoints = parseBool(body.importPoints, true); | |
| const importPaths = parseBool(body.importPaths, true); | |
| if (!importPoints && !importPaths) { | |
| throw new HttpException({ error: 'No import types selected' }, 400); | |
| } | |
| try { | |
| const result = await this.places.importMapFile(tripId, file.buffer, file.originalname, { importPoints, importPaths }); | |
| if (result.summary?.totalPlacemarks === 0) { | |
| throw new HttpException({ error: 'No valid Placemarks found in map file', summary: result.summary }, 400); | |
| } | |
| for (const place of result.places) { | |
| this.places.broadcast(tripId, 'place:created', { place }, socketId); | |
| } | |
| return result; | |
| } catch (err: unknown) { | |
| if (err instanceof HttpException) throw err; | |
| const message = err instanceof Error ? err.message : 'Failed to import map file'; | |
| throw new HttpException({ error: message }, 400); | |
| } | |
| } | |
| ('import/google-list') | |
| async importGoogle(() user: User, ('tripId') tripId: string, ('url') url: unknown, ('enrich') enrich: unknown, ('x-socket-id') socketId?: string) { | |
| return this.importList('google', user, tripId, url, enrich, socketId); | |
| } | |
| ('import/naver-list') | |
| async importNaver(() user: User, ('tripId') tripId: string, ('url') url: unknown, ('enrich') enrich: unknown, ('x-socket-id') socketId?: string) { | |
| return this.importList('naver', user, tripId, url, enrich, socketId); | |
| } | |
| /** Shared google/naver list import — identical flow, different provider + error string. */ | |
| private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!url || typeof url !== 'string') { | |
| throw new HttpException({ error: 'URL is required' }, 400); | |
| } | |
| // Opt-in: re-resolve each imported place via the Places API to fill in | |
| // photo / address / website / phone and persist a google_place_id (#886). | |
| const opts = { enrich: parseBool(enrich, false), userId: user.id }; | |
| const label = provider === 'google' ? 'Google' : 'Naver'; | |
| try { | |
| const result = provider === 'google' | |
| ? await this.places.importGoogleList(tripId, url, opts) | |
| : await this.places.importNaverList(tripId, url, opts); | |
| if ('error' in result) { | |
| throw new HttpException({ error: result.error }, result.status); | |
| } | |
| for (const place of result.places) { | |
| this.places.broadcast(tripId, 'place:created', { place }, socketId); | |
| } | |
| return { places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }; | |
| } catch (err: unknown) { | |
| if (err instanceof HttpException) throw err; | |
| console.error(`[Places] ${label} list import error:`, err instanceof Error ? err.message : err); | |
| throw new HttpException({ error: `Failed to import ${label} Maps list. Make sure the list is shared publicly.` }, 400); | |
| } | |
| } | |
| ('bulk-delete') | |
| (200) // Express answers bulk-delete with res.json (200), unlike the 201 imports. | |
| bulkDelete( | |
| () user: User, | |
| ('tripId') tripId: string, | |
| ('ids') ids: unknown, | |
| ('x-socket-id') socketId?: string, | |
| ) { | |
| const trip = this.requireTrip(tripId, user); | |
| this.requireEdit(trip, user); | |
| if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number')) { | |
| throw new HttpException({ error: 'ids must be an array of numbers' }, 400); | |
| } | |
| if (ids.length === 0) { | |
| return { deleted: [], count: 0 }; | |
| } | |
| for (const id of ids) this.places.onDeleted(id); | |
| const deleted = this.places.removeMany(tripId, ids); | |
| for (const id of deleted) { | |
| this.places.broadcast(tripId, 'place:deleted', { placeId: id }, socketId); | |
| } | |
| return { deleted, count: deleted.length }; | |
| } | |
| (':id') | |
| get(() user: User, ('tripId') tripId: string, ('id') id: string) { | |
| this.requireTrip(tripId, user); | |
| const place = this.places.get(tripId, id); | |
| if (!place) { | |
| throw new HttpException({ error: 'Place not found' }, 404); | |
| } | |
| return { place }; | |
| } | |
| (':id/image') | |
| async image(() user: User, ('tripId') tripId: string, ('id') id: string) { | |
| this.requireTrip(tripId, user); | |
| try { | |
| const result = await this.places.searchImage(tripId, id, user.id); | |
| if ('error' in result) { | |
| throw new HttpException({ error: result.error }, result.status); | |
| } | |
| return { photos: result.photos }; | |
| } catch (err: unknown) { | |
| if (err instanceof HttpException) throw err; | |
| console.error('Unsplash error:', err); | |
| throw new HttpException({ error: 'Error searching for image' }, 500); | |
| } | |
| } | |
| (':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); | |
| validateLengths(body); | |
| this.requireEdit(trip, user); | |
| const place = this.places.update(tripId, id, body as never); | |
| if (!place) { | |
| throw new HttpException({ error: 'Place not found' }, 404); | |
| } | |
| this.places.broadcast(tripId, 'place:updated', { place }, socketId); | |
| this.places.onUpdated(place.id); | |
| return { place }; | |
| } | |
| (':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); | |
| this.places.onDeleted(Number(id)); // sync before actual delete | |
| if (!this.places.remove(tripId, id)) { | |
| throw new HttpException({ error: 'Place not found' }, 404); | |
| } | |
| this.places.broadcast(tripId, 'place:deleted', { placeId: Number(id) }, socketId); | |
| return { success: true }; | |
| } | |
| } | |