Spaces:
Sleeping
Sleeping
| import { | |
| Body, | |
| Controller, | |
| Delete, | |
| Get, | |
| Headers, | |
| HttpCode, | |
| HttpException, | |
| Param, | |
| Patch, | |
| Post, | |
| Put, | |
| UploadedFile, | |
| UploadedFiles, | |
| UseGuards, | |
| UseInterceptors, | |
| } from '@nestjs/common'; | |
| import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; | |
| import { diskStorage } from 'multer'; | |
| import path from 'node:path'; | |
| import fs from 'node:fs'; | |
| import crypto from 'node:crypto'; | |
| import type { User } from '../../types'; | |
| import { JourneyService } from './journey.service'; | |
| import { JourneyAddonGuard } from './journey-addon.guard'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { CurrentUser } from '../auth/current-user.decorator'; | |
| import { getAllowedExtensions } from '../../services/fileService'; | |
| const uploadsBase = path.join(__dirname, '../../../uploads/journey'); | |
| const IMAGE_UPLOAD = { | |
| storage: diskStorage({ | |
| destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); }, | |
| filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`), | |
| }), | |
| limits: { fileSize: 20 * 1024 * 1024 }, | |
| fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { | |
| if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) { | |
| const err: Error & { statusCode?: number } = new Error('Only image files are allowed'); | |
| err.statusCode = 400; | |
| return cb(err, false); | |
| } | |
| const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); | |
| const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase()); | |
| if (!allowed.includes('*') && !allowed.includes(ext)) { | |
| const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); | |
| err.statusCode = 400; | |
| return cb(err, false); | |
| } | |
| cb(null, true); | |
| }, | |
| }; | |
| /** | |
| * /api/journeys β cross-trip travel narrative (journeys, entries, photo gallery | |
| * + provider mirroring, contributors, preferences, share links). | |
| * | |
| * Byte-identical to the legacy Express route (server/src/routes/journey.ts): | |
| * the Journey-addon gate (404) runs before auth, the service owns access | |
| * control (null/false β 403/404), create routes answer 201 while cover/trips/ | |
| * share-link/reorder/patch answer 200 and the two unlink/gallery-delete routes | |
| * answer 204. Static prefixes (/suggestions, /available-trips, /entries, /photos) | |
| * are declared before /:id so they win over the param. | |
| */ | |
| ('api/journeys') | |
| (JourneyAddonGuard, JwtAuthGuard) | |
| export class JourneyController { | |
| constructor(private readonly journey: JourneyService) {} | |
| // ββ Static prefix routes (before /:id) ββββββββββββββββββββββββββββββββββ | |
| () | |
| list(() user: User) { | |
| return { journeys: this.journey.listJourneys(user.id) }; | |
| } | |
| () | |
| create(() user: User, () body: { title?: string; subtitle?: string; trip_ids?: unknown[] }) { | |
| if (!body.title || typeof body.title !== 'string' || !body.title.trim()) { | |
| throw new HttpException({ error: 'Title is required' }, 400); | |
| } | |
| return this.journey.createJourney(user.id, { | |
| title: body.title.trim(), | |
| subtitle: body.subtitle, | |
| trip_ids: Array.isArray(body.trip_ids) ? body.trip_ids.map(Number) : [], | |
| }); | |
| } | |
| ('suggestions') | |
| suggestions(() user: User) { | |
| return { trips: this.journey.getSuggestions(user.id) }; | |
| } | |
| ('available-trips') | |
| availableTrips(() user: User) { | |
| return { trips: this.journey.listUserTrips(user.id) }; | |
| } | |
| // ββ Entries (prefix /entries β before /:id) βββββββββββββββββββββββββββββ | |
| ('entries/:entryId') | |
| updateEntry(() user: User, ('entryId') entryId: string, () body: Record<string, unknown>, ('x-socket-id') socketId?: string) { | |
| const result = this.journey.updateEntry(Number(entryId), user.id, body, socketId); | |
| if (!result) { | |
| throw new HttpException({ error: 'Entry not found' }, 404); | |
| } | |
| return result; | |
| } | |
| ('entries/:entryId') | |
| deleteEntry(() user: User, ('entryId') entryId: string, ('x-socket-id') socketId?: string) { | |
| if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) { | |
| throw new HttpException({ error: 'Entry not found' }, 404); | |
| } | |
| return { success: true }; | |
| } | |
| ('entries/:entryId/photos') | |
| (FilesInterceptor('photos', undefined, IMAGE_UPLOAD)) | |
| async uploadEntryPhotos(() user: User, ('entryId') entryId: string, () files: Express.Multer.File[] | undefined, () body: { caption?: string }) { | |
| if (!files?.length) { | |
| throw new HttpException({ error: 'No files uploaded' }, 400); | |
| } | |
| const results: unknown[] = []; | |
| for (const file of files) { | |
| const relativePath = `journey/${file.filename}`; | |
| const photo = this.journey.addPhoto(Number(entryId), user.id, relativePath, undefined, body?.caption); | |
| if (!photo) continue; | |
| // Mirror to Immich only when the user explicitly opted in (#730). | |
| if (this.journey.immichAutoUploadEnabled(user.id)) { | |
| try { | |
| const immichId = await this.journey.uploadToImmich(user.id, relativePath, file.originalname); | |
| if (immichId) { | |
| this.journey.setPhotoProvider(photo.id, 'immich', immichId, user.id); | |
| Object.assign(photo, { provider: 'immich', asset_id: immichId, owner_id: user.id }); | |
| } | |
| } catch { | |
| // best-effort mirror; the local photo is already saved | |
| } | |
| } | |
| results.push(photo); | |
| } | |
| if (!results.length) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { photos: results }; | |
| } | |
| ('entries/:entryId/provider-photos') | |
| providerPhotos(() user: User, ('entryId') entryId: string, () body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; caption?: string; passphrase?: string }) { | |
| const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; | |
| if (Array.isArray(body.asset_ids) && body.provider) { | |
| const added: unknown[] = []; | |
| for (const id of body.asset_ids) { | |
| const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, String(id), body.caption, pp); | |
| if (photo) added.push(photo); | |
| } | |
| return { photos: added, added: added.length }; | |
| } | |
| if (!body.provider || !body.asset_id) { | |
| throw new HttpException({ error: 'provider and asset_id required' }, 400); | |
| } | |
| const photo = this.journey.addProviderPhoto(Number(entryId), user.id, body.provider, body.asset_id, body.caption, pp); | |
| if (!photo) { | |
| throw new HttpException({ error: 'Not allowed or duplicate' }, 403); | |
| } | |
| return photo; | |
| } | |
| ('entries/:entryId/link-photo') | |
| linkPhoto(() user: User, ('entryId') entryId: string, () body: { journey_photo_id?: unknown; photo_id?: unknown }) { | |
| const journeyPhotoId = body.journey_photo_id ?? body.photo_id; | |
| if (!journeyPhotoId) { | |
| throw new HttpException({ error: 'journey_photo_id required' }, 400); | |
| } | |
| const result = this.journey.linkPhotoToEntry(Number(entryId), Number(journeyPhotoId), user.id); | |
| if (!result) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return result; | |
| } | |
| ('entries/:entryId/photos/:journeyPhotoId') | |
| (204) | |
| unlinkPhoto(() user: User, ('entryId') entryId: string, ('journeyPhotoId') journeyPhotoId: string): void { | |
| if (!this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id)) { | |
| throw new HttpException({ error: 'Not found or not allowed' }, 404); | |
| } | |
| } | |
| ('photos/:photoId') | |
| updatePhoto(() user: User, ('photoId') photoId: string, () body: Record<string, unknown>) { | |
| const result = this.journey.updatePhoto(Number(photoId), user.id, body); | |
| if (!result) { | |
| throw new HttpException({ error: 'Photo not found' }, 404); | |
| } | |
| return result; | |
| } | |
| ('photos/:photoId') | |
| deletePhoto(() user: User, ('photoId') photoId: string) { | |
| const photo = this.journey.deletePhoto(Number(photoId), user.id); | |
| if (!photo) { | |
| throw new HttpException({ error: 'Photo not found' }, 404); | |
| } | |
| if (photo.file_path) { | |
| try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ } | |
| } | |
| return { success: true }; | |
| } | |
| // ββ Gallery (prefix /:id/gallery β before /:id) βββββββββββββββββββββββββ | |
| (':id/gallery/photos') | |
| (FilesInterceptor('photos', undefined, IMAGE_UPLOAD)) | |
| uploadGalleryPhotos(() user: User, ('id') id: string, () files: Express.Multer.File[] | undefined) { | |
| if (!files?.length) { | |
| throw new HttpException({ error: 'No files uploaded' }, 400); | |
| } | |
| const filePaths = files.map((f) => ({ path: `journey/${f.filename}` })); | |
| const photos = this.journey.uploadGalleryPhotos(Number(id), user.id, filePaths); | |
| if (!photos.length) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { photos }; | |
| } | |
| (':id/gallery/provider-photos') | |
| galleryProviderPhotos(() user: User, ('id') id: string, () body: { provider?: string; asset_id?: string; asset_ids?: unknown[]; passphrase?: string }) { | |
| const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; | |
| if (Array.isArray(body.asset_ids) && body.provider) { | |
| const added: unknown[] = []; | |
| for (const aid of body.asset_ids) { | |
| const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, String(aid), undefined, pp); | |
| if (photo) added.push(photo); | |
| } | |
| return { photos: added, added: added.length }; | |
| } | |
| if (!body.provider || !body.asset_id) { | |
| throw new HttpException({ error: 'provider and asset_id required' }, 400); | |
| } | |
| const photo = this.journey.addProviderPhotoToGallery(Number(id), user.id, body.provider, body.asset_id, undefined, pp); | |
| if (!photo) { | |
| throw new HttpException({ error: 'Not allowed or duplicate' }, 403); | |
| } | |
| return photo; | |
| } | |
| (':id/gallery/:journeyPhotoId') | |
| (204) | |
| deleteGalleryPhoto(() user: User, ('journeyPhotoId') journeyPhotoId: string): void { | |
| const photo = this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id); | |
| if (!photo) { | |
| throw new HttpException({ error: 'Photo not found or not allowed' }, 404); | |
| } | |
| if (photo.file_path) { | |
| try { fs.unlinkSync(path.join(__dirname, '../../../uploads', photo.file_path)); } catch { /* file already gone */ } | |
| } | |
| } | |
| // ββ Journeys /:id βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id') | |
| get(() user: User, ('id') id: string) { | |
| const data = this.journey.getJourneyFull(Number(id), user.id); | |
| if (!data) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return data; | |
| } | |
| (':id') | |
| update(() user: User, ('id') id: string, () body: Record<string, unknown>) { | |
| const result = this.journey.updateJourney(Number(id), user.id, body); | |
| if (!result) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return result; | |
| } | |
| (':id/cover') | |
| (200) // Express answers cover with res.json (200). | |
| (FileInterceptor('cover', IMAGE_UPLOAD)) | |
| cover(() user: User, ('id') id: string, () file: Express.Multer.File | undefined) { | |
| if (!file) { | |
| throw new HttpException({ error: 'No file uploaded' }, 400); | |
| } | |
| const result = this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }); | |
| if (!result) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return result; | |
| } | |
| (':id') | |
| remove(() user: User, ('id') id: string) { | |
| if (!this.journey.deleteJourney(Number(id), user.id)) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return { success: true }; | |
| } | |
| // ββ Journey trips βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id/trips') | |
| (200) // Express answers with res.json (200). | |
| addTrip(() user: User, ('id') id: string, () body: { trip_id?: unknown }) { | |
| if (!body.trip_id) { | |
| throw new HttpException({ error: 'trip_id required' }, 400); | |
| } | |
| if (!this.journey.addTripToJourney(Number(id), Number(body.trip_id), user.id)) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| (':id/trips/:tripId') | |
| removeTrip(() user: User, ('id') id: string, ('tripId') tripId: string) { | |
| if (!this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id)) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| // ββ Entries under journey βββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id/entries') | |
| listEntries(() user: User, ('id') id: string) { | |
| const entries = this.journey.listEntries(Number(id), user.id); | |
| if (!entries) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return { entries }; | |
| } | |
| (':id/entries') | |
| createEntry(() user: User, ('id') id: string, () body: Record<string, unknown> & { entry_date?: unknown }, ('x-socket-id') socketId?: string) { | |
| if (!body.entry_date) { | |
| throw new HttpException({ error: 'entry_date is required' }, 400); | |
| } | |
| const entry = this.journey.createEntry(Number(id), user.id, body, socketId); | |
| if (!entry) { | |
| throw new HttpException({ error: 'Journey not found' }, 404); | |
| } | |
| return entry; | |
| } | |
| (':id/entries/reorder') | |
| reorderEntries(() user: User, ('id') id: string, () body: { orderedIds?: unknown }, ('x-socket-id') socketId?: string) { | |
| const orderedIds = body.orderedIds; | |
| if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) { | |
| throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400); | |
| } | |
| if (!this.journey.reorderEntries(Number(id), user.id, orderedIds.map(Number), socketId)) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| // ββ Contributors ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id/contributors') | |
| addContributor(() user: User, ('id') id: string, () body: { user_id?: unknown; role?: 'editor' | 'viewer' }) { | |
| if (!body.user_id) { | |
| throw new HttpException({ error: 'user_id required' }, 400); | |
| } | |
| if (!this.journey.addContributor(Number(id), user.id, Number(body.user_id), body.role || 'viewer')) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| (':id/contributors/:userId') | |
| updateContributor(() user: User, ('id') id: string, ('userId') userId: string, () body: { role?: 'editor' | 'viewer' }) { | |
| if (!this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer')) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| (':id/contributors/:userId') | |
| removeContributor(() user: User, ('id') id: string, ('userId') userId: string) { | |
| if (!this.journey.removeContributor(Number(id), user.id, Number(userId))) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| // ββ User Preferences ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id/preferences') | |
| preferences(() user: User, ('id') id: string, () body: Record<string, unknown>) { | |
| const result = this.journey.updateJourneyPreferences(Number(id), user.id, body); | |
| if (!result) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return result; | |
| } | |
| // ββ Share Link ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (':id/share-link') | |
| getShareLink(() user: User, ('id') id: string) { | |
| return { link: this.journey.getJourneyShareLink(Number(id), user.id) }; | |
| } | |
| (':id/share-link') | |
| (200) // Express answers with res.json (200). | |
| setShareLink(() user: User, ('id') id: string, () body: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) { | |
| const result = this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, { | |
| share_timeline: body.share_timeline, | |
| share_gallery: body.share_gallery, | |
| share_map: body.share_map, | |
| }); | |
| if (!result) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return result; | |
| } | |
| (':id/share-link') | |
| deleteShareLink(() user: User, ('id') id: string) { | |
| if (!this.journey.deleteJourneyShareLink(Number(id), user.id)) { | |
| throw new HttpException({ error: 'Not allowed' }, 403); | |
| } | |
| return { success: true }; | |
| } | |
| } | |