mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 }) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}枚の写真を共有しました。`,
|
||||
|
||||
@@ -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}장의 사진을 공유했습니다.`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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ı.`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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}".`,
|
||||
|
||||
@@ -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} 張照片。`,
|
||||
|
||||
@@ -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} 张照片。`,
|
||||
|
||||
@@ -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': '接受',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user