diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 18c49d04c..1c658ee11 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,11 +4,11 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, } function ImmichIcon({ size = 14 }: { size?: number }) { diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx index 5eb75cfcf..4e3d787f7 100644 --- a/client/src/components/Layout/BottomNav.tsx +++ b/client/src/components/Layout/BottomNav.tsx @@ -2,13 +2,14 @@ import { useNavigate, useLocation, useMatch } from 'react-router-dom' import { useAddonStore } from '../../store/addonStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { LayoutGrid, CalendarDays, Globe, Compass, Plus } from 'lucide-react' +import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' const ADDON_NAV: Record = { - vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, - atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' }, - journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' }, + vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, + atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' }, + journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' }, + collections: { icon: Bookmark, labelKey: 'admin.addons.catalog.collections.name' }, } interface NavItem { to: string; label: string; icon: LucideIcon } diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index bafaa103e..5df0d25de 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' -import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen } from 'lucide-react' +import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' -const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe, Compass } +const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe, Compass, Bookmark } interface NavbarProps { tripTitle?: string diff --git a/server/src/addons.ts b/server/src/addons.ts index fd5ced69a..b5aa22da3 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -9,6 +9,7 @@ export const ADDON_IDS = { JOURNEY: 'journey', AIRTRAIL: 'airtrail', LLM_PARSING: 'llm_parsing', + COLLECTIONS: 'collections', } as const; export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS]; diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 781214ee9..3923e321a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3180,6 +3180,81 @@ function runMigrations(db: Database.Database): void { ); `); }, + // Migration 150: Collections addon — personal place library (#1081). + // Multi-list + per-place status (idea/want/visited) + fusion sharing. + // (150 = migrations.length of this array after appending — the runner uses + // migrations.length, not this comment; the label is cosmetic.) + () => { + db.prepare( + ` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) + `, + ).run(); + + db.exec(` + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ); + + CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); + CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); + CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); + CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index bd6967efc..563a95bef 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -371,6 +371,68 @@ function createTables(db: Database.Database): void { sort_order INTEGER NOT NULL DEFAULT 0 ); + -- Collections addon tables + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ); + + CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); + CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); + CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); + CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); + CREATE TABLE IF NOT EXISTS day_accommodations ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index af8f65bce..8252f3562 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -121,6 +121,7 @@ function seedAddons(db: Database.Database): void { { id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 }, { id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 }, { id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 }, + { id: 'collections', name: 'Collections', description: 'Personal place library — save places across trips into named lists, copy into any trip, share with others', type: 'global', icon: 'Bookmark', enabled: 0, sort_order: 16 }, ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index be34ae24f..9cc12b609 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -28,6 +28,7 @@ import { PhotosModule } from './photos/photos.module'; import { MemoriesModule } from './memories/memories.module'; import { AirtrailModule } from './integrations/airtrail.module'; import { JourneyModule } from './journey/journey.module'; +import { CollectionsModule } from './collections/collections.module'; import { ShareModule } from './share/share.module'; import { FeedsModule } from './feeds/feeds.module'; import { SettingsModule } from './settings/settings.module'; @@ -48,7 +49,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], + imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/collections/collections-addon.guard.ts b/server/src/nest/collections/collections-addon.guard.ts new file mode 100644 index 000000000..7820f84e0 --- /dev/null +++ b/server/src/nest/collections/collections-addon.guard.ts @@ -0,0 +1,18 @@ +import { CanActivate, HttpException, Injectable } from '@nestjs/common'; +import { isAddonEnabled } from '../../services/adminService'; +import { ADDON_IDS } from '../../addons'; + +/** + * Mirrors the addon mount gate: when the Collections addon is disabled the whole + * route group answers 404, regardless of auth. Declared before JwtAuthGuard so + * the addon check wins over the 401, exactly as the Journey addon guard does. + */ +@Injectable() +export class CollectionsAddonGuard implements CanActivate { + canActivate(): boolean { + if (!isAddonEnabled(ADDON_IDS.COLLECTIONS)) { + throw new HttpException({ error: 'Collections addon is not enabled' }, 404); + } + return true; + } +} diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts new file mode 100644 index 000000000..d57bee370 --- /dev/null +++ b/server/src/nest/collections/collections.controller.ts @@ -0,0 +1,228 @@ +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpCode, + HttpException, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +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 { + collectionCreateRequestSchema, + collectionUpdateRequestSchema, + collectionSavePlaceRequestSchema, + collectionSaveFromTripRequestSchema, + collectionPlaceUpdateRequestSchema, + collectionSetStatusRequestSchema, + collectionCopyToTripRequestSchema, + collectionInviteRequestSchema, + collectionInviteActionRequestSchema, + collectionInviteCancelRequestSchema, + type CollectionCreateRequest, + type CollectionUpdateRequest, + type CollectionSavePlaceRequest, + type CollectionSaveFromTripRequest, + type CollectionPlaceUpdateRequest, + type CollectionSetStatusRequest, + type CollectionCopyToTripRequest, + type CollectionInviteRequest, + type CollectionInviteActionRequest, + type CollectionInviteCancelRequest, +} from '@trek/shared'; + +/** + * /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) { + return this.collections.savePlace(user.id, body); + } + + @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/delete-many') + @HttpCode(200) + deleteMany(@CurrentUser() user: User, @Body('ids') ids: unknown) { + 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)) }; + } + + @Patch('places/:pid') + updatePlace( + @CurrentUser() user: User, + @Param('pid') pid: string, + @Body(new ZodValidationPipe(collectionPlaceUpdateRequestSchema)) body: CollectionPlaceUpdateRequest, + ) { + return this.collections.updatePlace(user.id, Number(pid), body); + } + + @Post('places/:pid/status') + @HttpCode(200) + setStatus( + @CurrentUser() user: User, + @Param('pid') pid: string, + @Body(new ZodValidationPipe(collectionSetStatusRequestSchema)) body: CollectionSetStatusRequest, + ) { + return this.collections.setStatus(user.id, Number(pid), body.status); + } + + @Delete('places/:pid') + deletePlace(@CurrentUser() user: User, @Param('pid') pid: string) { + this.collections.deletePlace(user.id, Number(pid)); + 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); + } + + // ── 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); + 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 }; + } + + // ── /: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)) }; + } + + @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) { + return this.collections.updateCollection(user.id, Number(id), body); + } + + @Delete(':id') + remove(@CurrentUser() user: User, @Param('id') id: string) { + this.collections.deleteCollection(user.id, Number(id)); + return { success: true }; + } +} diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts new file mode 100644 index 000000000..7740dc13d --- /dev/null +++ b/server/src/nest/collections/collections.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CollectionsController } from './collections.controller'; +import { CollectionsService } from './collections.service'; +import { CollectionsAddonGuard } from './collections-addon.guard'; + +@Module({ + controllers: [CollectionsController], + providers: [CollectionsService, CollectionsAddonGuard], +}) +export class CollectionsModule {} diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts new file mode 100644 index 000000000..e7f74e74e --- /dev/null +++ b/server/src/nest/collections/collections.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import * as svc from '../../services/collectionsService'; +import type { + CollectionCreateRequest, + CollectionUpdateRequest, + CollectionSavePlaceRequest, + CollectionPlaceUpdateRequest, + CollectionCopyToTripRequest, + CollectionStatus, +} from '@trek/shared'; + +/** + * Thin Nest wrapper around services/collectionsService. All access control lives + * in the legacy service (assertAccess / isOwner throw, fusion returns {error}); + * this just re-exposes the functions one-to-one. + */ +@Injectable() +export class CollectionsService { + listCollections(userId: number) { return svc.listCollections(userId); } + getCollection(userId: number, id: number) { return svc.getCollection(userId, id); } + createCollection(userId: number, body: CollectionCreateRequest) { return svc.createCollection(userId, body); } + updateCollection(userId: number, id: number, body: CollectionUpdateRequest) { return svc.updateCollection(userId, id, body); } + deleteCollection(userId: number, id: number) { return svc.deleteCollection(userId, id); } + reorderCollections(userId: number, orderedIds: number[]) { return svc.reorderCollections(userId, orderedIds); } + + savePlace(userId: number, body: CollectionSavePlaceRequest) { return svc.savePlace(userId, body); } + saveFromTripPlace(userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean) { + return svc.saveFromTripPlace(userId, collectionId, tripId, placeId, force); + } + updatePlace(userId: number, placeId: number, body: CollectionPlaceUpdateRequest) { return svc.updatePlace(userId, placeId, body); } + setStatus(userId: number, placeId: number, status: CollectionStatus) { return svc.setStatus(userId, placeId, status); } + deletePlace(userId: number, placeId: number) { return svc.deletePlace(userId, placeId); } + deletePlacesMany(userId: number, ids: number[]) { return svc.deletePlacesMany(userId, ids); } + + copyToTrip(userId: number, body: CollectionCopyToTripRequest) { return svc.copyToTrip(userId, body); } + findMembership(userId: number, query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }) { + return svc.findMembership(userId, query); + } + + // Access helpers used by the controller's owner guards. + assertAccess(userId: number, collectionId: number) { return svc.assertAccess(userId, collectionId); } + isOwner(userId: number, collectionId: number) { return svc.isOwner(userId, collectionId); } + + sendInvite(collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number) { + return svc.sendInvite(collectionId, inviterId, inviterUsername, inviterEmail, targetUserId); + } + acceptInvite(userId: number, collectionId: number, socketId?: string) { return svc.acceptInvite(userId, collectionId, socketId); } + declineInvite(userId: number, collectionId: number, socketId?: string) { return svc.declineInvite(userId, collectionId, socketId); } + cancelInvite(collectionId: number, ownerId: number, targetUserId: number) { return svc.cancelInvite(collectionId, ownerId, targetUserId); } + leaveCollection(userId: number, collectionId: number, socketId?: string) { return svc.leaveCollection(userId, collectionId, socketId); } + availableUsers(ownerId: number, collectionId: number) { return svc.availableUsers(ownerId, collectionId); } +} diff --git a/server/src/services/collectionsService.ts b/server/src/services/collectionsService.ts new file mode 100644 index 000000000..93c397dc5 --- /dev/null +++ b/server/src/services/collectionsService.ts @@ -0,0 +1,629 @@ +import { db, canAccessTrip } from '../db/database'; +import { broadcastToUser } from '../websocket'; +import { checkPermission } from './permissions'; +import type { + Collection, + CollectionDetailResponse, + CollectionListResponse, + CollectionMember, + CollectionMembership, + CollectionPlace, + CollectionCreateRequest, + CollectionUpdateRequest, + CollectionSavePlaceRequest, + CollectionSaveResult, + CollectionCopyToTripRequest, + CollectionStatus, +} from '@trek/shared'; + +// --------------------------------------------------------------------------- +// Errors — thrown as plain Errors carrying a status; TrekExceptionFilter maps +// `err.status` → that HTTP code with an `{ error: message }` body. +// --------------------------------------------------------------------------- + +function httpError(status: number, message: string): never { + const err = new Error(message) as Error & { status: number }; + err.status = status; + throw err; +} + +// --------------------------------------------------------------------------- +// Visibility — a user may see/edit a collection if they own it OR are an +// accepted member. Every read/write goes through assertAccess. +// --------------------------------------------------------------------------- + +export function accessibleCollectionIds(userId: number): number[] { + const rows = db.prepare(` + SELECT id FROM collections WHERE owner_id = ? + UNION + SELECT collection_id FROM collection_members WHERE user_id = ? AND status = 'accepted' + `).all(userId, userId) as { id: number }[]; + return rows.map(r => r.id); +} + +function isVisible(userId: number, collectionId: number): boolean { + const row = db.prepare(` + SELECT 1 FROM collections WHERE id = ? AND owner_id = ? + UNION + SELECT 1 FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted' + LIMIT 1 + `).get(collectionId, userId, collectionId, userId); + return !!row; +} + +export function assertAccess(userId: number, collectionId: number): void { + if (!isVisible(userId, collectionId)) httpError(404, 'Collection not found'); +} + +export function isOwner(userId: number, collectionId: number): boolean { + const row = db.prepare('SELECT 1 FROM collections WHERE id = ? AND owner_id = ?').get(collectionId, userId); + return !!row; +} + +function ownerOf(collectionId: number): number { + const row = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined; + if (!row) httpError(404, 'Collection not found'); + return row.owner_id; +} + +// --------------------------------------------------------------------------- +// Hydration helpers +// --------------------------------------------------------------------------- + +interface PlaceRow extends CollectionPlace { + category_name?: string | null; + category_color?: string | null; + category_icon?: string | null; +} + +function loadTagsByCollectionPlaceIds(placeIds: number[]): Record { + const out: Record = {}; + if (placeIds.length === 0) return out; + const placeholders = placeIds.map(() => '?').join(','); + const rows = db.prepare(` + SELECT cpt.collection_place_id AS pid, t.id, t.name, t.color + FROM collection_place_tags cpt + JOIN tags t ON t.id = cpt.tag_id + WHERE cpt.collection_place_id IN (${placeholders}) + `).all(...placeIds) as { pid: number; id: number; name: string; color: string }[]; + for (const r of rows) { + if (!out[r.pid]) out[r.pid] = []; + out[r.pid].push({ id: r.id, name: r.name, color: r.color }); + } + return out; +} + +function hydratePlaces(rows: PlaceRow[]): CollectionPlace[] { + const ids = rows.map(r => r.id); + const tagsByPlace = loadTagsByCollectionPlaceIds(ids); + return rows.map(r => { + const { category_name, category_color, category_icon, ...rest } = r; + return { + ...rest, + category: r.category_id + ? { id: r.category_id, name: category_name ?? '', color: category_color ?? null, icon: category_icon ?? null } + : undefined, + tags: tagsByPlace[r.id] || [], + } as CollectionPlace; + }); +} + +function getPlaceById(placeId: number): CollectionPlace { + const row = db.prepare(` + SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon + FROM collection_places cp + LEFT JOIN categories c ON cp.category_id = c.id + WHERE cp.id = ? + `).get(placeId) as PlaceRow | undefined; + if (!row) httpError(404, 'Place not found'); + return hydratePlaces([row])[0]; +} + +function collectionIdOfPlace(placeId: number): number { + const row = db.prepare('SELECT collection_id FROM collection_places WHERE id = ?').get(placeId) as { collection_id: number } | undefined; + if (!row) httpError(404, 'Place not found'); + return row.collection_id; +} + +function buildMembers(collectionId: number): CollectionMember[] { + const owner = db.prepare(` + SELECT u.id AS user_id, u.username, u.email, u.avatar + FROM collections col JOIN users u ON u.id = col.owner_id + WHERE col.id = ? + `).get(collectionId) as Omit | undefined; + const members = db.prepare(` + SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status + FROM collection_members cm JOIN users u ON u.id = cm.user_id + WHERE cm.collection_id = ? + ORDER BY u.username + `).all(collectionId) as Omit[]; + const result: CollectionMember[] = []; + if (owner) result.push({ ...owner, status: 'accepted', is_owner: true }); + for (const m of members) result.push({ ...m, is_owner: false }); + return result; +} + +function getCollectionRow(id: number): Collection { + const col = db.prepare('SELECT * FROM collections WHERE id = ?').get(id) as Collection | undefined; + if (!col) httpError(404, 'Collection not found'); + const placeCount = (db.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(id) as { n: number }).n; + return { ...col, place_count: placeCount, members: buildMembers(id) }; +} + +// --------------------------------------------------------------------------- +// Lists CRUD +// --------------------------------------------------------------------------- + +export function listCollections(userId: number): CollectionListResponse { + const ids = accessibleCollectionIds(userId); + const collections: Collection[] = ids + .map(id => { + const col = getCollectionRow(id); + return { ...col, is_owner: col.owner_id === userId }; + }) + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id); + + const incomingInvites = (db.prepare(` + SELECT cm.collection_id, c.name, u.id AS from_id, u.username AS from_username + FROM collection_members cm + JOIN collections c ON c.id = cm.collection_id + JOIN users u ON u.id = c.owner_id + WHERE cm.user_id = ? AND cm.status = 'pending' + `).all(userId) as { collection_id: number; name: string; from_id: number; from_username: string }[]) + .map(r => ({ collection_id: r.collection_id, name: r.name, from: { id: r.from_id, username: r.from_username } })); + + return { collections, incomingInvites }; +} + +export function getCollection(userId: number, id: number): CollectionDetailResponse { + assertAccess(userId, id); + const collection = getCollectionRow(id); + const rows = db.prepare(` + SELECT cp.*, c.name AS category_name, c.color AS category_color, c.icon AS category_icon + FROM collection_places cp + LEFT JOIN categories c ON cp.category_id = c.id + WHERE cp.collection_id = ? + ORDER BY cp.sort_order, cp.created_at + `).all(id) as PlaceRow[]; + return { collection: { ...collection, is_owner: collection.owner_id === userId }, places: hydratePlaces(rows) }; +} + +export function createCollection(userId: number, body: CollectionCreateRequest): Collection { + const max = (db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM collections WHERE owner_id = ?').get(userId) as { m: number }).m; + const result = db.prepare(` + INSERT INTO collections (owner_id, name, description, color, icon, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + userId, + body.name, + body.description ?? null, + body.color ?? '#6366f1', + body.icon ?? 'Bookmark', + max + 1, + ); + const col = getCollectionRow(Number(result.lastInsertRowid)); + return { ...col, is_owner: true }; +} + +export function updateCollection(userId: number, id: number, body: CollectionUpdateRequest): Collection { + assertAccess(userId, id); + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); } + if (body.description !== undefined) { updates.push('description = ?'); params.push(body.description ?? null); } + if (body.color !== undefined) { updates.push('color = ?'); params.push(body.color ?? null); } + if (body.icon !== undefined) { updates.push('icon = ?'); params.push(body.icon ?? null); } + if (body.sort_order !== undefined) { updates.push('sort_order = ?'); params.push(body.sort_order); } + if (updates.length > 0) { + updates.push("updated_at = CURRENT_TIMESTAMP"); + params.push(id); + db.prepare(`UPDATE collections SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + notifyCollectionUsers(id, undefined, 'collections:updated'); + const col = getCollectionRow(id); + return { ...col, is_owner: col.owner_id === userId }; +} + +export function deleteCollection(userId: number, id: number): void { + assertAccess(userId, id); + if (!isOwner(userId, id)) httpError(403, 'Only the owner can delete this list'); + + // Snapshot recipients BEFORE the cascade wipes collection_members. + const accepted = (db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(id) as { user_id: number }[]).map(r => r.user_id); + const pending = (db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'pending'").all(id) as { user_id: number }[]).map(r => r.user_id); + + db.prepare('DELETE FROM collections WHERE id = ?').run(id); // CASCADE drops members + places + tags + + [...new Set([...accepted, ...pending])] + .filter(uid => uid !== userId) + .forEach(uid => broadcastToUser(uid, { type: 'collections:deleted', collectionId: id })); +} + +export function reorderCollections(userId: number, orderedIds: number[]): void { + const visible = new Set(accessibleCollectionIds(userId)); + const stmt = db.prepare('UPDATE collections SET sort_order = ? WHERE id = ?'); + orderedIds.forEach((cid, index) => { + if (visible.has(cid)) stmt.run(index, cid); + }); +} + +// --------------------------------------------------------------------------- +// Dedup (collection-scoped ports of placeService helpers) +// --------------------------------------------------------------------------- + +const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m + +interface DedupSet { + names: Set; + coords: Array<{ lat: number; lng: number }>; +} + +function buildDedupSet(collectionId: number): DedupSet { + const rows = db.prepare('SELECT name, lat, lng FROM collection_places WHERE collection_id = ?').all(collectionId) as Array<{ + name: string | null; lat: number | null; lng: number | null; + }>; + const names = new Set(); + const coords: Array<{ lat: number; lng: number }> = []; + for (const row of rows) { + if (row.name) names.add(row.name.trim().toLowerCase()); + else if (row.lat != null && row.lng != null) coords.push({ lat: row.lat, lng: row.lng }); + } + return { names, coords }; +} + +function isCollectionPlaceDuplicate( + candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined }, + dedup: DedupSet, +): boolean { + const normalizedName = candidate.name?.trim().toLowerCase(); + if (normalizedName) return dedup.names.has(normalizedName); + if (candidate.lat != null && candidate.lng != null) { + return dedup.coords.some(c => + Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE && + Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE, + ); + } + return false; +} + +function findDuplicateCollectionPlace( + collectionId: number, + candidate: { name: string | null | undefined; lat: number | null | undefined; lng: number | null | undefined }, +): { id: number; name: string } | null { + const normalizedName = candidate.name?.trim().toLowerCase(); + if (normalizedName) { + const dup = db.prepare(` + SELECT id, name FROM collection_places + WHERE collection_id = ? AND lower(trim(name)) = ? + ORDER BY id ASC LIMIT 1 + `).get(collectionId, normalizedName) as { id: number; name: string } | undefined; + if (dup) return dup; + } + if (candidate.lat != null && candidate.lng != null) { + return (db.prepare(` + SELECT id, name FROM collection_places + WHERE collection_id = ? AND lat IS NOT NULL AND lng IS NOT NULL + AND abs(lat - ?) <= ? AND abs(lng - ?) <= ? + ORDER BY id ASC LIMIT 1 + `).get(collectionId, candidate.lat, COORD_DEDUP_TOLERANCE, candidate.lng, COORD_DEDUP_TOLERANCE) as { id: number; name: string } | undefined) || null; + } + return null; +} + +// --------------------------------------------------------------------------- +// Saved places CRUD +// --------------------------------------------------------------------------- + +function attachTags(collectionPlaceId: number, tagIds: number[] | undefined): void { + if (!tagIds || tagIds.length === 0) return; + const stmt = db.prepare('INSERT OR IGNORE INTO collection_place_tags (collection_place_id, tag_id) VALUES (?, ?)'); + for (const tid of tagIds) stmt.run(collectionPlaceId, tid); +} + +export function savePlace(userId: number, body: CollectionSavePlaceRequest): CollectionSaveResult { + assertAccess(userId, body.collection_id); + + if (!body.force) { + const dup = findDuplicateCollectionPlace(body.collection_id, { name: body.name, lat: body.lat, lng: body.lng }); + if (dup) return { duplicate: true, duplicateOf: dup }; + } + + const ownerId = ownerOf(body.collection_id); + const result = db.prepare(` + INSERT INTO collection_places ( + collection_id, owner_id, saved_by, name, description, lat, lng, address, + category_id, price, currency, notes, image_url, google_place_id, google_ftid, + osm_id, website, phone, status, source_trip_id, source_place_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + body.collection_id, ownerId, userId, + body.name, body.description ?? null, body.lat ?? null, body.lng ?? null, body.address ?? null, + body.category_id ?? null, body.price ?? null, body.currency ?? null, body.notes ?? null, + body.image_url ?? null, body.google_place_id ?? null, body.google_ftid ?? null, + body.osm_id ?? null, body.website ?? null, body.phone ?? null, + body.status ?? 'idea', body.source_trip_id ?? null, body.source_place_id ?? null, + ); + + const placeId = Number(result.lastInsertRowid); + attachTags(placeId, body.tag_ids); + notifyCollectionUsers(body.collection_id, undefined, 'collections:updated'); + return { place: getPlaceById(placeId) }; +} + +export function saveFromTripPlace( + userId: number, collectionId: number, tripId: number, placeId: number, force?: boolean, +): CollectionSaveResult { + assertAccess(userId, collectionId); + if (!canAccessTrip(tripId, userId)) httpError(404, 'Trip not found'); + + const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record | undefined; + if (!place) httpError(404, 'Place not found'); + + return savePlace(userId, { + collection_id: collectionId, + name: place.name as string, + description: (place.description as string | null) ?? null, + lat: (place.lat as number | null) ?? null, + lng: (place.lng as number | null) ?? null, + address: (place.address as string | null) ?? null, + category_id: (place.category_id as number | null) ?? null, + price: (place.price as number | null) ?? null, + currency: (place.currency as string | null) ?? null, + notes: (place.notes as string | null) ?? null, + image_url: (place.image_url as string | null) ?? null, + google_place_id: (place.google_place_id as string | null) ?? null, + google_ftid: (place.google_ftid as string | null) ?? null, + osm_id: (place.osm_id as string | null) ?? null, + website: (place.website as string | null) ?? null, + phone: (place.phone as string | null) ?? null, + source_trip_id: tripId, + source_place_id: placeId, + force, + }); +} + +export function updatePlace(userId: number, placeId: number, body: import('@trek/shared').CollectionPlaceUpdateRequest): CollectionPlace { + const currentCollection = collectionIdOfPlace(placeId); + assertAccess(userId, currentCollection); + + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (body.name !== undefined) { updates.push('name = ?'); params.push(body.name); } + if (body.notes !== undefined) { updates.push('notes = ?'); params.push(body.notes ?? null); } + if (body.status !== undefined) { updates.push('status = ?'); params.push(body.status); } + if (body.category_id !== undefined) { updates.push('category_id = ?'); params.push(body.category_id ?? null); } + + let movedTo: number | null = null; + if (body.collection_id !== undefined && body.collection_id !== currentCollection) { + assertAccess(userId, body.collection_id); + updates.push('collection_id = ?'); params.push(body.collection_id); + updates.push('owner_id = ?'); params.push(ownerOf(body.collection_id)); + movedTo = body.collection_id; + } + + if (updates.length > 0) { + updates.push("updated_at = CURRENT_TIMESTAMP"); + params.push(placeId); + db.prepare(`UPDATE collection_places SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + + if (body.tag_ids !== undefined) { + db.prepare('DELETE FROM collection_place_tags WHERE collection_place_id = ?').run(placeId); + attachTags(placeId, body.tag_ids); + } + + notifyCollectionUsers(currentCollection, undefined, 'collections:updated'); + if (movedTo) notifyCollectionUsers(movedTo, undefined, 'collections:updated'); + return getPlaceById(placeId); +} + +export function setStatus(userId: number, placeId: number, status: CollectionStatus): CollectionPlace { + const collectionId = collectionIdOfPlace(placeId); + assertAccess(userId, collectionId); + db.prepare("UPDATE collection_places SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, placeId); + notifyCollectionUsers(collectionId, undefined, 'collections:updated'); + return getPlaceById(placeId); +} + +export function deletePlace(userId: number, placeId: number): void { + const collectionId = collectionIdOfPlace(placeId); + assertAccess(userId, collectionId); + db.prepare('DELETE FROM collection_places WHERE id = ?').run(placeId); // CASCADE drops tags. NO photo-cache reclaim. + notifyCollectionUsers(collectionId, undefined, 'collections:updated'); +} + +export function deletePlacesMany(userId: number, ids: number[]): number[] { + const deleted: number[] = []; + const touched = new Set(); + for (const id of ids) { + const collectionId = collectionIdOfPlace(id); + assertAccess(userId, collectionId); + db.prepare('DELETE FROM collection_places WHERE id = ?').run(id); + deleted.push(id); + touched.add(collectionId); + } + touched.forEach(cid => notifyCollectionUsers(cid, undefined, 'collections:updated')); + return deleted; +} + +// --------------------------------------------------------------------------- +// Copy to trip +// --------------------------------------------------------------------------- + +export function copyToTrip(userId: number, body: CollectionCopyToTripRequest): { copied: number; skipped: { id: number; name: string }[] } { + const trip = canAccessTrip(body.trip_id, userId) as { id: number; user_id: number } | undefined; + if (!trip) httpError(404, 'Trip not found'); + const role = (db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined)?.role ?? 'user'; + if (!checkPermission('place_edit', role, trip.user_id, userId, trip.user_id !== userId)) { + httpError(403, 'Not allowed to edit this trip'); + } + + // Visibility on every SOURCE place — no cross-user exfiltration via copy. + const sources: Array<{ id: number; name: string; description: string | null; lat: number | null; lng: number | null; + address: string | null; category_id: number | null; price: number | null; currency: string | null; + notes: string | null; image_url: string | null; google_place_id: string | null; google_ftid: string | null; + osm_id: string | null; website: string | null; phone: string | null; collection_id: number }> = []; + for (const pid of body.place_ids) { + const row = db.prepare(` + SELECT id, collection_id, name, description, lat, lng, address, category_id, price, currency, + notes, image_url, google_place_id, google_ftid, osm_id, website, phone + FROM collection_places WHERE id = ? + `).get(pid) as (typeof sources)[number] | undefined; + if (!row) httpError(404, 'Place not found'); + assertAccess(userId, row.collection_id); + sources.push(row); + } + + // Trip dedup set (mirrors placeService.buildDedupSet). + const existing = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(body.trip_id) as Array<{ name: string | null; lat: number | null; lng: number | null }>; + const dedup: DedupSet = { names: new Set(), coords: [] }; + for (const r of existing) { + if (r.name) dedup.names.add(r.name.trim().toLowerCase()); + else if (r.lat != null && r.lng != null) dedup.coords.push({ lat: r.lat, lng: r.lng }); + } + + const insertPlace = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, + currency, notes, image_url, google_place_id, google_ftid, website, phone, osm_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + + let copied = 0; + const skipped: { id: number; name: string }[] = []; + for (const s of sources) { + if (!body.force && isCollectionPlaceDuplicate({ name: s.name, lat: s.lat, lng: s.lng }, dedup)) { + skipped.push({ id: s.id, name: s.name }); + continue; + } + const res = insertPlace.run( + body.trip_id, s.name, s.description, s.lat, s.lng, s.address, s.category_id, s.price, + s.currency, s.notes, s.image_url, s.google_place_id, s.google_ftid, s.website, s.phone, s.osm_id, + ); + const newPlaceId = Number(res.lastInsertRowid); + const tagIds = db.prepare('SELECT tag_id FROM collection_place_tags WHERE collection_place_id = ?').all(s.id) as { tag_id: number }[]; + for (const t of tagIds) insertTag.run(newPlaceId, t.tag_id); + + if (s.name) dedup.names.add(s.name.trim().toLowerCase()); + else if (s.lat != null && s.lng != null) dedup.coords.push({ lat: s.lat, lng: s.lng }); + copied++; + } + return { copied, skipped }; +} + +// --------------------------------------------------------------------------- +// Library-wide membership lookup (inspector indicator) +// --------------------------------------------------------------------------- + +export function findMembership( + userId: number, + query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number }, +): CollectionMembership { + const ids = accessibleCollectionIds(userId); + if (ids.length === 0) return { saved: false, lists: [] }; + const placeholders = ids.map(() => '?').join(','); + + const conditions: string[] = []; + const params: (string | number)[] = [...ids]; + if (query.google_place_id) { conditions.push('cp.google_place_id = ?'); params.push(query.google_place_id); } + if (query.google_ftid) { conditions.push('cp.google_ftid = ?'); params.push(query.google_ftid); } + if (query.name && query.name.trim()) { conditions.push('lower(trim(cp.name)) = ?'); params.push(query.name.trim().toLowerCase()); } + if (query.lat != null && query.lng != null) { + conditions.push('(cp.lat IS NOT NULL AND cp.lng IS NOT NULL AND abs(cp.lat - ?) <= ? AND abs(cp.lng - ?) <= ?)'); + params.push(query.lat, COORD_DEDUP_TOLERANCE, query.lng, COORD_DEDUP_TOLERANCE); + } + if (conditions.length === 0) return { saved: false, lists: [] }; + + const rows = db.prepare(` + SELECT cp.id AS place_id, cp.collection_id, c.name + FROM collection_places cp + JOIN collections c ON c.id = cp.collection_id + WHERE cp.collection_id IN (${placeholders}) AND (${conditions.join(' OR ')}) + `).all(...params) as { place_id: number; collection_id: number; name: string }[]; + + return { saved: rows.length > 0, lists: rows.map(r => ({ collection_id: r.collection_id, name: r.name, place_id: r.place_id })) }; +} + +// --------------------------------------------------------------------------- +// WebSocket notify +// --------------------------------------------------------------------------- + +export function notifyCollectionUsers(collectionId: number, excludeSid: string | undefined, event = 'collections:updated'): void { + const owner = db.prepare('SELECT owner_id FROM collections WHERE id = ?').get(collectionId) as { owner_id: number } | undefined; + if (!owner) return; + const userIds = [owner.owner_id]; + const members = db.prepare("SELECT user_id FROM collection_members WHERE collection_id = ? AND status = 'accepted'").all(collectionId) as { user_id: number }[]; + members.forEach(m => userIds.push(m.user_id)); + userIds.forEach(id => broadcastToUser(id, { type: event, collectionId }, excludeSid)); +} + +// --------------------------------------------------------------------------- +// Fusion invitations (mirror vacayService, dropping the one-fusion guards) +// --------------------------------------------------------------------------- + +export function sendInvite( + collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, +): { error?: string; status?: number } { + if (!isOwner(inviterId, collectionId)) return { error: 'Not allowed', status: 403 }; + if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; + + const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId); + if (!targetUser) return { error: 'User not found', status: 404 }; + + const existing = db.prepare('SELECT id, status FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, targetUserId) as { id: number; status: string } | undefined; + if (existing) { + if (existing.status === 'accepted') return { error: 'Already a member', status: 400 }; + if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; + } + + db.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'pending')").run(collectionId, targetUserId); + + broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId }); + + import('../services/notificationService').then(({ send }) => { + send({ event: 'collection_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, collectionId: String(collectionId) } }).catch(() => {}); + }).catch(() => {}); + + return {}; +} + +export function acceptInvite(userId: number, collectionId: number, socketId: string | undefined): { error?: string; status?: number } { + const invite = db.prepare("SELECT id FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").get(collectionId, userId) as { id: number } | undefined; + if (!invite) return { error: 'No pending invite', status: 404 }; + db.prepare("UPDATE collection_members SET status = 'accepted' WHERE id = ?").run(invite.id); + notifyCollectionUsers(collectionId, socketId, 'collections:accepted'); + return {}; +} + +export function declineInvite(userId: number, collectionId: number, socketId: string | undefined): void { + db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").run(collectionId, userId); + notifyCollectionUsers(collectionId, socketId, 'collections:declined'); +} + +export function cancelInvite(collectionId: number, ownerId: number, targetUserId: number): void { + if (!isOwner(ownerId, collectionId)) httpError(403, 'Not allowed'); + db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'pending'").run(collectionId, targetUserId); + broadcastToUser(targetUserId, { type: 'collections:cancelled', collectionId }); +} + +export function leaveCollection(userId: number, collectionId: number, socketId: string | undefined): void { + if (isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list'); + db.prepare("DELETE FROM collection_members WHERE collection_id = ? AND user_id = ? AND status = 'accepted'").run(collectionId, userId); + notifyCollectionUsers(collectionId, socketId, 'collections:left'); +} + +export function availableUsers(ownerId: number, collectionId: number): { id: number; username: string }[] { + return db.prepare(` + SELECT u.id, u.username FROM users u + WHERE u.id != ? + AND u.id NOT IN (SELECT user_id FROM collection_members WHERE collection_id = ?) + AND u.is_guest = 0 + ORDER BY u.username + `).all(ownerId, collectionId) as { id: number; username: string }[]; +} + +export function findMembershipForUser(userId: number, collectionId: number): { is_member: boolean; is_owner: boolean; status: string | null } { + if (isOwner(userId, collectionId)) return { is_member: true, is_owner: true, status: 'accepted' }; + const row = db.prepare('SELECT status FROM collection_members WHERE collection_id = ? AND user_id = ?').get(collectionId, userId) as { status: string } | undefined; + return { is_member: row?.status === 'accepted', is_owner: false, status: row?.status ?? null }; +} diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index 6efe538b6..1ed45e402 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -12,6 +12,7 @@ export type NotifEventType = | 'trip_reminder' | 'todo_due' | 'vacay_invite' + | 'collection_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged' @@ -33,6 +34,7 @@ const IMPLEMENTED_COMBOS: Record = { trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'], todo_due: ['inapp', 'email', 'webhook', 'ntfy'], vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'], + collection_invite: ['inapp', 'email', 'webhook', 'ntfy'], photos_shared: ['inapp', 'email', 'webhook', 'ntfy'], collab_message: ['inapp', 'email', 'webhook', 'ntfy'], packing_tagged: ['inapp', 'email', 'webhook', 'ntfy'], diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index 67f02f2a5..071becdfa 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -96,6 +96,13 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view_vacay', navigateTarget: p => (p.planId ? `/vacay/${p.planId}` : null), }, + collection_invite: { + inAppType: 'navigate', + titleKey: 'notif.collection_invite.title', + textKey: 'notif.collection_invite.text', + navigateTextKey: 'notif.action.view_collection', + navigateTarget: p => (p.collectionId ? `/collections/${p.collectionId}` : '/collections'), + }, photos_shared: { inAppType: 'navigate', titleKey: 'notif.photos_shared.title', diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts index 216b1aeae..d16484152 100644 --- a/server/src/services/placePhotoCache.ts +++ b/server/src/services/placePhotoCache.ts @@ -148,9 +148,18 @@ export function serveFilePath(placeId: string): string | null { // Google place_id (the dedup key) or by the stable proxy URL stored in image_url // (covers coords: pseudo-ids, which never have a google_place_id). function isReferenced(placeId: string): boolean { + // A collection-saved place copies image_url = proxyUrl(google_place_id) and/or + // the google_place_id itself, so collection_places must count as a referencing + // table — otherwise the nightly sweep + trip-place delete would evict a photo + // still shown on a collection thumbnail (#1081 photo-cache pitfall). const row = db - .prepare('SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1') - .get(placeId, proxyUrl(placeId)); + .prepare( + `SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? + UNION ALL + SELECT 1 FROM collection_places WHERE google_place_id = ? OR image_url = ? + LIMIT 1`, + ) + .get(placeId, proxyUrl(placeId), placeId, proxyUrl(placeId)); return !!row; } diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts new file mode 100644 index 000000000..4207d8600 --- /dev/null +++ b/server/tests/e2e/collections.e2e.test.ts @@ -0,0 +1,203 @@ +/** + * Collections e2e — drives /api/addons/collections through the REAL JwtAuthGuard + * AND the real collectionsService against a temp SQLite db (full schema). Only the + * addon flag, websocket and notification send are mocked. Covers: the addon gate + * (404 before auth), auth, CRUD happy paths, invite/accept/decline, copy-to-trip, + * cross-user 404s and the non-owner 403 on /:id/available-users (no enumeration). + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare('SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)').get(userId, tripId, userId), + isOwner: () => false, +})); + +const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); +vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser, createTrip, createCategory } from '../helpers/factories'; +import { CollectionsModule } from '../../src/nest/collections/collections.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +describe('Collections e2e (real auth guard + real service + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + let ownerId: number; + let otherId: number; + let tripId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + createTables(db as never); + runMigrations(db as never); + ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.example' }).user.id; + otherId = createUser(db as never, { username: 'other', email: 'other@test.example' }).user.id; + createCategory(db as never); + tripId = createTrip(db as never, ownerId).id; + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + isAddonEnabled.mockReturnValue(true); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── Addon gate ─────────────────────────────────────────────────────────── + it('COLLECTIONS-E2E-001: addon disabled → 404 before auth (no cookie)', async () => { + isAddonEnabled.mockReturnValue(false); + const res = await request(server).get('/api/addons/collections'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Collections addon is not enabled' }); + }); + + it('COLLECTIONS-E2E-002: addon disabled → 404 on a deep route too', async () => { + isAddonEnabled.mockReturnValue(false); + expect((await request(server).post('/api/addons/collections').send({ name: 'X' })).status).toBe(404); + expect((await request(server).get('/api/addons/collections/1')).status).toBe(404); + }); + + it('COLLECTIONS-E2E-003: addon enabled but no cookie → 401', async () => { + expect((await request(server).get('/api/addons/collections')).status).toBe(401); + }); + + // ── CRUD happy path ────────────────────────────────────────────────────── + it('COLLECTIONS-E2E-010: create → list → get a collection', async () => { + const created = await request(server).post('/api/addons/collections') + .set('Cookie', sessionCookie(ownerId)).send({ name: 'Italy' }); + expect(created.status).toBe(201); + expect(created.body.name).toBe('Italy'); + const id = created.body.id; + + const list = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(ownerId)); + expect(list.status).toBe(200); + expect(list.body.collections.map((c: { id: number }) => c.id)).toContain(id); + expect(list.body).toHaveProperty('incomingInvites'); + + const detail = await request(server).get(`/api/addons/collections/${id}`).set('Cookie', sessionCookie(ownerId)); + expect(detail.status).toBe(200); + expect(detail.body.collection.id).toBe(id); + expect(detail.body.places).toEqual([]); + }); + + it('COLLECTIONS-E2E-011: save a place (200) then copy it to a trip', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Trip plan' })).body; + const saved = await request(server).post('/api/addons/collections/places') + .set('Cookie', sessionCookie(ownerId)).set('X-Socket-Id', 'sock-1').send({ collection_id: col.id, name: 'Trevi Fountain', lat: 41.9, lng: 12.48 }); + expect(saved.status).toBe(200); + expect(saved.body.place.name).toBe('Trevi Fountain'); + + const copy = await request(server).post('/api/addons/collections/copy-to-trip') + .set('Cookie', sessionCookie(ownerId)).send({ trip_id: tripId, place_ids: [saved.body.place.id] }); + expect(copy.status).toBe(200); + expect(copy.body.copied).toBe(1); + const placed = db.prepare("SELECT reservation_status FROM places WHERE trip_id = ? AND name = 'Trevi Fountain'").get(tripId) as { reservation_status: string }; + expect(placed.reservation_status).toBe('none'); // itinerary defaults + }); + + // ── Cross-user isolation ───────────────────────────────────────────────── + it('COLLECTIONS-E2E-020: a stranger gets 404 on someone else’s collection', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Private' })).body; + const res = await request(server).get(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId)); + expect(res.status).toBe(404); + }); + + // ── Fusion ─────────────────────────────────────────────────────────────── + it('COLLECTIONS-E2E-030: invite → accept → member sees the list → decline path', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Shared' })).body; + + const invite = await request(server).post('/api/addons/collections/invite') + .set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId }); + expect(invite.status).toBe(200); + + // recipient sees it as an incoming invite + const inbox = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(otherId)); + expect(inbox.body.incomingInvites.map((i: { collection_id: number }) => i.collection_id)).toContain(col.id); + + const accept = await request(server).post('/api/addons/collections/invite/accept') + .set('Cookie', sessionCookie(otherId)).set('X-Socket-Id', 'sock-9').send({ collection_id: col.id }); + expect(accept.status).toBe(200); + + const memberList = await request(server).get('/api/addons/collections').set('Cookie', sessionCookie(otherId)); + expect(memberList.body.collections.map((c: { id: number }) => c.id)).toContain(col.id); + + const leave = await request(server).post('/api/addons/collections/leave') + .set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id }); + expect(leave.status).toBe(200); + }); + + it('COLLECTIONS-E2E-031: invite is owner-only (non-owner → 403, not 404 leak after access)', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'OwnerOnly' })).body; + // a complete stranger cannot even see it → 404 + const stranger = await request(server).post('/api/addons/collections/invite') + .set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id, user_id: ownerId }); + expect(stranger.status).toBe(404); + }); + + // ── available-users owner guard ────────────────────────────────────────── + it('COLLECTIONS-E2E-040: /:id/available-users — owner 200, stranger 404 (no enumeration)', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Members' })).body; + + const ok = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(ownerId)); + expect(ok.status).toBe(200); + expect(Array.isArray(ok.body.users)).toBe(true); + expect(ok.body.users.map((u: { id: number }) => u.id)).not.toContain(ownerId); + + const denied = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(otherId)); + expect(denied.status).toBe(404); // not visible → 404 (never reveals existence to a non-member) + }); + + it('COLLECTIONS-E2E-041: an accepted member (non-owner) hitting available-users gets 403', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Members2' })).body; + await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId }); + await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id }); + + const res = await request(server).get(`/api/addons/collections/${col.id}/available-users`).set('Cookie', sessionCookie(otherId)); + expect(res.status).toBe(403); // visible (member) but not owner + }); + + // ── delete ─────────────────────────────────────────────────────────────── + it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => { + const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body; + await request(server).post('/api/addons/collections/invite').set('Cookie', sessionCookie(ownerId)).send({ collection_id: col.id, user_id: otherId }); + await request(server).post('/api/addons/collections/invite/accept').set('Cookie', sessionCookie(otherId)).send({ collection_id: col.id }); + + expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(otherId))).status).toBe(403); + expect((await request(server).delete(`/api/addons/collections/${col.id}`).set('Cookie', sessionCookie(ownerId))).status).toBe(200); + expect(db.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 }); + }); +}); diff --git a/server/tests/unit/services/collectionsService.test.ts b/server/tests/unit/services/collectionsService.test.ts new file mode 100644 index 000000000..22b4100e7 --- /dev/null +++ b/server/tests/unit/services/collectionsService.test.ts @@ -0,0 +1,484 @@ +/** + * Unit tests for collectionsService (COLLECTIONS-SVC-001 … 040). + * Real in-memory SQLite (full schema + migrations) so the SQL — owner/member + * visibility, the collection-scoped dedup, the fusion state machine and the + * widened photo-cache reference check — is exercised faithfully. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock, broadcastToUser } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const broadcastToUser = vi.fn(); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number | string, userId: number) => + db + .prepare( + 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', + ) + .get(userId, tripId, userId), + isOwner: () => false, + }; + return { testDb: db, dbMock: mock, broadcastToUser }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcastToUser, broadcast: vi.fn() })); +const notifSend = vi.fn().mockResolvedValue(undefined); +vi.mock('../../../src/services/notificationService', () => ({ send: notifSend })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { createUser, createTrip, createPlace, createCategory, createTag, addTripMember } from '../../helpers/factories'; +import * as svc from '../../../src/services/collectionsService'; +import { removeIfUnreferenced } from '../../../src/services/placePhotoCache'; + +function clearCollections() { + testDb.exec(` + DELETE FROM collection_place_tags; + DELETE FROM collection_places; + DELETE FROM collection_members; + DELETE FROM collections; + DELETE FROM google_place_photo_meta; + DELETE FROM place_tags; + DELETE FROM places; + DELETE FROM trip_members; + DELETE FROM trips; + DELETE FROM users; + `); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + clearCollections(); + broadcastToUser.mockClear(); + notifSend.mockClear(); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── Lists CRUD + visibility ────────────────────────────────────────────────── + +describe('collections CRUD + visibility', () => { + it('COLLECTIONS-SVC-001: createCollection + listCollections is owner-scoped', () => { + const a = createUser(testDb).user; + const b = createUser(testDb).user; + const col = svc.createCollection(a.id, { name: 'Tokyo' }); + expect(col.is_owner).toBe(true); + expect(col.owner_id).toBe(a.id); + + expect(svc.listCollections(a.id).collections).toHaveLength(1); + expect(svc.listCollections(b.id).collections).toHaveLength(0); + }); + + it('COLLECTIONS-SVC-002: getCollection 404 for a non-member', () => { + const a = createUser(testDb).user; + const b = createUser(testDb).user; + const col = svc.createCollection(a.id, { name: 'Private' }); + expect(() => svc.getCollection(b.id, col.id)).toThrow(); + try { svc.getCollection(b.id, col.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } + }); + + it('COLLECTIONS-SVC-003: updateCollection renames; reorder only touches visible rows', () => { + const a = createUser(testDb).user; + const col = svc.createCollection(a.id, { name: 'Old' }); + const updated = svc.updateCollection(a.id, col.id, { name: 'New' }); + expect(updated.name).toBe('New'); + + const b = createUser(testDb).user; + const other = svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0 + svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1 + const otherRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(other.id) as { sort_order: number }; + const colRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(col.id) as { sort_order: number }; + expect(otherRow.sort_order).toBe(0); // untouched — not visible to a + expect(colRow.sort_order).toBe(1); // reordered to its index in the visible-filtered list + }); +}); + +// ── Saved places + dedup ───────────────────────────────────────────────────── + +describe('saved places + dedup', () => { + it('COLLECTIONS-SVC-010: savePlace sets owner_id=owner, saved_by=caller, no itinerary cols', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared' }); + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); + + const res = svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 }); + expect(res.place).toBeDefined(); + const row = testDb.prepare('SELECT * FROM collection_places WHERE id = ?').get(res.place!.id) as Record; + expect(row.owner_id).toBe(owner.id); + expect(row.saved_by).toBe(member.id); + expect('reservation_status' in row).toBe(false); + expect('place_time' in row).toBe(false); + }); + + it('COLLECTIONS-SVC-011: second identical save is a duplicate; force inserts', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Dedup' }); + svc.savePlace(u.id, { collection_id: col.id, name: 'Eiffel Tower' }); + + const dup = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower' }); + expect(dup.duplicate).toBe(true); + expect(dup.duplicateOf?.name).toBe('Eiffel Tower'); + + const forced = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true }); + expect(forced.place).toBeDefined(); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 2 }); + }); + + it('COLLECTIONS-SVC-012: savePlace attaches tags', () => { + const u = createUser(testDb).user; + const tag = createTag(testDb, u.id, { name: 'food' }); + const col = svc.createCollection(u.id, { name: 'Tagged' }); + const res = svc.savePlace(u.id, { collection_id: col.id, name: 'Ramen', tag_ids: [tag.id] }); + expect(res.place!.tags?.map((t) => t.name)).toContain('food'); + }); + + it('COLLECTIONS-SVC-013: savePlace rejects an inaccessible collection (404)', () => { + const a = createUser(testDb).user; + const b = createUser(testDb).user; + const col = svc.createCollection(a.id, { name: 'Locked' }); + expect(() => svc.savePlace(b.id, { collection_id: col.id, name: 'X' })).toThrow(); + }); +}); + +// ── save-from-trip provenance + IDOR ───────────────────────────────────────── + +describe('saveFromTripPlace', () => { + it('COLLECTIONS-SVC-014: records provenance from a readable trip', () => { + const u = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, u.id); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + const col = svc.createCollection(u.id, { name: 'From trip' }); + + const res = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id); + expect(res.place!.source_trip_id).toBe(trip.id); + expect(res.place!.source_place_id).toBe(place.id); + expect(res.place!.name).toBe('Louvre'); + }); + + it('COLLECTIONS-SVC-015: rejects a trip the user cannot read (no IDOR)', () => { + const owner = createUser(testDb).user; + const stranger = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, owner.id); + const place = createPlace(testDb, trip.id, { name: 'Secret' }); + const col = svc.createCollection(stranger.id, { name: 'Mine' }); + + expect(() => svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id)).toThrow(); + try { svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } + }); +}); + +// ── status + move ──────────────────────────────────────────────────────────── + +describe('status + updatePlace move', () => { + it('COLLECTIONS-SVC-016: setStatus cycles idea→want→visited', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'S' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Place' }).place!; + expect(p.status).toBe('idea'); + expect(svc.setStatus(u.id, p.id, 'want').status).toBe('want'); + expect(svc.setStatus(u.id, p.id, 'visited').status).toBe('visited'); + }); + + it('COLLECTIONS-SVC-017: updatePlace moves to another list (asserts access on target, resets owner_id)', () => { + const owner = createUser(testDb).user; + const a = svc.createCollection(owner.id, { name: 'A' }); + const targetOwner = createUser(testDb).user; + const b = svc.createCollection(targetOwner.id, { name: 'B' }); + // owner is also an accepted member of b so the move target is visible to them + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(b.id, owner.id); + + const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' }).place!; + const moved = svc.updatePlace(owner.id, p.id, { collection_id: b.id }); + expect(moved.collection_id).toBe(b.id); + const row = testDb.prepare('SELECT owner_id FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number }; + expect(row.owner_id).toBe(targetOwner.id); // reset to the target collection's owner + }); + + it('COLLECTIONS-SVC-018: updatePlace move to an inaccessible target is rejected', () => { + const owner = createUser(testDb).user; + const a = svc.createCollection(owner.id, { name: 'A' }); + const stranger = createUser(testDb).user; + const b = svc.createCollection(stranger.id, { name: 'B' }); + const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'X' }).place!; + expect(() => svc.updatePlace(owner.id, p.id, { collection_id: b.id })).toThrow(); + }); +}); + +// ── copy to trip ───────────────────────────────────────────────────────────── + +describe('copyToTrip', () => { + it('COLLECTIONS-SVC-020: reduced INSERT (itinerary defaults), skips dups, copies tags', () => { + const u = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, u.id); + const tag = createTag(testDb, u.id, { name: 'must-see' }); + const col = svc.createCollection(u.id, { name: 'Plan' }); + const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'Colosseum', tag_ids: [tag.id] }).place!; + + // pre-existing trip place that should make a duplicate + createPlace(testDb, trip.id, { name: 'Pantheon' }); + const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' }).place!; + + const res = svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); + expect(res.copied).toBe(1); + expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); + + const inserted = testDb.prepare("SELECT * FROM places WHERE trip_id = ? AND name = 'Colosseum'").get(trip.id) as Record; + expect(inserted.reservation_status).toBe('none'); // itinerary column took the table default + expect(inserted.duration_minutes).toBe(60); + const tagLink = testDb.prepare('SELECT COUNT(*) n FROM place_tags WHERE place_id = ?').get(inserted.id); + expect(tagLink).toEqual({ n: 1 }); + }); + + it('COLLECTIONS-SVC-021: rejects place_ids from a collection the user cannot see', () => { + const owner = createUser(testDb).user; + const stranger = createUser(testDb).user; + createCategory(testDb); + const hidden = svc.createCollection(owner.id, { name: 'Hidden' }); + const p = svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' }).place!; + const trip = createTrip(testDb, stranger.id); + + expect(() => svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); + }); + + it('COLLECTIONS-SVC-022: rejects a trip the user cannot edit (403/404)', () => { + const u = createUser(testDb).user; + const owner2 = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, owner2.id); // u has no access + const col = svc.createCollection(u.id, { name: 'C' }); + const p = svc.savePlace(u.id, { collection_id: col.id, name: 'X' }).place!; + expect(() => svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); + }); + + it('COLLECTIONS-SVC-023: a trip MEMBER can copy (place_edit allowed)', () => { + const owner2 = createUser(testDb).user; + const member = createUser(testDb).user; + createCategory(testDb); + const trip = createTrip(testDb, owner2.id); + addTripMember(testDb, trip.id, member.id); + const col = svc.createCollection(member.id, { name: 'C' }); + const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' }).place!; + const res = svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); + expect(res.copied).toBe(1); + }); +}); + +// ── delete + delete-many ───────────────────────────────────────────────────── + +describe('delete places', () => { + it('COLLECTIONS-SVC-024: deletePlace + deletePlacesMany assert access', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'D' }); + const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!; + const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!; + svc.deletePlace(u.id, p1.id); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 1 }); + expect(svc.deletePlacesMany(u.id, [p2.id])).toEqual([p2.id]); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + }); +}); + +// ── Fusion state machine ───────────────────────────────────────────────────── + +describe('fusion invitations', () => { + function setup() { + const owner = createUser(testDb).user; + const target = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Fusion' }); + return { owner, target, col }; + } + + it('COLLECTIONS-SVC-030: sendInvite — self 400, unknown 404, non-owner 403, happy path', async () => { + const { owner, target, col } = setup(); + expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, owner.id).status).toBe(400); + expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, 99999).status).toBe(404); + expect(svc.sendInvite(col.id, target.id, target.username, target.email, owner.id).status).toBe(403); // non-owner inviter + + const ok = svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect(ok.error).toBeUndefined(); + expect(broadcastToUser).toHaveBeenCalledWith(target.id, expect.objectContaining({ type: 'collections:invite' })); + // the notification send is fire-and-forget via a dynamic import — flush microtasks. + await vi.waitFor(() => expect(notifSend).toHaveBeenCalledWith(expect.objectContaining({ event: 'collection_invite', targetId: target.id }))); + }); + + it('COLLECTIONS-SVC-031: double-invite while pending → 400; existing member → 400', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).status).toBe(400); + svc.acceptInvite(target.id, col.id, undefined); + expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).error).toBe('Already a member'); + }); + + it('COLLECTIONS-SVC-032: acceptInvite — 404 with no pending; flips to accepted → member now sees list', () => { + const { owner, target, col } = setup(); + expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect(svc.acceptInvite(target.id, col.id, undefined).error).toBeUndefined(); + expect(svc.listCollections(target.id).collections.map((c) => c.id)).toContain(col.id); + }); + + it('COLLECTIONS-SVC-033: accept-after-cancel → 404 (no orphan accept)', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + svc.cancelInvite(col.id, owner.id, target.id); + expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); + }); + + it('COLLECTIONS-SVC-034: declineInvite removes the pending row', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + svc.declineInvite(target.id, col.id, undefined); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + }); + + it('COLLECTIONS-SVC-035: cancelInvite is owner-only', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect(() => svc.cancelInvite(col.id, target.id, target.id)).toThrow(); // non-owner + svc.cancelInvite(col.id, owner.id, target.id); // owner ok + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + }); + + it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + svc.acceptInvite(target.id, col.id, undefined); + svc.leaveCollection(target.id, col.id, undefined); + expect(svc.listCollections(target.id).collections.map((c) => c.id)).not.toContain(col.id); + + expect(() => svc.leaveCollection(owner.id, col.id, undefined)).toThrow(); + try { svc.leaveCollection(owner.id, col.id, undefined); } catch (e) { expect((e as { status: number }).status).toBe(400); } + }); + + it('COLLECTIONS-SVC-037: availableUsers is scoped to THIS collection only (no one-fusion bug)', () => { + const owner = createUser(testDb).user; + const target = createUser(testDb).user; + const colA = svc.createCollection(owner.id, { name: 'A' }); + const colB = svc.createCollection(owner.id, { name: 'B' }); + // target is accepted in A; must still be invitable to B + svc.sendInvite(colA.id, owner.id, owner.username, owner.email, target.id); + svc.acceptInvite(target.id, colA.id, undefined); + + const forB = svc.availableUsers(owner.id, colB.id).map((u) => u.id); + expect(forB).toContain(target.id); + const forA = svc.availableUsers(owner.id, colA.id).map((u) => u.id); + expect(forA).not.toContain(target.id); // already a member of A + }); + + it('COLLECTIONS-SVC-038: availableUsers excludes self + guests', () => { + const owner = createUser(testDb).user; + const normal = createUser(testDb).user; + const guest = createUser(testDb).user; + testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + const col = svc.createCollection(owner.id, { name: 'C' }); + const ids = svc.availableUsers(owner.id, col.id).map((u) => u.id); + expect(ids).toContain(normal.id); + expect(ids).not.toContain(owner.id); + expect(ids).not.toContain(guest.id); + }); + + it('COLLECTIONS-SVC-039: visibility = owner OR accepted member (pending does NOT grant access)', () => { + const { owner, target, col } = setup(); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + expect(() => svc.getCollection(target.id, col.id)).toThrow(); // pending, no access yet + svc.acceptInvite(target.id, col.id, undefined); + expect(svc.getCollection(target.id, col.id).collection.id).toBe(col.id); + }); +}); + +// ── deleteCollection snapshot + broadcast + cascade ────────────────────────── + +describe('deleteCollection', () => { + it('COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted, cascades', () => { + const owner = createUser(testDb).user; + const accepted = createUser(testDb).user; + const pending = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Doomed' }); + svc.savePlace(owner.id, { collection_id: col.id, name: 'P' }); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, accepted.id); + svc.acceptInvite(accepted.id, col.id, undefined); + svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id); + + // a non-owner member cannot delete + expect(() => svc.deleteCollection(accepted.id, col.id)).toThrow(); + + broadcastToUser.mockClear(); + svc.deleteCollection(owner.id, col.id); + + const targets = broadcastToUser.mock.calls.map((c) => c[0]); + expect(targets).toEqual(expect.arrayContaining([accepted.id, pending.id])); + expect(targets).not.toContain(owner.id); + expect(broadcastToUser.mock.calls.every((c) => (c[1] as { type: string }).type === 'collections:deleted')).toBe(true); + + expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + }); +}); + +// ── owner_id semantics: member account deletion keeps shared content ───────── + +describe('owner_id semantics', () => { + it('COLLECTIONS-SVC-041: deleting a MEMBER account nulls saved_by but keeps the place', () => { + const owner = createUser(testDb).user; + const member = createUser(testDb).user; + const col = svc.createCollection(owner.id, { name: 'Shared' }); + testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); + const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' }).place!; + + testDb.prepare('DELETE FROM users WHERE id = ?').run(member.id); + + const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number; saved_by: number | null }; + expect(row).toBeDefined(); + expect(row.owner_id).toBe(owner.id); + expect(row.saved_by).toBeNull(); // ON DELETE SET NULL + }); +}); + +// ── Photo-cache guard ──────────────────────────────────────────────────────── + +describe('photo-cache widening', () => { + it('COLLECTIONS-SVC-042: a collection_places row keeps a photo no places row references', () => { + const u = createUser(testDb).user; + const col = svc.createCollection(u.id, { name: 'Photos' }); + // cache meta for place_id 'gp-x', referenced ONLY by a collection_places row. + testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-x', null, Date.now()); + svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' }); + + removeIfUnreferenced('gp-x'); // would evict if isReferenced ignored collection_places + + const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-x'); + expect(meta).toBeDefined(); + }); + + it('COLLECTIONS-SVC-043: an unreferenced photo is still reclaimable', () => { + testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-orphan', null, Date.now()); + removeIfUnreferenced('gp-orphan'); + const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-orphan'); + expect(meta).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/services/notificationPreferencesService.test.ts b/server/tests/unit/services/notificationPreferencesService.test.ts index 1a61beb67..efa8709fa 100644 --- a/server/tests/unit/services/notificationPreferencesService.test.ts +++ b/server/tests/unit/services/notificationPreferencesService.test.ts @@ -94,14 +94,14 @@ describe('getPreferencesMatrix', () => { const { user } = createUser(testDb); const { event_types } = getPreferencesMatrix(user.id, 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(9); + expect(event_types.length).toBe(10); }); it('NPREF-005 — user scope excludes version_available for everyone including admins', () => { const { user } = createAdmin(testDb); const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(9); + expect(event_types.length).toBe(10); }); it('NPREF-005b — admin scope returns only version_available', () => { diff --git a/server/tests/unit/services/placePhotoCache.test.ts b/server/tests/unit/services/placePhotoCache.test.ts index aa79a4e37..5d3b699d8 100644 --- a/server/tests/unit/services/placePhotoCache.test.ts +++ b/server/tests/unit/services/placePhotoCache.test.ts @@ -17,7 +17,9 @@ import Database from 'better-sqlite3'; const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ppc-')); process.env.TREK_PLACE_PHOTO_DIR = TMP_DIR; -// Minimal real DB with just the two tables placePhotoCache touches. +// Minimal real DB with just the tables placePhotoCache touches. isReferenced now +// UNIONs collection_places (#1081 photo-cache fix), so the bare fixture must +// declare it too or the reference check would throw "no such table". const testDb = new Database(':memory:'); testDb.exec(` CREATE TABLE places ( @@ -25,6 +27,11 @@ testDb.exec(` google_place_id TEXT, image_url TEXT ); + CREATE TABLE collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + google_place_id TEXT, + image_url TEXT + ); CREATE TABLE google_place_photo_meta ( place_id TEXT PRIMARY KEY, attribution TEXT, diff --git a/shared/src/collection/collection.schema.ts b/shared/src/collection/collection.schema.ts new file mode 100644 index 000000000..f0e3e53e8 --- /dev/null +++ b/shared/src/collection/collection.schema.ts @@ -0,0 +1,190 @@ +import { z } from 'zod'; +import { placeCategorySchema } from '../place/place.schema'; +import { tagSchema } from '../tag/tag.schema'; + +export const COLLECTION_STATUSES = ['idea', 'want', 'visited'] as const; +export const collectionStatusSchema = z.enum(COLLECTION_STATUSES).catch('idea').default('idea'); +export type CollectionStatus = (typeof COLLECTION_STATUSES)[number]; + +/** A saved place — assignmentPlace minus itinerary, plus status + provenance. */ +export const collectionPlaceSchema = z.object({ + id: z.number(), + collection_id: z.number(), + owner_id: z.number().optional(), + saved_by: z.number().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lng: z.number().nullable().optional(), + address: z.string().nullable().optional(), + category_id: z.number().nullable().optional(), + price: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + image_url: z.string().nullable().optional(), + google_place_id: z.string().nullable().optional(), + google_ftid: z.string().nullable().optional(), + osm_id: z.string().nullable().optional(), + website: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + status: collectionStatusSchema, + source_trip_id: z.number().nullable().optional(), + source_place_id: z.number().nullable().optional(), + sort_order: z.number().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + category: placeCategorySchema.optional(), + tags: z.array(tagSchema.partial()).optional(), +}); +export type CollectionPlace = z.infer; + +/** Member of a shared list (mirrors vacay person rows). */ +export const collectionMemberSchema = z.object({ + user_id: z.number(), + username: z.string(), + email: z.string().optional(), + avatar: z.string().nullable().optional(), + status: z.enum(['pending', 'accepted']), + is_owner: z.boolean().optional(), +}); +export type CollectionMember = z.infer; + +/** A list, with computed counts + membership for the current viewer. */ +export const collectionSchema = z.object({ + id: z.number(), + owner_id: z.number(), + name: z.string(), + description: z.string().nullable().optional(), + color: z.string().nullable().optional(), + icon: z.string().nullable().optional(), + cover_image: z.string().nullable().optional(), + sort_order: z.number().optional(), + place_count: z.number().optional(), + is_owner: z.boolean().optional(), + members: z.array(collectionMemberSchema).optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), +}); +export type Collection = z.infer; + +// ── Requests ────────────────────────────────────────────────────────────── +export const collectionCreateRequestSchema = z.object({ + name: z.string().min(1).max(120), + description: z.string().max(2000).nullable().optional(), + color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).optional(), + icon: z.string().max(40).optional(), +}); +export type CollectionCreateRequest = z.infer; + +export const collectionUpdateRequestSchema = collectionCreateRequestSchema.partial().extend({ + sort_order: z.number().optional(), +}); +export type CollectionUpdateRequest = z.infer; + +/** Save a place into a list from a raw maps/manual payload (or carrying provenance). */ +export const collectionSavePlaceRequestSchema = z.object({ + collection_id: z.number(), + source_place_id: z.number().nullable().optional(), + source_trip_id: z.number().nullable().optional(), + name: z.string().min(1), + description: z.string().nullable().optional(), + lat: z.number().nullable().optional(), + lng: z.number().nullable().optional(), + address: z.string().nullable().optional(), + category_id: z.number().nullable().optional(), + price: z.number().nullable().optional(), + currency: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + image_url: z.string().nullable().optional(), + google_place_id: z.string().nullable().optional(), + google_ftid: z.string().nullable().optional(), + osm_id: z.string().nullable().optional(), + website: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + status: collectionStatusSchema.optional(), + tag_ids: z.array(z.number()).optional(), + force: z.boolean().optional(), // "add anyway" over a dedup match +}); +export type CollectionSavePlaceRequest = z.infer; + +/** DEDICATED DTO for POST /places/from-trip — the server reads the place, so no place payload. */ +export const collectionSaveFromTripRequestSchema = z.object({ + collection_id: z.number(), + source_trip_id: z.number(), + source_place_id: z.number(), + force: z.boolean().optional(), +}); +export type CollectionSaveFromTripRequest = z.infer; + +export const collectionPlaceUpdateRequestSchema = z.object({ + name: z.string().min(1).optional(), + notes: z.string().nullable().optional(), + status: collectionStatusSchema.optional(), + category_id: z.number().nullable().optional(), + collection_id: z.number().optional(), // move to another list + tag_ids: z.array(z.number()).optional(), +}); +export type CollectionPlaceUpdateRequest = z.infer; + +export const collectionSetStatusRequestSchema = z.object({ status: collectionStatusSchema }); +export type CollectionSetStatusRequest = z.infer; + +/** Copy one or many saved places INTO a trip (dedup precheck on server). */ +export const collectionCopyToTripRequestSchema = z.object({ + trip_id: z.number(), + place_ids: z.array(z.number()).min(1), + force: z.boolean().optional(), +}); +export type CollectionCopyToTripRequest = z.infer; + +// Fusion invitations. user_id is NUMERIC ONLY — the UI always sends an id from availableUsers. +export const collectionInviteRequestSchema = z.object({ + collection_id: z.number(), + user_id: z.number(), +}); +export type CollectionInviteRequest = z.infer; + +export const collectionInviteActionRequestSchema = z.object({ collection_id: z.number() }); +export type CollectionInviteActionRequest = z.infer; + +export const collectionInviteCancelRequestSchema = z.object({ + collection_id: z.number(), + user_id: z.number(), +}); +export type CollectionInviteCancelRequest = z.infer; + +// ── Responses ───────────────────────────────────────────────────────────── +export const collectionListResponseSchema = z.object({ + collections: z.array(collectionSchema), + // `from` is DERIVED from collections.owner_id JOIN users (sendInvite is owner-only, so the + // inviter is always the list owner — collection_members has no invited_by column). + incomingInvites: z.array( + z.object({ + collection_id: z.number(), + name: z.string(), + from: z.object({ id: z.number(), username: z.string() }), + }), + ), +}); +export type CollectionListResponse = z.infer; + +export const collectionDetailResponseSchema = z.object({ + collection: collectionSchema, + places: z.array(collectionPlaceSchema), +}); +export type CollectionDetailResponse = z.infer; + +/** Dedup outcome envelope reused by save + copy (matches placeService dedup UX). */ +export const collectionSaveResultSchema = z.object({ + place: collectionPlaceSchema.optional(), + duplicate: z.boolean().optional(), + duplicateOf: z.object({ id: z.number(), name: z.string() }).nullable().optional(), +}); +export type CollectionSaveResult = z.infer; + +/** Library-wide "is this place already saved anywhere I can see?" lookup (inspector indicator). */ +export const collectionMembershipSchema = z.object({ + saved: z.boolean(), + lists: z.array(z.object({ collection_id: z.number(), name: z.string(), place_id: z.number() })), +}); +export type CollectionMembership = z.infer; diff --git a/shared/src/i18n/ar/externalNotifications.ts b/shared/src/i18n/ar/externalNotifications.ts index bb61a9bb9..a2977bc70 100644 --- a/shared/src/i18n/ar/externalNotifications.ts +++ b/shared/src/i18n/ar/externalNotifications.ts @@ -28,6 +28,10 @@ const ar: NotificationLocale = { title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.`, }), + collection_invite: (p) => ({ + title: 'دعوة إلى مجموعة', + body: `${p.actor} يدعوك لمشاركة مجموعة. افتح TREK للقبول أو الرفض.`, + }), photos_shared: (p) => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".`, diff --git a/shared/src/i18n/ar/notif.ts b/shared/src/i18n/ar/notif.ts index 8b56b1253..c631d2989 100644 --- a/shared/src/i18n/ar/notif.ts +++ b/shared/src/i18n/ar/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}', 'notif.vacay_invite.title': 'دعوة دمج الإجازة', 'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة', + 'notif.collection_invite.title': 'دعوة إلى مجموعة', + 'notif.collection_invite.text': '{actor} يدعوك لمشاركة مجموعة', 'notif.photos_shared.title': 'تمت مشاركة الصور', 'notif.photos_shared.text': '{actor} شارك {count} صورة في {trip}', 'notif.collab_message.title': 'رسالة جديدة', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'عرض التعبئة', 'notif.action.view_photos': 'عرض الصور', 'notif.action.view_vacay': 'عرض Vacay', + 'notif.action.view_collection': 'عرض المجموعة', 'notif.action.view_admin': 'الذهاب للإدارة', 'notif.action.view': 'عرض', 'notif.action.accept': 'قبول', diff --git a/shared/src/i18n/br/externalNotifications.ts b/shared/src/i18n/br/externalNotifications.ts index 2ca5229b2..4a91e8e70 100644 --- a/shared/src/i18n/br/externalNotifications.ts +++ b/shared/src/i18n/br/externalNotifications.ts @@ -28,6 +28,10 @@ const br: NotificationLocale = { title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.`, }), + collection_invite: (p) => ({ + title: 'Convite para coleção', + body: `${p.actor} convidou você para compartilhar uma coleção. Abra o TREK para aceitar ou recusar.`, + }), photos_shared: (p) => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".`, diff --git a/shared/src/i18n/br/notif.ts b/shared/src/i18n/br/notif.ts index b649c69fe..2b0660ef7 100644 --- a/shared/src/i18n/br/notif.ts +++ b/shared/src/i18n/br/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} em {trip} vence em {due}', 'notif.vacay_invite.title': 'Convite Vacay Fusion', 'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias', + 'notif.collection_invite.title': 'Convite para coleção', + 'notif.collection_invite.text': '{actor} convidou você para uma coleção', 'notif.photos_shared.title': 'Fotos compartilhadas', 'notif.photos_shared.text': '{actor} compartilhou {count} foto(s) em {trip}', 'notif.collab_message.title': 'Nova mensagem', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Ver bagagem', 'notif.action.view_photos': 'Ver fotos', 'notif.action.view_vacay': 'Ver Vacay', + 'notif.action.view_collection': 'Ver coleção', 'notif.action.view_admin': 'Ir para admin', 'notif.action.view': 'Ver', 'notif.action.accept': 'Aceitar', diff --git a/shared/src/i18n/cs/externalNotifications.ts b/shared/src/i18n/cs/externalNotifications.ts index c0877be57..565e4b257 100644 --- a/shared/src/i18n/cs/externalNotifications.ts +++ b/shared/src/i18n/cs/externalNotifications.ts @@ -28,6 +28,10 @@ const cs: NotificationLocale = { title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.`, }), + collection_invite: (p) => ({ + title: 'Pozvánka do sbírky', + body: `${p.actor} vás pozval ke sdílení sbírky. Otevřete TREK pro přijetí nebo odmítnutí.`, + }), photos_shared: (p) => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".`, diff --git a/shared/src/i18n/cs/notif.ts b/shared/src/i18n/cs/notif.ts index 8e4ffa5f7..f7682f0a6 100644 --- a/shared/src/i18n/cs/notif.ts +++ b/shared/src/i18n/cs/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}', 'notif.vacay_invite.title': 'Pozvánka Vacay Fusion', 'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů', + 'notif.collection_invite.title': 'Pozvánka do sbírky', + 'notif.collection_invite.text': '{actor} vás pozval do sbírky', 'notif.photos_shared.title': 'Fotky sdíleny', 'notif.photos_shared.text': '{actor} sdílel {count} foto v {trip}', 'notif.collab_message.title': 'Nová zpráva', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Zobrazit balení', 'notif.action.view_photos': 'Zobrazit fotky', 'notif.action.view_vacay': 'Zobrazit Vacay', + 'notif.action.view_collection': 'Zobrazit sbírku', 'notif.action.view_admin': 'Jít do adminu', 'notif.action.view': 'Zobrazit', 'notif.action.accept': 'Přijmout', diff --git a/shared/src/i18n/de/externalNotifications.ts b/shared/src/i18n/de/externalNotifications.ts index 810196899..0ea4364c5 100644 --- a/shared/src/i18n/de/externalNotifications.ts +++ b/shared/src/i18n/de/externalNotifications.ts @@ -28,6 +28,10 @@ const de: NotificationLocale = { title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.`, }), + collection_invite: (p) => ({ + title: 'Sammlungs-Einladung', + body: `${p.actor} hat dich eingeladen, eine Sammlung zu teilen. Öffne TREK um anzunehmen oder abzulehnen.`, + }), photos_shared: (p) => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.`, diff --git a/shared/src/i18n/de/notif.ts b/shared/src/i18n/de/notif.ts index b704513ea..866b2732f 100644 --- a/shared/src/i18n/de/notif.ts +++ b/shared/src/i18n/de/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig', 'notif.vacay_invite.title': 'Vacay Fusion-Einladung', 'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen', + 'notif.collection_invite.title': 'Sammlungs-Einladung', + 'notif.collection_invite.text': '{actor} hat dich zu einer Sammlung eingeladen', 'notif.photos_shared.title': 'Fotos geteilt', 'notif.photos_shared.text': '{actor} hat {count} Foto(s) in {trip} geteilt', 'notif.collab_message.title': 'Neue Nachricht', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Packliste ansehen', 'notif.action.view_photos': 'Fotos ansehen', 'notif.action.view_vacay': 'Vacay ansehen', + 'notif.action.view_collection': 'Sammlung ansehen', 'notif.action.view_admin': 'Zum Admin', 'notif.action.view': 'Ansehen', 'notif.action.accept': 'Annehmen', diff --git a/shared/src/i18n/en/externalNotifications.ts b/shared/src/i18n/en/externalNotifications.ts index f0b6f10df..498ef7fbe 100644 --- a/shared/src/i18n/en/externalNotifications.ts +++ b/shared/src/i18n/en/externalNotifications.ts @@ -28,6 +28,10 @@ const en: NotificationLocale = { title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.`, }), + collection_invite: (p) => ({ + title: 'Collection invite', + body: `${p.actor} invited you to share a collection. Open TREK to accept or decline.`, + }), photos_shared: (p) => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".`, diff --git a/shared/src/i18n/en/notif.ts b/shared/src/i18n/en/notif.ts index 924bed06a..d7f06dccb 100644 --- a/shared/src/i18n/en/notif.ts +++ b/shared/src/i18n/en/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} in {trip} is due on {due}', 'notif.vacay_invite.title': 'Vacay Fusion Invite', 'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans', + 'notif.collection_invite.title': 'Collection invite', + 'notif.collection_invite.text': '{actor} invited you to share a collection', 'notif.photos_shared.title': 'Photos Shared', 'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}', 'notif.collab_message.title': 'New Message', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'View Packing', 'notif.action.view_photos': 'View Photos', 'notif.action.view_vacay': 'View Vacay', + 'notif.action.view_collection': 'View Collection', 'notif.action.view_admin': 'Go to Admin', 'notif.action.view': 'View', 'notif.action.accept': 'Accept', diff --git a/shared/src/i18n/es/externalNotifications.ts b/shared/src/i18n/es/externalNotifications.ts index ff5a044e1..f1c73c6b1 100644 --- a/shared/src/i18n/es/externalNotifications.ts +++ b/shared/src/i18n/es/externalNotifications.ts @@ -28,6 +28,10 @@ const es: NotificationLocale = { title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.`, }), + collection_invite: (p) => ({ + title: 'Invitación a colección', + body: `${p.actor} te invitó a compartir una colección. Abre TREK para aceptar o rechazar.`, + }), photos_shared: (p) => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".`, diff --git a/shared/src/i18n/es/notif.ts b/shared/src/i18n/es/notif.ts index 5fc8fc97d..25c299da2 100644 --- a/shared/src/i18n/es/notif.ts +++ b/shared/src/i18n/es/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} en {trip} vence el {due}', 'notif.vacay_invite.title': 'Invitación Vacay Fusion', 'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones', + 'notif.collection_invite.title': 'Invitación a colección', + 'notif.collection_invite.text': '{actor} te invitó a una colección', 'notif.photos_shared.title': 'Fotos compartidas', 'notif.photos_shared.text': '{actor} compartió {count} foto(s) en {trip}', 'notif.collab_message.title': 'Nuevo mensaje', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Ver equipaje', 'notif.action.view_photos': 'Ver fotos', 'notif.action.view_vacay': 'Ver Vacay', + 'notif.action.view_collection': 'Ver colección', 'notif.action.view_admin': 'Ir al admin', 'notif.action.view': 'Ver', 'notif.action.accept': 'Aceptar', diff --git a/shared/src/i18n/externalNotifications/types.ts b/shared/src/i18n/externalNotifications/types.ts index a7a49bb98..4e88afa82 100644 --- a/shared/src/i18n/externalNotifications/types.ts +++ b/shared/src/i18n/externalNotifications/types.ts @@ -27,6 +27,7 @@ export type NotificationEventKey = | 'trip_reminder' | 'todo_due' | 'vacay_invite' + | 'collection_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged' diff --git a/shared/src/i18n/fr/externalNotifications.ts b/shared/src/i18n/fr/externalNotifications.ts index 934ae6f46..07ee1dac9 100644 --- a/shared/src/i18n/fr/externalNotifications.ts +++ b/shared/src/i18n/fr/externalNotifications.ts @@ -28,6 +28,10 @@ const fr: NotificationLocale = { title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.`, }), + collection_invite: (p) => ({ + title: 'Invitation à une collection', + body: `${p.actor} vous invite à partager une collection. Ouvrez TREK pour accepter ou refuser.`, + }), photos_shared: (p) => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".`, diff --git a/shared/src/i18n/fr/notif.ts b/shared/src/i18n/fr/notif.ts index 731f9bd47..b5fde0e1b 100644 --- a/shared/src/i18n/fr/notif.ts +++ b/shared/src/i18n/fr/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} dans {trip} est due le {due}', 'notif.vacay_invite.title': 'Invitation Vacay Fusion', 'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances', + 'notif.collection_invite.title': 'Invitation à une collection', + 'notif.collection_invite.text': '{actor} vous invite à une collection', 'notif.photos_shared.title': 'Photos partagées', 'notif.photos_shared.text': '{actor} a partagé {count} photo(s) dans {trip}', 'notif.collab_message.title': 'Nouveau message', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Voir les bagages', 'notif.action.view_photos': 'Voir les photos', 'notif.action.view_vacay': 'Voir Vacay', + 'notif.action.view_collection': 'Voir la collection', 'notif.action.view_admin': "Aller à l'admin", 'notif.action.view': 'Voir', 'notif.action.accept': 'Accepter', diff --git a/shared/src/i18n/gr/externalNotifications.ts b/shared/src/i18n/gr/externalNotifications.ts index e04b19c6e..5d5a736fa 100644 --- a/shared/src/i18n/gr/externalNotifications.ts +++ b/shared/src/i18n/gr/externalNotifications.ts @@ -28,6 +28,10 @@ const gr: NotificationLocale = { title: 'Πρόσκληση συγχώνευσης διακοπών', body: `Ο/Η ${p.actor} σας προσκάλεσε να συγχωνεύσετε τα σχέδια διακοπών σας. Ανοίξτε το TREK για να αποδεχτείτε ή να απορρίψετε.`, }), + collection_invite: (p) => ({ + title: 'Πρόσκληση σε συλλογή', + body: `Ο/Η ${p.actor} σας προσκάλεσε να μοιραστείτε μια συλλογή. Ανοίξτε το TREK για να αποδεχτείτε ή να απορρίψετε.`, + }), photos_shared: (p) => ({ title: `${p.count} φωτογραφίες κοινοποιήθηκαν`, body: `Ο/Η ${p.actor} κοινοποίησε ${p.count} φωτογραφία/ες στο "${p.trip}".`, diff --git a/shared/src/i18n/gr/notif.ts b/shared/src/i18n/gr/notif.ts index b15b41951..b6bca1f03 100644 --- a/shared/src/i18n/gr/notif.ts +++ b/shared/src/i18n/gr/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': 'Η εργασία {todo} στο {trip} λήγει στις {due}', 'notif.vacay_invite.title': 'Πρόσκληση Σύντηξης Vacay', 'notif.vacay_invite.text': 'Ο/Η {actor} σας προσκάλεσε να συγχωνεύσετε τα σχέδια διακοπών', + 'notif.collection_invite.title': 'Πρόσκληση σε συλλογή', + 'notif.collection_invite.text': 'Ο/Η {actor} σας προσκάλεσε σε μια συλλογή', 'notif.photos_shared.title': 'Κοινοποίηση Φωτογραφιών', 'notif.photos_shared.text': 'Ο/Η {actor} μοιράστηκε {count} φωτογραφία(ες) στο {trip}', 'notif.collab_message.title': 'Νέο Μήνυμα', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Προβολή Αποσκευών', 'notif.action.view_photos': 'Προβολή Φωτογραφιών', 'notif.action.view_vacay': 'Προβολή Vacay', + 'notif.action.view_collection': 'Προβολή συλλογής', 'notif.action.view_admin': 'Μετάβαση στη Διαχείριση', 'notif.action.view': 'Προβολή', 'notif.action.accept': 'Αποδοχή', diff --git a/shared/src/i18n/hu/externalNotifications.ts b/shared/src/i18n/hu/externalNotifications.ts index e4607b7ed..3def322d8 100644 --- a/shared/src/i18n/hu/externalNotifications.ts +++ b/shared/src/i18n/hu/externalNotifications.ts @@ -28,6 +28,10 @@ const hu: NotificationLocale = { title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.`, }), + collection_invite: (p) => ({ + title: 'Gyűjtemény meghívó', + body: `${p.actor} meghívott egy gyűjtemény megosztására. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.`, + }), photos_shared: (p) => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.`, diff --git a/shared/src/i18n/hu/notif.ts b/shared/src/i18n/hu/notif.ts index 6d0be26c2..2f9f1174e 100644 --- a/shared/src/i18n/hu/notif.ts +++ b/shared/src/i18n/hu/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} ({trip}) határideje: {due}', 'notif.vacay_invite.title': 'Vacay Fusion meghívó', 'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához', + 'notif.collection_invite.title': 'Gyűjtemény meghívó', + 'notif.collection_invite.text': '{actor} meghívott egy gyűjteménybe', 'notif.photos_shared.title': 'Fotók megosztva', 'notif.photos_shared.text': '{actor} {count} fotót osztott meg a(z) {trip} utazásban', 'notif.collab_message.title': 'Új üzenet', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Csomagolás megtekintése', 'notif.action.view_photos': 'Fotók megtekintése', 'notif.action.view_vacay': 'Vacay megtekintése', + 'notif.action.view_collection': 'Gyűjtemény megtekintése', 'notif.action.view_admin': 'Admin megnyitása', 'notif.action.view': 'Megtekintés', 'notif.action.accept': 'Elfogadás', diff --git a/shared/src/i18n/id/externalNotifications.ts b/shared/src/i18n/id/externalNotifications.ts index 8b9f129b3..238ab1aa2 100644 --- a/shared/src/i18n/id/externalNotifications.ts +++ b/shared/src/i18n/id/externalNotifications.ts @@ -28,6 +28,10 @@ const id: NotificationLocale = { title: 'Undangan Penggabungan Vacay', body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.`, }), + collection_invite: (p) => ({ + title: 'Undangan koleksi', + body: `${p.actor} mengundang Anda untuk berbagi koleksi. Buka TREK untuk menerima atau menolak.`, + }), photos_shared: (p) => ({ title: `${p.count} foto dibagikan`, body: `${p.actor} membagikan ${p.count} foto di "${p.trip}".`, diff --git a/shared/src/i18n/id/notif.ts b/shared/src/i18n/id/notif.ts index 5c07dd1d7..1b862f023 100644 --- a/shared/src/i18n/id/notif.ts +++ b/shared/src/i18n/id/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}', 'notif.vacay_invite.title': 'Undangan Vacay Fusion', 'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan', + 'notif.collection_invite.title': 'Undangan koleksi', + 'notif.collection_invite.text': '{actor} mengundang Anda ke koleksi', 'notif.photos_shared.title': 'Foto Dibagikan', 'notif.photos_shared.text': '{actor} membagikan {count} foto di {trip}', 'notif.collab_message.title': 'Pesan Baru', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Lihat Perlengkapan', 'notif.action.view_photos': 'Lihat Foto', 'notif.action.view_vacay': 'Lihat Vacay', + 'notif.action.view_collection': 'Lihat koleksi', 'notif.action.view_admin': 'Ke Admin', 'notif.action.view': 'Lihat', 'notif.action.accept': 'Terima', diff --git a/shared/src/i18n/it/externalNotifications.ts b/shared/src/i18n/it/externalNotifications.ts index 0ee5a23aa..cf779ed00 100644 --- a/shared/src/i18n/it/externalNotifications.ts +++ b/shared/src/i18n/it/externalNotifications.ts @@ -28,6 +28,10 @@ const it: NotificationLocale = { title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.`, }), + collection_invite: (p) => ({ + title: 'Invito a una raccolta', + body: `${p.actor} ti ha invitato a condividere una raccolta. Apri TREK per accettare o rifiutare.`, + }), photos_shared: (p) => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".`, diff --git a/shared/src/i18n/it/notif.ts b/shared/src/i18n/it/notif.ts index 2425f2e43..7a66779fa 100644 --- a/shared/src/i18n/it/notif.ts +++ b/shared/src/i18n/it/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} in {trip} scade il {due}', 'notif.vacay_invite.title': 'Invito Vacay Fusion', 'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza', + 'notif.collection_invite.title': 'Invito a una raccolta', + 'notif.collection_invite.text': '{actor} ti ha invitato a una raccolta', 'notif.photos_shared.title': 'Foto condivise', 'notif.photos_shared.text': '{actor} ha condiviso {count} foto in {trip}', 'notif.collab_message.title': 'Nuovo messaggio', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Vedi bagagli', 'notif.action.view_photos': 'Vedi foto', 'notif.action.view_vacay': 'Vedi Vacay', + 'notif.action.view_collection': 'Vedi raccolta', 'notif.action.view_admin': "Vai all'admin", 'notif.action.view': 'Vedi', 'notif.action.accept': 'Accetta', diff --git a/shared/src/i18n/ja/externalNotifications.ts b/shared/src/i18n/ja/externalNotifications.ts index c8dde8a39..6b9750878 100644 --- a/shared/src/i18n/ja/externalNotifications.ts +++ b/shared/src/i18n/ja/externalNotifications.ts @@ -28,6 +28,10 @@ const ja: NotificationLocale = { title: 'Vacay Fusion招待', body: `${p.actor}が休暇プランの統合に招待しています。TREKを開いて承認または拒否してください。`, }), + collection_invite: (p) => ({ + title: 'コレクション招待', + body: `${p.actor}がコレクションの共有に招待しています。TREKを開いて承認または拒否してください。`, + }), photos_shared: (p) => ({ title: `${p.count}枚の写真が共有されました`, body: `${p.actor}が「${p.trip}」で${p.count}枚の写真を共有しました。`, diff --git a/shared/src/i18n/ja/notif.ts b/shared/src/i18n/ja/notif.ts index 2ca4e4ce8..2d677284f 100644 --- a/shared/src/i18n/ja/notif.ts +++ b/shared/src/i18n/ja/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '「{trip}」の{todo}は{due}が期限です', 'notif.vacay_invite.title': 'Vacay Fusionへの招待', 'notif.vacay_invite.text': '{actor}から旅行プランの統合に招待されました', + 'notif.collection_invite.title': 'コレクション招待', + 'notif.collection_invite.text': '{actor}がコレクションに招待しました', 'notif.photos_shared.title': '写真が共有されました', 'notif.photos_shared.text': '{actor}が「{trip}」で{count}枚の写真を共有しました', 'notif.collab_message.title': '新しいメッセージ', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': '持ち物を見る', 'notif.action.view_photos': '写真を見る', 'notif.action.view_vacay': 'Vacayを見る', + 'notif.action.view_collection': 'コレクションを見る', 'notif.action.view_admin': '管理画面へ', 'notif.action.view': '表示', 'notif.action.accept': '承認', diff --git a/shared/src/i18n/ko/externalNotifications.ts b/shared/src/i18n/ko/externalNotifications.ts index 6fd6a124b..5c25d8919 100644 --- a/shared/src/i18n/ko/externalNotifications.ts +++ b/shared/src/i18n/ko/externalNotifications.ts @@ -28,6 +28,10 @@ const ko: NotificationLocale = { title: 'Vacay Fusion 초대', body: `${p.actor}이(가) 휴가 계획을 합치도록 초대했습니다. TREK을 열어 수락하거나 거절하세요.`, }), + collection_invite: (p) => ({ + title: '컬렉션 초대', + body: `${p.actor}이(가) 컬렉션 공유에 초대했습니다. TREK을 열어 수락하거나 거절하세요.`, + }), photos_shared: (p) => ({ title: `${p.count}장의 사진이 공유되었습니다`, body: `${p.actor}이(가) "${p.trip}"에서 ${p.count}장의 사진을 공유했습니다.`, diff --git a/shared/src/i18n/ko/notif.ts b/shared/src/i18n/ko/notif.ts index 62251f544..118b99ea3 100644 --- a/shared/src/i18n/ko/notif.ts +++ b/shared/src/i18n/ko/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{trip}의 {todo}이(가) {due}에 마감됩니다', 'notif.vacay_invite.title': 'Vacay 퓨전 초대', 'notif.vacay_invite.text': '{actor}이(가) 휴가 계획 공유에 초대했습니다', + 'notif.collection_invite.title': '컬렉션 초대', + 'notif.collection_invite.text': '{actor}이(가) 컬렉션에 초대했습니다', 'notif.photos_shared.title': '사진 공유됨', 'notif.photos_shared.text': '{actor}이(가) {trip}에서 {count}장의 사진을 공유했습니다', 'notif.collab_message.title': '새 메시지', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': '짐 목록 보기', 'notif.action.view_photos': '사진 보기', 'notif.action.view_vacay': 'Vacay 보기', + 'notif.action.view_collection': '컬렉션 보기', 'notif.action.view_admin': '관리자로 이동', 'notif.action.view': '보기', 'notif.action.accept': '수락', diff --git a/shared/src/i18n/nl/externalNotifications.ts b/shared/src/i18n/nl/externalNotifications.ts index 7dda2945e..39ef8bd73 100644 --- a/shared/src/i18n/nl/externalNotifications.ts +++ b/shared/src/i18n/nl/externalNotifications.ts @@ -28,6 +28,10 @@ const nl: NotificationLocale = { title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.`, }), + collection_invite: (p) => ({ + title: 'Collectie-uitnodiging', + body: `${p.actor} nodigt je uit om een collectie te delen. Open TREK om te accepteren of af te wijzen.`, + }), photos_shared: (p) => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".`, diff --git a/shared/src/i18n/nl/notif.ts b/shared/src/i18n/nl/notif.ts index dc1dffedb..f004ee63b 100644 --- a/shared/src/i18n/nl/notif.ts +++ b/shared/src/i18n/nl/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} in {trip} verloopt op {due}', 'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging', 'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren', + 'notif.collection_invite.title': 'Collectie-uitnodiging', + 'notif.collection_invite.text': '{actor} nodigt je uit voor een collectie', 'notif.photos_shared.title': "Foto's gedeeld", 'notif.photos_shared.text': "{actor} heeft {count} foto('s) gedeeld in {trip}", 'notif.collab_message.title': 'Nieuw bericht', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Paklijst bekijken', 'notif.action.view_photos': "Foto's bekijken", 'notif.action.view_vacay': 'Vacay bekijken', + 'notif.action.view_collection': 'Collectie bekijken', 'notif.action.view_admin': 'Naar admin', 'notif.action.view': 'Bekijken', 'notif.action.accept': 'Accepteren', diff --git a/shared/src/i18n/pl/externalNotifications.ts b/shared/src/i18n/pl/externalNotifications.ts index 0a706005b..cb4c3b29c 100644 --- a/shared/src/i18n/pl/externalNotifications.ts +++ b/shared/src/i18n/pl/externalNotifications.ts @@ -28,6 +28,10 @@ const pl: NotificationLocale = { title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.`, }), + collection_invite: (p) => ({ + title: 'Zaproszenie do kolekcji', + body: `${p.actor} zaprosił Cię do udostępnienia kolekcji. Otwórz TREK, aby zaakceptować lub odrzucić.`, + }), photos_shared: (p) => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".`, diff --git a/shared/src/i18n/pl/notif.ts b/shared/src/i18n/pl/notif.ts index b95449a33..0fc8cacfd 100644 --- a/shared/src/i18n/pl/notif.ts +++ b/shared/src/i18n/pl/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} w {trip} — termin {due}', 'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion', 'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych', + 'notif.collection_invite.title': 'Zaproszenie do kolekcji', + 'notif.collection_invite.text': '{actor} zaprosił Cię do kolekcji', 'notif.photos_shared.title': 'Zdjęcia udostępnione', 'notif.photos_shared.text': '{actor} udostępnił {count} zdjęcie/zdjęcia w {trip}', 'notif.collab_message.title': 'Nowa wiadomość', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Zobacz pakowanie', 'notif.action.view_photos': 'Zobacz zdjęcia', 'notif.action.view_vacay': 'Zobacz Vacay', + 'notif.action.view_collection': 'Zobacz kolekcję', 'notif.action.view_admin': 'Przejdź do admina', 'notif.action.view': 'Zobacz', 'notif.action.accept': 'Akceptuj', diff --git a/shared/src/i18n/ru/externalNotifications.ts b/shared/src/i18n/ru/externalNotifications.ts index eeeb4b601..9f65064d3 100644 --- a/shared/src/i18n/ru/externalNotifications.ts +++ b/shared/src/i18n/ru/externalNotifications.ts @@ -28,6 +28,10 @@ const ru: NotificationLocale = { title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.`, }), + collection_invite: (p) => ({ + title: 'Приглашение в коллекцию', + body: `${p.actor} приглашает вас поделиться коллекцией. Откройте TREK для подтверждения.`, + }), photos_shared: (p) => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".`, diff --git a/shared/src/i18n/ru/notif.ts b/shared/src/i18n/ru/notif.ts index b3eb0c976..dea837706 100644 --- a/shared/src/i18n/ru/notif.ts +++ b/shared/src/i18n/ru/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} в {trip} — срок {due}', 'notif.vacay_invite.title': 'Приглашение Vacay Fusion', 'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска', + 'notif.collection_invite.title': 'Приглашение в коллекцию', + 'notif.collection_invite.text': '{actor} приглашает вас в коллекцию', 'notif.photos_shared.title': 'Фото опубликованы', 'notif.photos_shared.text': '{actor} поделился {count} фото в {trip}', 'notif.collab_message.title': 'Новое сообщение', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Открыть упаковку', 'notif.action.view_photos': 'Открыть фото', 'notif.action.view_vacay': 'Открыть Vacay', + 'notif.action.view_collection': 'Открыть коллекцию', 'notif.action.view_admin': 'Перейти в админ', 'notif.action.view': 'Открыть', 'notif.action.accept': 'Принять', diff --git a/shared/src/i18n/sv/externalNotifications.ts b/shared/src/i18n/sv/externalNotifications.ts index cf25830a0..0fd8c4126 100644 --- a/shared/src/i18n/sv/externalNotifications.ts +++ b/shared/src/i18n/sv/externalNotifications.ts @@ -28,6 +28,10 @@ const en: NotificationLocale = { title: 'Vacay sammanslagnings inbjudan', body: `${p.actor} bjöd in dig att slå samman semesterplaner. Öppna TREK för att acceptera eller avvisa.`, }), + collection_invite: (p) => ({ + title: 'Inbjudan till samling', + body: `${p.actor} bjöd in dig att dela en samling. Öppna TREK för att acceptera eller avvisa.`, + }), photos_shared: (p) => ({ title: `${p.count} foton delade`, body: `${p.actor} delade ${p.count} foto(n) i "${p.trip}".`, diff --git a/shared/src/i18n/sv/notif.ts b/shared/src/i18n/sv/notif.ts index dc75c5c4e..555e92d53 100644 --- a/shared/src/i18n/sv/notif.ts +++ b/shared/src/i18n/sv/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} i {trip} förfaller den {due}', 'notif.vacay_invite.title': 'Vacay sammanslagnings inbjudan', 'notif.vacay_invite.text': '{actor} bjöd in dig till sammanslagning av semester planer', + 'notif.collection_invite.title': 'Inbjudan till samling', + 'notif.collection_invite.text': '{actor} bjöd in dig till en samling', 'notif.photos_shared.title': 'Delade foton', 'notif.photos_shared.text': '{actor} delade {count} foto(n) i {trip}', 'notif.collab_message.title': 'Nytt meddelande', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Visa förpackning', 'notif.action.view_photos': 'Visa bilder', 'notif.action.view_vacay': 'Visa Vacay', + 'notif.action.view_collection': 'Visa samling', 'notif.action.view_admin': 'Gå till Admin', 'notif.action.view': 'Visa', 'notif.action.accept': 'Godkänn', diff --git a/shared/src/i18n/tr/externalNotifications.ts b/shared/src/i18n/tr/externalNotifications.ts index 6cff11099..bf1652a41 100644 --- a/shared/src/i18n/tr/externalNotifications.ts +++ b/shared/src/i18n/tr/externalNotifications.ts @@ -28,6 +28,10 @@ const tr: NotificationLocale = { title: 'Vacay Fusion Daveti', body: `${p.actor} sizi tatil planlarını birleştirmeye davet etti. Kabul etmek veya reddetmek için TREK'i açın.`, }), + collection_invite: (p) => ({ + title: 'Koleksiyon daveti', + body: `${p.actor} sizi bir koleksiyonu paylaşmaya davet etti. Kabul etmek veya reddetmek için TREK’i açın.`, + }), photos_shared: (p) => ({ title: `${p.count} fotoğraf paylaşıldı`, body: `${p.actor}, "${p.trip}" içinde ${p.count} fotoğraf paylaştı.`, diff --git a/shared/src/i18n/tr/notif.ts b/shared/src/i18n/tr/notif.ts index 46bfbb7a6..9a7665093 100644 --- a/shared/src/i18n/tr/notif.ts +++ b/shared/src/i18n/tr/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{trip} içindeki {todo} — son tarih: {due}', 'notif.vacay_invite.title': 'Vacay Birleştirme Daveti', 'notif.vacay_invite.text': '{actor} tatil planlarını birleştirmeniz için sizi davet etti', + 'notif.collection_invite.title': 'Koleksiyon daveti', + 'notif.collection_invite.text': '{actor} sizi bir koleksiyona davet etti', 'notif.photos_shared.title': 'Fotoğraflar Paylaşıldı', 'notif.photos_shared.text': '{actor}, {trip} içinde {count} fotoğraf paylaştı', 'notif.collab_message.title': 'Yeni Mesaj', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Paket listesini görüntüle', 'notif.action.view_photos': 'Fotoğrafları Görüntüle', 'notif.action.view_vacay': "Vacay'ı Görüntüle", + 'notif.action.view_collection': 'Koleksiyonu görüntüle', 'notif.action.view_admin': 'Yöneticiye git', 'notif.action.view': 'Görüntüle', 'notif.action.accept': 'Kabul et', diff --git a/shared/src/i18n/uk/externalNotifications.ts b/shared/src/i18n/uk/externalNotifications.ts index e6732f921..3aa35f197 100644 --- a/shared/src/i18n/uk/externalNotifications.ts +++ b/shared/src/i18n/uk/externalNotifications.ts @@ -28,6 +28,10 @@ const uk: NotificationLocale = { title: 'Запрошення Vacay Fusion', body: `${p.actor} запрошує вас об'єднати плани відпустки. Відкрийте TREK, щоб прийняти або відхилити.`, }), + collection_invite: (p) => ({ + title: 'Запрошення до колекції', + body: `${p.actor} запрошує вас поділитися колекцією. Відкрийте TREK, щоб прийняти або відхилити.`, + }), photos_shared: (p) => ({ title: `${p.count} фото поділились`, body: `${p.actor} поділився ${p.count} фото у "${p.trip}".`, diff --git a/shared/src/i18n/uk/notif.ts b/shared/src/i18n/uk/notif.ts index a0545d06e..d087e2f0f 100644 --- a/shared/src/i18n/uk/notif.ts +++ b/shared/src/i18n/uk/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} у {trip} — термін {due}', 'notif.vacay_invite.title': 'Запрошення Vacay Fusion', 'notif.vacay_invite.text': '{actor} запрошує вас об’єднати плани відпустки', + 'notif.collection_invite.title': 'Запрошення до колекції', + 'notif.collection_invite.text': '{actor} запрошує вас до колекції', 'notif.photos_shared.title': 'Фото опубліковано', 'notif.photos_shared.text': '{actor} поділився {count} фото у {trip}', 'notif.collab_message.title': 'Нове повідомлення', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Відкрити пакування', 'notif.action.view_photos': 'Відкрити фото', 'notif.action.view_vacay': 'Відкрити Vacay', + 'notif.action.view_collection': 'Переглянути колекцію', 'notif.action.view_admin': 'Перейти в адмін', 'notif.action.view': 'Відкрити', 'notif.action.accept': 'Прийняти', diff --git a/shared/src/i18n/vi/externalNotifications.ts b/shared/src/i18n/vi/externalNotifications.ts index aedc47059..ea1dd6f77 100644 --- a/shared/src/i18n/vi/externalNotifications.ts +++ b/shared/src/i18n/vi/externalNotifications.ts @@ -28,6 +28,10 @@ const en: NotificationLocale = { title: 'Lời mời kết hợp kì nghỉ', body: `${p.actor} đã mời bạn kết hợp kế hoạch kỳ nghỉ. Mở TREK để chấp nhận hoặc từ chối.`, }), + collection_invite: (p) => ({ + title: 'Lời mời bộ sưu tập', + body: `${p.actor} đã mời bạn chia sẻ một bộ sưu tập. Mở TREK để chấp nhận hoặc từ chối.`, + }), photos_shared: (p) => ({ title: `${p.count} đã chia sẻ hình ảnh`, body: `${p.actor} đã chi sẻ ${p.count} ảnh trong "${p.trip}".`, diff --git a/shared/src/i18n/vi/notif.ts b/shared/src/i18n/vi/notif.ts index b6a591565..1bf475885 100644 --- a/shared/src/i18n/vi/notif.ts +++ b/shared/src/i18n/vi/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{todo} trong {trip} đáo hạn vào {due}', 'notif.vacay_invite.title': 'Lời mời kết hợp Vacay', 'notif.vacay_invite.text': '{actor} đã mời bạn lên kế hoạch cho kỳ nghỉ', + 'notif.collection_invite.title': 'Lời mời bộ sưu tập', + 'notif.collection_invite.text': '{actor} đã mời bạn vào một bộ sưu tập', 'notif.photos_shared.title': 'Ảnh được chia sẻ', 'notif.photos_shared.text': '{actor} đã chia sẻ {count} ảnh trong {trip}', 'notif.collab_message.title': 'Tin nhắn mới', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': 'Xem đóng gói', 'notif.action.view_photos': 'Xem ảnh', 'notif.action.view_vacay': 'Xem kỳ nghỉ', + 'notif.action.view_collection': 'Xem bộ sưu tập', 'notif.action.view_admin': 'Đi tới quản trị viên', 'notif.action.view': 'Xem', 'notif.action.accept': 'Chấp nhận', diff --git a/shared/src/i18n/zh-TW/externalNotifications.ts b/shared/src/i18n/zh-TW/externalNotifications.ts index 2f5d28e28..01f919524 100644 --- a/shared/src/i18n/zh-TW/externalNotifications.ts +++ b/shared/src/i18n/zh-TW/externalNotifications.ts @@ -28,6 +28,10 @@ const zhTW: NotificationLocale = { title: 'Vacay 融合邀請', body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。`, }), + collection_invite: (p) => ({ + title: '收藏邀請', + body: `${p.actor} 邀請您共享收藏。開啟 TREK 以接受或拒絕。`, + }), photos_shared: (p) => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。`, diff --git a/shared/src/i18n/zh-TW/notif.ts b/shared/src/i18n/zh-TW/notif.ts index 0b642610a..26cb1c8c9 100644 --- a/shared/src/i18n/zh-TW/notif.ts +++ b/shared/src/i18n/zh-TW/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期', 'notif.vacay_invite.title': 'Vacay Fusion 邀請', 'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫', + 'notif.collection_invite.title': '收藏邀請', + 'notif.collection_invite.text': '{actor} 邀請您加入收藏', 'notif.photos_shared.title': '照片已分享', 'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片', 'notif.collab_message.title': '新訊息', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': '查看打包', 'notif.action.view_photos': '查看照片', 'notif.action.view_vacay': '查看 Vacay', + 'notif.action.view_collection': '查看收藏', 'notif.action.view_admin': '前往管理', 'notif.action.view': '查看', 'notif.action.accept': '接受', diff --git a/shared/src/i18n/zh/externalNotifications.ts b/shared/src/i18n/zh/externalNotifications.ts index 9759e5b57..f44f5a857 100644 --- a/shared/src/i18n/zh/externalNotifications.ts +++ b/shared/src/i18n/zh/externalNotifications.ts @@ -28,6 +28,10 @@ const zh: NotificationLocale = { title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。`, }), + collection_invite: (p) => ({ + title: '收藏邀请', + body: `${p.actor} 邀请你共享收藏。打开 TREK 接受或拒绝。`, + }), photos_shared: (p) => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。`, diff --git a/shared/src/i18n/zh/notif.ts b/shared/src/i18n/zh/notif.ts index 8b5161e25..cca4f1192 100644 --- a/shared/src/i18n/zh/notif.ts +++ b/shared/src/i18n/zh/notif.ts @@ -15,6 +15,8 @@ const notif: TranslationStrings = { 'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期', 'notif.vacay_invite.title': 'Vacay 融合邀请', 'notif.vacay_invite.text': '{actor} 邀请您合并假期计划', + 'notif.collection_invite.title': '收藏邀请', + 'notif.collection_invite.text': '{actor} 邀请你加入收藏', 'notif.photos_shared.title': '照片已分享', 'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 张照片', 'notif.collab_message.title': '新消息', @@ -28,6 +30,7 @@ const notif: TranslationStrings = { 'notif.action.view_packing': '查看行李', 'notif.action.view_photos': '查看照片', 'notif.action.view_vacay': '查看 Vacay', + 'notif.action.view_collection': '查看收藏', 'notif.action.view_admin': '前往管理', 'notif.action.view': '查看', 'notif.action.accept': '接受', diff --git a/shared/src/index.ts b/shared/src/index.ts index 2dfdb1c67..d3ed90dec 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -31,6 +31,7 @@ export * from './airtrail/airtrail.schema'; export * from './day/day.schema'; export * from './assignment/assignment.schema'; export * from './place/place.schema'; +export * from './collection/collection.schema'; export * from './trip/trip.schema'; export * from './collab/collab.schema'; export * from './file/file.schema';