feat(collections): backend for the Overall Places addon (#1081)

Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.

- Data: collection / collection_members / collection_places / collection_place_tags
  tables (+ migration and baseline schema). Saved places carry the owner plus a
  nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
  status, save-from-trip and copy-to-trip (reusing the trip copy column list),
  and the full fusion-invitation state machine mirrored from vacay (send / accept
  / decline / cancel / leave) with a websocket broadcast and an invite
  notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
  nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
This commit is contained in:
Maurice
2026-06-30 21:19:19 +02:00
committed by Maurice
parent 37ef3a9d18
commit ec8509f2de
67 changed files with 2150 additions and 14 deletions
+2 -2
View File
@@ -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 }) {
+5 -4
View File
@@ -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<string, { icon: LucideIcon; labelKey: string }> = {
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 }
+2 -2
View File
@@ -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<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
interface NavbarProps {
tripTitle?: string
+1
View File
@@ -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];
+75
View File
@@ -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) {
+62
View File
@@ -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,
+1
View File
@@ -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);
+2 -1
View File
@@ -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,
@@ -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;
}
}
@@ -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 };
}
}
@@ -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 {}
@@ -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); }
}
+629
View File
@@ -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<number, { id: number; name: string; color: string }[]> {
const out: Record<number, { id: number; name: string; color: string }[]> = {};
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<CollectionMember, 'status' | 'is_owner'> | 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<CollectionMember, 'is_owner'>[];
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<string>;
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<string>();
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<string, unknown> | 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<number>();
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 };
}
@@ -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<NotifEventType, NotifChannel[]> = {
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'],
@@ -96,6 +96,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
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',
+11 -2
View File
@@ -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;
}
+203
View File
@@ -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<ReturnType<typeof build>>;
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 });
});
});
@@ -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<string, unknown>;
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<string, unknown>;
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();
});
});
@@ -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', () => {
@@ -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,
+190
View File
@@ -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<typeof collectionPlaceSchema>;
/** 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<typeof collectionMemberSchema>;
/** 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<typeof collectionSchema>;
// ── 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<typeof collectionCreateRequestSchema>;
export const collectionUpdateRequestSchema = collectionCreateRequestSchema.partial().extend({
sort_order: z.number().optional(),
});
export type CollectionUpdateRequest = z.infer<typeof collectionUpdateRequestSchema>;
/** 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<typeof collectionSavePlaceRequestSchema>;
/** 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<typeof collectionSaveFromTripRequestSchema>;
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<typeof collectionPlaceUpdateRequestSchema>;
export const collectionSetStatusRequestSchema = z.object({ status: collectionStatusSchema });
export type CollectionSetStatusRequest = z.infer<typeof collectionSetStatusRequestSchema>;
/** 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<typeof collectionCopyToTripRequestSchema>;
// 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<typeof collectionInviteRequestSchema>;
export const collectionInviteActionRequestSchema = z.object({ collection_id: z.number() });
export type CollectionInviteActionRequest = z.infer<typeof collectionInviteActionRequestSchema>;
export const collectionInviteCancelRequestSchema = z.object({
collection_id: z.number(),
user_id: z.number(),
});
export type CollectionInviteCancelRequest = z.infer<typeof collectionInviteCancelRequestSchema>;
// ── 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<typeof collectionListResponseSchema>;
export const collectionDetailResponseSchema = z.object({
collection: collectionSchema,
places: z.array(collectionPlaceSchema),
});
export type CollectionDetailResponse = z.infer<typeof collectionDetailResponseSchema>;
/** 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<typeof collectionSaveResultSchema>;
/** 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<typeof collectionMembershipSchema>;
@@ -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}".`,
+3
View File
@@ -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': 'قبول',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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.`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -27,6 +27,7 @@ export type NotificationEventKey =
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
| 'collection_invite'
| 'photos_shared'
| 'collab_message'
| 'packing_tagged'
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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': 'Αποδοχή',
@@ -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.`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}枚の写真を共有しました。`,
+3
View File
@@ -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': '承認',
@@ -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}장의 사진을 공유했습니다.`,
+3
View File
@@ -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': '수락',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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': 'Принять',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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ı.`,
+3
View File
@@ -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',
@@ -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}".`,
+3
View File
@@ -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': 'Прийняти',
@@ -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}".`,
+3
View File
@@ -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',
@@ -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} 張照片。`,
+3
View File
@@ -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': '接受',
@@ -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} 张照片。`,
+3
View File
@@ -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': '接受',
+1
View File
@@ -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';