mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
399e3573c1
Trip members can rate a place 1-5 stars; the place shows the average, with a who-voted custom tooltip and a star-sort toggle in the places sidebar. Saved collection places are ratable too (plus a min-rating filter), and ratings travel with a place copied between a trip and a collection — carrying only the votes of users who belong to both sides. Adds a full Collections MCP surface (list/get, CRUD lists + places, status, rate_collection_place, labels, sharing, copy-to-trip) under a new collections:read/collections:write scope, plus rate_place for trip places; rating aggregates flow through the existing place outputs. Mobile trip + collection place sheets gain the rating row. New UI strings translated across all 22 locales.
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Headers,
|
|
HttpCode,
|
|
HttpException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Put,
|
|
Query,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import type { Request } from 'express';
|
|
import { diskStorage } from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import type { User } from '../../types';
|
|
import { CollectionsService } from './collections.service';
|
|
import { CollectionsAddonGuard } from './collections-addon.guard';
|
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
import { CurrentUser } from '../auth/current-user.decorator';
|
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
|
import { PLACE_IMAGE_UPLOAD } from '../common/place-image-upload';
|
|
import { placeImageUrl } from '../../services/placeImage';
|
|
import { isDemoEmail } from '../../services/demo';
|
|
import {
|
|
collectionCreateRequestSchema,
|
|
collectionUpdateRequestSchema,
|
|
collectionSavePlaceRequestSchema,
|
|
collectionSaveFromTripRequestSchema,
|
|
collectionSaveFromTripManyRequestSchema,
|
|
collectionPlaceUpdateRequestSchema,
|
|
collectionSetStatusRequestSchema,
|
|
placeRatingRequestSchema,
|
|
collectionCopyToTripRequestSchema,
|
|
collectionInviteRequestSchema,
|
|
collectionInviteActionRequestSchema,
|
|
collectionInviteCancelRequestSchema,
|
|
collectionRemoveMemberRequestSchema,
|
|
collectionSetMemberRoleRequestSchema,
|
|
collectionLabelCreateRequestSchema,
|
|
collectionLabelUpdateRequestSchema,
|
|
collectionLabelAssignRequestSchema,
|
|
type CollectionLabelCreateRequest,
|
|
type CollectionLabelUpdateRequest,
|
|
type CollectionLabelAssignRequest,
|
|
type CollectionCreateRequest,
|
|
type CollectionUpdateRequest,
|
|
type CollectionSavePlaceRequest,
|
|
type CollectionSaveFromTripRequest,
|
|
type CollectionSaveFromTripManyRequest,
|
|
type CollectionPlaceUpdateRequest,
|
|
type CollectionSetStatusRequest,
|
|
type PlaceRatingRequest,
|
|
type CollectionCopyToTripRequest,
|
|
type CollectionInviteRequest,
|
|
type CollectionInviteActionRequest,
|
|
type CollectionInviteCancelRequest,
|
|
type CollectionRemoveMemberRequest,
|
|
type CollectionSetMemberRoleRequest,
|
|
} from '@trek/shared';
|
|
|
|
const MAX_COVER_SIZE = 20 * 1024 * 1024;
|
|
const coversDir = path.join(__dirname, '../../../uploads/covers');
|
|
const COVER_UPLOAD = {
|
|
storage: diskStorage({
|
|
destination: (_req, _file, cb) => {
|
|
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
|
|
cb(null, coversDir);
|
|
},
|
|
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
|
|
}),
|
|
limits: { fileSize: MAX_COVER_SIZE },
|
|
fileFilter: (_req: Request, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) cb(null, true);
|
|
else cb(new Error('Only jpg, png, gif, webp images allowed'), false);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* /api/addons/collections — saved-place library (lists, places, fusion sharing).
|
|
*
|
|
* The addon guard (404) runs before JwtAuthGuard so a disabled addon answers 404
|
|
* regardless of auth. Access control lives in the service (assertAccess / isOwner
|
|
* throw 404/403/400). /invite, /invite/cancel and /:id/available-users add an
|
|
* explicit owner check in the controller so they cannot be used to enumerate.
|
|
* The X-Socket-Id header is forwarded to the service so the originating client is
|
|
* excluded from the WS broadcast.
|
|
*/
|
|
@Controller('api/addons/collections')
|
|
@UseGuards(CollectionsAddonGuard, JwtAuthGuard)
|
|
export class CollectionsController {
|
|
constructor(private readonly collections: CollectionsService) {}
|
|
|
|
// ── Lists ─────────────────────────────────────────────────────────────────
|
|
@Get()
|
|
list(@CurrentUser() user: User) {
|
|
return this.collections.listCollections(user.id);
|
|
}
|
|
|
|
@Post()
|
|
create(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionCreateRequestSchema)) body: CollectionCreateRequest) {
|
|
return this.collections.createCollection(user.id, body);
|
|
}
|
|
|
|
@Post('reorder')
|
|
@HttpCode(200)
|
|
reorder(@CurrentUser() user: User, @Body('orderedIds') orderedIds: unknown) {
|
|
if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) {
|
|
throw new HttpException({ error: 'orderedIds must be an array of numbers' }, 400);
|
|
}
|
|
this.collections.reorderCollections(user.id, orderedIds.map(Number));
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Places (static prefixes before /:id) ────────────────────────────────────
|
|
@Post('places')
|
|
@HttpCode(200)
|
|
savePlace(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSavePlaceRequestSchema)) body: CollectionSavePlaceRequest, @Headers('x-socket-id') socketId?: string) {
|
|
return this.collections.savePlace(user.id, body, socketId);
|
|
}
|
|
|
|
@Post('places/from-trip')
|
|
@HttpCode(200)
|
|
saveFromTrip(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSaveFromTripRequestSchema)) body: CollectionSaveFromTripRequest) {
|
|
return this.collections.saveFromTripPlace(user.id, body.collection_id, body.source_trip_id, body.source_place_id, body.force);
|
|
}
|
|
|
|
@Post('places/from-trip-many')
|
|
@HttpCode(200)
|
|
saveFromTripMany(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSaveFromTripManyRequestSchema)) body: CollectionSaveFromTripManyRequest) {
|
|
return this.collections.saveFromTripPlaces(user.id, body.collection_id, body.source_trip_id, body.source_place_ids, body.force);
|
|
}
|
|
|
|
@Post('places/delete-many')
|
|
@HttpCode(200)
|
|
deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown, @Headers('x-socket-id') socketId?: string) {
|
|
if (!Array.isArray(ids) || !ids.every((v) => Number.isFinite(Number(v)))) {
|
|
throw new HttpException({ error: 'ids must be an array of numbers' }, 400);
|
|
}
|
|
return { deleted: this.collections.deletePlacesMany(user.id, ids.map(Number), socketId) };
|
|
}
|
|
|
|
@Patch('places/:pid')
|
|
updatePlace(
|
|
@CurrentUser() user: User,
|
|
@Param('pid') pid: string,
|
|
@Body(new ZodValidationPipe(collectionPlaceUpdateRequestSchema)) body: CollectionPlaceUpdateRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.updatePlace(user.id, Number(pid), body, socketId);
|
|
}
|
|
|
|
@Post('places/:pid/status')
|
|
@HttpCode(200)
|
|
setStatus(
|
|
@CurrentUser() user: User,
|
|
@Param('pid') pid: string,
|
|
@Body(new ZodValidationPipe(collectionSetStatusRequestSchema)) body: CollectionSetStatusRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.setStatus(user.id, Number(pid), body.status, socketId);
|
|
}
|
|
|
|
@Put('places/:pid/rating')
|
|
setRating(
|
|
@CurrentUser() user: User,
|
|
@Param('pid') pid: string,
|
|
@Body(new ZodValidationPipe(placeRatingRequestSchema)) body: PlaceRatingRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.setRating(user.id, Number(pid), body.rating, socketId);
|
|
}
|
|
|
|
@Delete('places/:pid/rating')
|
|
clearRating(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) {
|
|
return this.collections.setRating(user.id, Number(pid), null, socketId);
|
|
}
|
|
|
|
@Post('places/:pid/image')
|
|
@HttpCode(200)
|
|
@UseInterceptors(FileInterceptor('image', PLACE_IMAGE_UPLOAD))
|
|
uploadPlaceImage(
|
|
@CurrentUser() user: User,
|
|
@Param('pid') pid: string,
|
|
@UploadedFile() file: Express.Multer.File | undefined,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
|
|
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
|
|
}
|
|
if (!file) throw new HttpException({ error: 'No image uploaded' }, 400);
|
|
return this.collections.setPlaceImage(user.id, Number(pid), placeImageUrl(file.filename), socketId);
|
|
}
|
|
|
|
@Delete('places/:pid')
|
|
deletePlace(@CurrentUser() user: User, @Param('pid') pid: string, @Headers('x-socket-id') socketId?: string) {
|
|
this.collections.deletePlace(user.id, Number(pid), socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Copy to trip ────────────────────────────────────────────────────────────
|
|
@Post('copy-to-trip')
|
|
@HttpCode(200)
|
|
copyToTrip(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionCopyToTripRequestSchema)) body: CollectionCopyToTripRequest) {
|
|
return this.collections.copyToTrip(user.id, body);
|
|
}
|
|
|
|
// ── Labels (per-collection custom labels; static prefixes before /:id) ───────
|
|
@Post('labels')
|
|
@HttpCode(200)
|
|
createLabel(
|
|
@CurrentUser() user: User,
|
|
@Body(new ZodValidationPipe(collectionLabelCreateRequestSchema)) body: CollectionLabelCreateRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.createLabel(user.id, body.collection_id, body.name, body.color, socketId);
|
|
}
|
|
|
|
@Patch('labels/:lid')
|
|
updateLabel(
|
|
@CurrentUser() user: User,
|
|
@Param('lid') lid: string,
|
|
@Body(new ZodValidationPipe(collectionLabelUpdateRequestSchema)) body: CollectionLabelUpdateRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.updateLabel(user.id, Number(lid), body, socketId);
|
|
}
|
|
|
|
@Delete('labels/:lid')
|
|
deleteLabel(@CurrentUser() user: User, @Param('lid') lid: string, @Headers('x-socket-id') socketId?: string) {
|
|
this.collections.deleteLabel(user.id, Number(lid), socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('labels/assign')
|
|
@HttpCode(200)
|
|
assignLabels(
|
|
@CurrentUser() user: User,
|
|
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, false, socketId);
|
|
}
|
|
|
|
@Post('labels/unassign')
|
|
@HttpCode(200)
|
|
unassignLabels(
|
|
@CurrentUser() user: User,
|
|
@Body(new ZodValidationPipe(collectionLabelAssignRequestSchema)) body: CollectionLabelAssignRequest,
|
|
@Headers('x-socket-id') socketId?: string,
|
|
) {
|
|
return this.collections.assignLabels(user.id, body.label_ids, body.place_ids, true, socketId);
|
|
}
|
|
|
|
// ── Library-wide membership lookup ──────────────────────────────────────────
|
|
@Get('membership')
|
|
membership(
|
|
@CurrentUser() user: User,
|
|
@Query('google_place_id') googlePlaceId?: string,
|
|
@Query('google_ftid') googleFtid?: string,
|
|
@Query('name') name?: string,
|
|
@Query('lat') lat?: string,
|
|
@Query('lng') lng?: string,
|
|
) {
|
|
return this.collections.findMembership(user.id, {
|
|
google_place_id: googlePlaceId,
|
|
google_ftid: googleFtid,
|
|
name,
|
|
lat: lat != null && lat !== '' ? Number(lat) : undefined,
|
|
lng: lng != null && lng !== '' ? Number(lng) : undefined,
|
|
});
|
|
}
|
|
|
|
// ── Fusion invitations ──────────────────────────────────────────────────────
|
|
@Post('invite')
|
|
@HttpCode(200)
|
|
invite(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionInviteRequestSchema)) body: CollectionInviteRequest) {
|
|
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible (no enumeration)
|
|
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
|
throw new HttpException({ error: 'Only the owner can invite' }, 403);
|
|
}
|
|
const result = this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role);
|
|
if (result.error) {
|
|
throw new HttpException({ error: result.error }, result.status!);
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('invite/accept')
|
|
@HttpCode(200)
|
|
acceptInvite(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionInviteActionRequestSchema)) body: CollectionInviteActionRequest, @Headers('x-socket-id') socketId?: string) {
|
|
const result = this.collections.acceptInvite(user.id, body.collection_id, socketId);
|
|
if (result.error) {
|
|
throw new HttpException({ error: result.error }, result.status!);
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('invite/decline')
|
|
@HttpCode(200)
|
|
declineInvite(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionInviteActionRequestSchema)) body: CollectionInviteActionRequest, @Headers('x-socket-id') socketId?: string) {
|
|
this.collections.declineInvite(user.id, body.collection_id, socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('invite/cancel')
|
|
@HttpCode(200)
|
|
cancelInvite(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionInviteCancelRequestSchema)) body: CollectionInviteCancelRequest) {
|
|
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible
|
|
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
|
throw new HttpException({ error: 'Only the owner can cancel invites' }, 403);
|
|
}
|
|
this.collections.cancelInvite(body.collection_id, user.id, body.user_id);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('leave')
|
|
@HttpCode(200)
|
|
leave(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionInviteActionRequestSchema)) body: CollectionInviteActionRequest, @Headers('x-socket-id') socketId?: string) {
|
|
this.collections.leaveCollection(user.id, body.collection_id, socketId);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('members/remove')
|
|
@HttpCode(200)
|
|
removeMember(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionRemoveMemberRequestSchema)) body: CollectionRemoveMemberRequest) {
|
|
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible
|
|
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
|
throw new HttpException({ error: 'Only the owner can remove members' }, 403);
|
|
}
|
|
this.collections.removeMember(user.id, body.collection_id, body.user_id);
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('members/role')
|
|
@HttpCode(200)
|
|
setMemberRole(@CurrentUser() user: User, @Body(new ZodValidationPipe(collectionSetMemberRoleRequestSchema)) body: CollectionSetMemberRoleRequest) {
|
|
this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible
|
|
if (!this.collections.isOwner(user.id, body.collection_id)) {
|
|
throw new HttpException({ error: 'Only the owner can change member roles' }, 403);
|
|
}
|
|
this.collections.setMemberRole(user.id, body.collection_id, body.user_id, body.role);
|
|
return { success: true };
|
|
}
|
|
|
|
// ── /:id (declared last so static prefixes win) ─────────────────────────────
|
|
@Get(':id/available-users')
|
|
availableUsers(@CurrentUser() user: User, @Param('id') id: string) {
|
|
this.collections.assertAccess(user.id, Number(id)); // 404 if not visible (no enumeration)
|
|
if (!this.collections.isOwner(user.id, Number(id))) {
|
|
throw new HttpException({ error: 'Only the owner can manage members' }, 403);
|
|
}
|
|
return { users: this.collections.availableUsers(user.id, Number(id)) };
|
|
}
|
|
|
|
@Post(':id/cover')
|
|
@UseInterceptors(FileInterceptor('cover', COVER_UPLOAD))
|
|
uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
|
|
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
|
|
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
|
|
}
|
|
if (!file) throw new HttpException({ error: 'No image uploaded' }, 400);
|
|
const coverUrl = `/uploads/covers/${file.filename}`;
|
|
return this.collections.setCollectionCover(user.id, Number(id), coverUrl, socketId);
|
|
}
|
|
|
|
@Get(':id')
|
|
get(@CurrentUser() user: User, @Param('id') id: string) {
|
|
return this.collections.getCollection(user.id, Number(id));
|
|
}
|
|
|
|
@Patch(':id')
|
|
update(@CurrentUser() user: User, @Param('id') id: string, @Body(new ZodValidationPipe(collectionUpdateRequestSchema)) body: CollectionUpdateRequest, @Headers('x-socket-id') socketId?: string) {
|
|
return this.collections.updateCollection(user.id, Number(id), body, socketId);
|
|
}
|
|
|
|
@Delete(':id')
|
|
remove(@CurrentUser() user: User, @Param('id') id: string) {
|
|
this.collections.deleteCollection(user.id, Number(id));
|
|
return { success: true };
|
|
}
|
|
}
|