mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat: Journey addon — travel journal with entries, photos, public sharing & PDF export
- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91 - Trip-to-Journey sync engine with skeleton entries and photo sync - Full CRUD API for journeys, entries, photos with Immich/Synology integration - Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons) - Journey frontpage with hero card, stats and trip suggestions - Public share links with token-based access and photo proxy - PDF photo book export (Polarsteps-inspired) - Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design - BottomNav profile sheet with settings/admin/logout - DayPlan mobile inline place picker - TripFormModal members management - Vacay calendar trip date indicator dots - Fix contributor photo access (403) for journey Immich/Synology photos - Trip deletion cleanup for journey skeleton entries - i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
This commit is contained in:
+6
-1
@@ -37,6 +37,8 @@ import atlasRoutes from './routes/atlas';
|
||||
import memoriesRoutes from './routes/memories/unified';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
import journeyPublicRoutes from './routes/journeyPublic';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
@@ -142,9 +144,10 @@ export function createApp(): express.Application {
|
||||
});
|
||||
}
|
||||
|
||||
// Static: avatars and covers are public
|
||||
// Static: avatars, covers, and journey photos
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
|
||||
|
||||
// Photos require auth or valid share token
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
@@ -256,6 +259,8 @@ export function createApp(): express.Application {
|
||||
// Addon routes
|
||||
app.use('/api/addons/vacay', vacayRoutes);
|
||||
app.use('/api/addons/atlas', atlasRoutes);
|
||||
app.use('/api/journeys', journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
|
||||
@@ -884,6 +884,438 @@ function runMigrations(db: Database.Database): void {
|
||||
ins.run(r.trip_id, r.category, idx++);
|
||||
}
|
||||
},
|
||||
// Migration 84: Journey addon — trip tracking & travel journal
|
||||
() => {
|
||||
// Register addon (disabled by default — opt-in)
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order)
|
||||
VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35)
|
||||
`).run();
|
||||
|
||||
// Core journey table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
is_public INTEGER NOT NULL DEFAULT 0,
|
||||
public_token TEXT UNIQUE,
|
||||
settings TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Check-ins — visited locations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_checkins (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
address TEXT,
|
||||
country_code TEXT,
|
||||
notes TEXT,
|
||||
checked_in_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Journal entries — daily stories
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
title TEXT,
|
||||
body TEXT,
|
||||
mood TEXT,
|
||||
weather TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// Photos — local uploads + provider references (Immich/Synology)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
|
||||
entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL,
|
||||
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||
asset_id TEXT,
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
original_name TEXT,
|
||||
mime_type TEXT,
|
||||
size_bytes INTEGER,
|
||||
caption TEXT,
|
||||
taken_at TEXT,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// GPS trail points (Dawarich integration)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_location_trail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
altitude REAL,
|
||||
accuracy REAL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'dawarich'
|
||||
)
|
||||
`);
|
||||
|
||||
// Indexes
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at);
|
||||
`);
|
||||
},
|
||||
// Migration 85: Journal — richer entry fields for magazine-style design
|
||||
() => {
|
||||
// Highlight tags (JSON array), visibility control, hero photo, color accent
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
|
||||
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
|
||||
|
||||
// Check-in: allow a single cover photo reference
|
||||
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
|
||||
|
||||
// Photos: add caption edit timestamp for gallery ordering
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
|
||||
},
|
||||
// Migration 86: Journey multi-trip support + sharing/collaboration
|
||||
() => {
|
||||
// Junction table: journey can include multiple trips
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_trips (
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
PRIMARY KEY (journey_id, trip_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)');
|
||||
|
||||
// Sharing: invite users to a journey
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE(journey_id, user_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
|
||||
|
||||
// author tracking on entries and checkins
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
},
|
||||
// Migration 87: Journey rebuild — new schema with trip sync
|
||||
() => {
|
||||
// Migrate existing data from old tables into backup, then rebuild
|
||||
const hasOldJourneys = db.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'"
|
||||
).get();
|
||||
|
||||
let oldJourneys: any[] = [];
|
||||
let oldEntries: any[] = [];
|
||||
let oldPhotos: any[] = [];
|
||||
|
||||
if (hasOldJourneys) {
|
||||
// Save existing data before dropping
|
||||
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
|
||||
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
|
||||
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
|
||||
|
||||
// Drop all old journey tables
|
||||
db.exec('DROP TABLE IF EXISTS journey_location_trail');
|
||||
db.exec('DROP TABLE IF EXISTS journey_photos');
|
||||
db.exec('DROP TABLE IF EXISTS journey_entries');
|
||||
db.exec('DROP TABLE IF EXISTS journey_checkins');
|
||||
db.exec('DROP TABLE IF EXISTS journey_members');
|
||||
db.exec('DROP TABLE IF EXISTS journey_trips');
|
||||
db.exec('DROP TABLE IF EXISTS journeys');
|
||||
}
|
||||
|
||||
// New schema
|
||||
db.exec(`
|
||||
CREATE TABLE journeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
cover_gradient TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_trips (
|
||||
journey_id INTEGER NOT NULL,
|
||||
trip_id INTEGER NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (journey_id, trip_id),
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id INTEGER NOT NULL,
|
||||
source_trip_id INTEGER,
|
||||
source_place_id INTEGER,
|
||||
author_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
story TEXT,
|
||||
entry_date TEXT NOT NULL,
|
||||
entry_time TEXT,
|
||||
location_name TEXT,
|
||||
location_lat REAL,
|
||||
location_lng REAL,
|
||||
mood TEXT,
|
||||
weather TEXT,
|
||||
tags TEXT,
|
||||
visibility TEXT DEFAULT 'private',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE journey_contributors (
|
||||
journey_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (journey_id, user_id),
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Indexes
|
||||
db.exec(`
|
||||
CREATE INDEX idx_journeys_user ON journeys(user_id);
|
||||
CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date);
|
||||
CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id);
|
||||
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id);
|
||||
CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id);
|
||||
`);
|
||||
|
||||
// Re-import old data if it existed
|
||||
if (oldJourneys.length > 0) {
|
||||
const ts = Date.now();
|
||||
const journeyIdMap = new Map<string, number>(); // old TEXT id -> new INTEGER id
|
||||
|
||||
for (const j of oldJourneys) {
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
j.user_id,
|
||||
j.title || 'Untitled Journey',
|
||||
j.description || null,
|
||||
j.status || 'draft',
|
||||
j.created_at ? new Date(j.created_at).getTime() : ts,
|
||||
j.updated_at ? new Date(j.updated_at).getTime() : ts
|
||||
);
|
||||
journeyIdMap.set(j.id, Number(res.lastInsertRowid));
|
||||
|
||||
// Add owner as contributor
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at)
|
||||
VALUES (?, ?, 'owner', ?)
|
||||
`).run(Number(res.lastInsertRowid), j.user_id, ts);
|
||||
|
||||
// Link trip if old journey had one
|
||||
if (j.trip_id) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(Number(res.lastInsertRowid), j.trip_id, ts);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate entries
|
||||
const entryIdMap = new Map<string, number>();
|
||||
for (const e of oldEntries) {
|
||||
const newJourneyId = journeyIdMap.get(e.journey_id);
|
||||
if (!newJourneyId) continue;
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
newJourneyId,
|
||||
e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1,
|
||||
e.title || null,
|
||||
e.body || null,
|
||||
e.entry_date || new Date().toISOString().split('T')[0],
|
||||
e.place_name || null,
|
||||
e.lat || null,
|
||||
e.lng || null,
|
||||
e.mood || null,
|
||||
e.weather || null,
|
||||
e.visibility || 'private',
|
||||
e.sort_order || 0,
|
||||
e.created_at ? new Date(e.created_at).getTime() : ts,
|
||||
e.updated_at ? new Date(e.updated_at).getTime() : ts
|
||||
);
|
||||
entryIdMap.set(e.id, Number(res.lastInsertRowid));
|
||||
}
|
||||
|
||||
// Migrate photos
|
||||
for (const p of oldPhotos) {
|
||||
const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null;
|
||||
if (!newEntryId || !p.file_path) continue;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
newEntryId,
|
||||
p.file_path,
|
||||
p.thumbnail_path || null,
|
||||
p.caption || null,
|
||||
p.sort_order || 0,
|
||||
p.width || null,
|
||||
p.height || null,
|
||||
p.created_at ? new Date(p.created_at).getTime() : ts
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`);
|
||||
}
|
||||
},
|
||||
// Migration 88: Journey photos — provider support (Immich/Synology)
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
|
||||
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
|
||||
// file_path was NOT NULL — recreate table to make it nullable
|
||||
const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
|
||||
if (hasProvider) {
|
||||
// Already has the column, just ensure file_path is nullable by recreating
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'local',
|
||||
asset_id TEXT,
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos;
|
||||
DROP TABLE journey_photos;
|
||||
ALTER TABLE journey_photos_new RENAME TO journey_photos;
|
||||
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
|
||||
`);
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
// Migration 89: Journey cover image
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
|
||||
},
|
||||
// Migration 90: Pros/Cons for journey entries
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
|
||||
},
|
||||
// Migration 91: Journey share tokens
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS journey_share_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
journey_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER NOT NULL,
|
||||
share_timeline INTEGER DEFAULT 1,
|
||||
share_gallery INTEGER DEFAULT 1,
|
||||
share_map INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -89,6 +89,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
];
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import * as svc from '../services/journeyService';
|
||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||
import { uploadToImmich } from '../services/memories/immichService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../uploads/journey');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
|
||||
cb(null, uploadsBase);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
cb(null, `${crypto.randomUUID()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
||||
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ journeys: svc.listJourneys(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { title, subtitle, trip_ids } = req.body || {};
|
||||
if (!title || typeof title !== 'string' || !title.trim()) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
const journey = svc.createJourney(authReq.user.id, {
|
||||
title: title.trim(),
|
||||
subtitle,
|
||||
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
|
||||
});
|
||||
res.status(201).json(journey);
|
||||
});
|
||||
|
||||
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.getSuggestions(authReq.user.id) });
|
||||
});
|
||||
|
||||
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json({ trips: svc.listUserTrips(authReq.user.id) });
|
||||
});
|
||||
|
||||
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
|
||||
|
||||
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Entry not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
|
||||
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of files) {
|
||||
const relativePath = `journey/${file.filename}`;
|
||||
const photo = svc.addPhoto(
|
||||
Number(req.params.entryId),
|
||||
authReq.user.id,
|
||||
relativePath,
|
||||
undefined,
|
||||
req.body?.caption
|
||||
);
|
||||
if (photo) {
|
||||
// sync to Immich if connected — update the same photo record
|
||||
try {
|
||||
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
|
||||
if (immichId) {
|
||||
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
|
||||
photo.provider = 'immich' as any;
|
||||
photo.asset_id = immichId;
|
||||
photo.owner_id = authReq.user.id;
|
||||
}
|
||||
} catch {}
|
||||
results.push(photo);
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json({ photos: results });
|
||||
});
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, caption } = req.body || {};
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
// Link an existing photo to a (different) entry
|
||||
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { photo_id } = req.body || {};
|
||||
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
|
||||
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Photo not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
// delete local file
|
||||
if (photo.file_path) {
|
||||
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
|
||||
try { fs.unlinkSync(fullPath); } catch {}
|
||||
}
|
||||
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
|
||||
// photos imported from Immich (no file_path) are just references — don't touch Immich
|
||||
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
|
||||
try {
|
||||
const { getImmichCredentials } = await import('../services/memories/immichService');
|
||||
const creds = getImmichCredentials(authReq.user.id);
|
||||
if (creds) {
|
||||
const { safeFetch } = await import('../utils/ssrfGuard');
|
||||
await safeFetch(`${creds.immich_url}/api/assets`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [photo.asset_id] }),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
|
||||
|
||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
|
||||
if (!data) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
router.patch('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const relativePath = `journey/${req.file.filename}`;
|
||||
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
|
||||
if (!result) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Journey not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Journey trips ────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { trip_id } = req.body || {};
|
||||
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
|
||||
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Entries under journey ────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
|
||||
if (!entries) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.json({ entries });
|
||||
});
|
||||
|
||||
router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { entry_date } = req.body || {};
|
||||
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
|
||||
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
|
||||
if (!entry) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { user_id, role } = req.body || {};
|
||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
||||
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.status(201).json({ success: true });
|
||||
});
|
||||
|
||||
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { role } = req.body || {};
|
||||
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Share Link ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const link = getJourneyShareLink(Number(req.params.id));
|
||||
res.json({ link });
|
||||
});
|
||||
|
||||
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { share_timeline, share_gallery, share_map } = req.body || {};
|
||||
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
deleteJourneyShareLink(Number(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,50 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
|
||||
import { streamImmichAsset } from '../services/memories/immichService';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:token', (req: Request, res: Response) => {
|
||||
const data = getPublicJourney(req.params.token);
|
||||
if (!data) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Public photo proxy — validates share token instead of auth
|
||||
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, provider, assetId, ownerId, kind } = req.params;
|
||||
|
||||
// Validate token and that this asset belongs to the shared journey
|
||||
const valid = validateShareTokenForAsset(token, assetId);
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (provider === 'local') {
|
||||
// Local file — assetId is the file_path
|
||||
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
|
||||
const resolved = path.resolve(filePath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
return res.sendFile(resolved);
|
||||
}
|
||||
|
||||
// Immich/Synology — proxy through
|
||||
const effectiveOwnerId = valid.ownerId || Number(ownerId);
|
||||
if (provider === 'immich') {
|
||||
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
|
||||
} else {
|
||||
// Synology or other providers — try dynamic import
|
||||
try {
|
||||
const { streamSynologyAsset } = await import('../services/memories/synologyService');
|
||||
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
} catch {
|
||||
res.status(404).json({ error: 'Provider not supported' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
importGoogleList,
|
||||
searchPlaceImage,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
|
||||
@@ -49,6 +50,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
const place = createPlace(tripId, req.body);
|
||||
res.status(201).json({ place });
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceCreated(Number(tripId), place.id); } catch {}
|
||||
});
|
||||
|
||||
// Import places from GPX file with full track geometry (must be before /:id)
|
||||
@@ -142,6 +144,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
|
||||
res.json({ place });
|
||||
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
@@ -151,6 +154,7 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo
|
||||
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
|
||||
const deleted = deletePlace(tripId, id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Place not found' });
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
|
||||
const contributors = db.prepare(
|
||||
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
||||
).all(journeyId) as { user_id: number }[];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number } | undefined;
|
||||
|
||||
const userIds = new Set(contributors.map(c => c.user_id));
|
||||
if (owner) userIds.add(owner.user_id);
|
||||
|
||||
for (const uid of userIds) {
|
||||
if (uid === excludeUserId) continue;
|
||||
broadcastToUser(uid, { type: event, journeyId, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Access control ───────────────────────────────────────────────────────
|
||||
|
||||
export function canAccessJourney(journeyId: number, userId: number): Journey | null {
|
||||
const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as Journey | undefined;
|
||||
if (own) return own;
|
||||
const contrib = db.prepare(
|
||||
'SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId);
|
||||
if (contrib) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isOwner(journeyId: number, userId: number): boolean {
|
||||
return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
|
||||
}
|
||||
|
||||
export function canEdit(journeyId: number, userId: number): boolean {
|
||||
if (isOwner(journeyId, userId)) return true;
|
||||
const c = db.prepare(
|
||||
"SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?"
|
||||
).get(journeyId, userId) as { role: string } | undefined;
|
||||
return c?.role === 'editor' || c?.role === 'owner';
|
||||
}
|
||||
|
||||
// ── Journey CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listJourneys(userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT DISTINCT j.*,
|
||||
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
||||
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
|
||||
FROM journeys j
|
||||
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
||||
WHERE j.user_id = ? OR jc.user_id = ?
|
||||
ORDER BY j.updated_at DESC
|
||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
|
||||
}
|
||||
|
||||
export function createJourney(userId: number, data: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
trip_ids?: number[];
|
||||
}): Journey {
|
||||
const now = ts();
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'active', ?, ?)
|
||||
`).run(userId, data.title, data.subtitle || null, now, now);
|
||||
|
||||
const journeyId = Number(res.lastInsertRowid);
|
||||
|
||||
// add owner as contributor
|
||||
db.prepare(
|
||||
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
|
||||
).run(journeyId, userId, 'owner', now);
|
||||
|
||||
// link trips and sync skeleton entries
|
||||
if (data.trip_ids?.length) {
|
||||
for (const tripId of data.trip_ids) {
|
||||
addTripToJourney(journeyId, tripId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
}
|
||||
|
||||
export function getJourneyFull(journeyId: number, userId: number) {
|
||||
const journey = canAccessJourney(journeyId, userId);
|
||||
if (!journey) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
// group photos by entry
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
const enrichedEntries = entries.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
source_trip_name: e.source_trip_id
|
||||
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
|
||||
: null,
|
||||
}));
|
||||
|
||||
// linked trips
|
||||
const trips = db.prepare(`
|
||||
SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
|
||||
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
|
||||
FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
|
||||
WHERE jt.journey_id = ? ORDER BY t.start_date ASC
|
||||
`).all(journeyId);
|
||||
|
||||
// contributors
|
||||
const contributors = db.prepare(`
|
||||
SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
|
||||
FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
|
||||
WHERE jc.journey_id = ? ORDER BY jc.added_at
|
||||
`).all(journeyId);
|
||||
|
||||
// stats
|
||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
||||
const photoCount = photos.length;
|
||||
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
return {
|
||||
...journey,
|
||||
entries: enrichedEntries,
|
||||
trips,
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cover_gradient: string;
|
||||
cover_image: string;
|
||||
status: string;
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val !== undefined && allowed.includes(key)) {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(ts());
|
||||
values.push(journeyId);
|
||||
db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
}
|
||||
|
||||
export function deleteJourney(journeyId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Trip management ──────────────────────────────────────────────────────
|
||||
|
||||
export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
|
||||
const now = ts();
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
|
||||
).run(journeyId, tripId, now);
|
||||
} catch { return false; }
|
||||
|
||||
// sync skeleton entries for all places in this trip
|
||||
syncTripPlaces(journeyId, tripId, userId);
|
||||
// import existing trip photos (Immich/Synology) with sharing settings
|
||||
syncTripPhotos(journeyId, tripId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
|
||||
// remove skeleton entries that haven't been filled in
|
||||
db.prepare(`
|
||||
DELETE FROM journey_entries
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton'
|
||||
`).run(journeyId, tripId);
|
||||
|
||||
// detach filled entries from this trip
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
|
||||
`).run(journeyId, tripId);
|
||||
|
||||
db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Sync engine ──────────────────────────────────────────────────────────
|
||||
|
||||
export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
|
||||
const places = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
|
||||
FROM places p
|
||||
LEFT JOIN day_assignments da ON da.place_id = p.id
|
||||
LEFT JOIN days d ON da.day_id = d.id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY d.day_number ASC, da.order_index ASC
|
||||
`).all(tripId) as any[];
|
||||
|
||||
const now = ts();
|
||||
const existing = db.prepare(
|
||||
'SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?'
|
||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||
|
||||
for (const place of places) {
|
||||
if (existingPlaceIds.has(place.id)) continue;
|
||||
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
journeyId, tripId, place.id, authorId,
|
||||
place.name, entryDate, entryTime,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
place.day_number || 0, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// import trip_photos into journey when a trip is linked
|
||||
function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
const tripPhotos = db.prepare(
|
||||
'SELECT * FROM trip_photos WHERE trip_id = ?'
|
||||
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
|
||||
if (!tripPhotos.length) return;
|
||||
|
||||
const now = ts();
|
||||
|
||||
// find or create a "Photos" entry for this trip's photos
|
||||
let photoEntry = db.prepare(`
|
||||
SELECT id FROM journey_entries
|
||||
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
|
||||
`).get(journeyId, tripId) as { id: number } | undefined;
|
||||
|
||||
if (!photoEntry) {
|
||||
// get trip date for the entry
|
||||
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
|
||||
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
|
||||
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
|
||||
photoEntry = { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
|
||||
// import each trip photo, skip duplicates
|
||||
for (const tp of tripPhotos) {
|
||||
const exists = db.prepare(
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
|
||||
).get(photoEntry.id, tp.provider, tp.asset_id);
|
||||
if (exists) continue;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is created
|
||||
export function onPlaceCreated(tripId: number, placeId: number) {
|
||||
const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number }[];
|
||||
if (!links.length) return;
|
||||
|
||||
const place = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
|
||||
FROM places p
|
||||
LEFT JOIN day_assignments da ON da.place_id = p.id
|
||||
LEFT JOIN days d ON da.day_id = d.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as any;
|
||||
if (!place) return;
|
||||
|
||||
const now = ts();
|
||||
for (const link of links) {
|
||||
const already = db.prepare(
|
||||
'SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
||||
).get(link.journey_id, placeId);
|
||||
if (already) continue;
|
||||
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).run(
|
||||
link.journey_id, tripId, placeId, journey.user_id,
|
||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is updated
|
||||
export function onPlaceUpdated(placeId: number) {
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE source_place_id = ?'
|
||||
).all(placeId) as JourneyEntry[];
|
||||
if (!entries.length) return;
|
||||
|
||||
const place = db.prepare(`
|
||||
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
|
||||
FROM places p
|
||||
LEFT JOIN day_assignments da ON da.place_id = p.id
|
||||
LEFT JOIN days d ON da.day_id = d.id
|
||||
WHERE p.id = ?
|
||||
`).get(placeId) as any;
|
||||
if (!place) return;
|
||||
|
||||
const now = ts();
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'skeleton') {
|
||||
// update everything on skeletons
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
place.name,
|
||||
place.day_date || entry.entry_date,
|
||||
place.assignment_time || place.place_time || entry.entry_time,
|
||||
place.address || place.name,
|
||||
place.lat || null, place.lng || null,
|
||||
now, entry.id
|
||||
);
|
||||
} else {
|
||||
// for filled entries, only update location silently
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// called when a trip place is deleted
|
||||
export function onPlaceDeleted(placeId: number) {
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE source_place_id = ?'
|
||||
).all(placeId) as JourneyEntry[];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'skeleton') {
|
||||
// no content: just delete
|
||||
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
|
||||
if (!hasPhotos && !entry.story) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// entry has content: keep it, detach, add note
|
||||
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
|
||||
const newStory = (entry.story || '') + note;
|
||||
db.prepare(
|
||||
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?'
|
||||
).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function listEntries(journeyId: number, userId: number) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
return entries.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
source_trip_name: e.source_trip_id
|
||||
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createEntry(journeyId: number, userId: number, data: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
story?: string;
|
||||
entry_date: string;
|
||||
entry_time?: string;
|
||||
location_name?: string;
|
||||
location_lat?: number;
|
||||
location_lng?: number;
|
||||
mood?: string;
|
||||
weather?: string;
|
||||
tags?: string[];
|
||||
pros_cons?: { pros: string[]; cons: string[] };
|
||||
visibility?: string;
|
||||
}): JourneyEntry | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const now = ts();
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||
).get(journeyId, data.entry_date) as { m: number | null };
|
||||
|
||||
const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length)
|
||||
? JSON.stringify(data.pros_cons) : null;
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
journeyId, userId,
|
||||
data.type || 'entry',
|
||||
data.title || null,
|
||||
data.story || null,
|
||||
data.entry_date,
|
||||
data.entry_time || null,
|
||||
data.location_name || null,
|
||||
data.location_lat ?? null,
|
||||
data.location_lng ?? null,
|
||||
data.mood || null,
|
||||
data.weather || null,
|
||||
data.tags?.length ? JSON.stringify(data.tags) : null,
|
||||
prosConsJson,
|
||||
data.visibility || 'private',
|
||||
(maxOrder?.m ?? -1) + 1,
|
||||
now, now
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
|
||||
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
type: string;
|
||||
title: string;
|
||||
story: string;
|
||||
entry_date: string;
|
||||
entry_time: string;
|
||||
location_name: string;
|
||||
location_lat: number;
|
||||
location_lng: number;
|
||||
mood: string;
|
||||
weather: string;
|
||||
tags: string[];
|
||||
pros_cons: { pros: string[]; cons: string[] };
|
||||
visibility: string;
|
||||
sort_order: number;
|
||||
}>): JourneyEntry | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (val === undefined) continue;
|
||||
if (key === 'tags') {
|
||||
fields.push('tags = ?');
|
||||
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
|
||||
} else if (key === 'pros_cons') {
|
||||
fields.push('pros_cons = ?');
|
||||
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
// if adding story to a skeleton, promote to entry
|
||||
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
|
||||
fields.push('type = ?');
|
||||
values.push('entry');
|
||||
}
|
||||
|
||||
if (fields.length === 0) return entry;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(ts());
|
||||
values.push(entryId);
|
||||
db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
// touch the journey
|
||||
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteEntry(entryId: number, userId: number): boolean {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return false;
|
||||
if (!canEdit(entry.journey_id, userId)) return false;
|
||||
|
||||
// move photos to hidden Gallery entry so they stay in the gallery
|
||||
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entryId);
|
||||
if (hasPhotos) {
|
||||
let gallery = db.prepare(
|
||||
"SELECT id FROM journey_entries WHERE journey_id = ? AND title = 'Gallery' AND id != ?"
|
||||
).get(entry.journey_id, entryId) as { id: number } | undefined;
|
||||
if (!gallery) {
|
||||
const now = ts();
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, 'entry', 'Gallery', ?, 999, ?, ?)
|
||||
`).run(entry.journey_id, entry.author_id, entry.entry_date, now, now);
|
||||
gallery = { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE entry_id = ?').run(gallery.id, entryId);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Photos ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
|
||||
VALUES (?, 'local', ?, ?, ?, ?, ?)
|
||||
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
|
||||
if (exists) return null;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
|
||||
if (!source) return null;
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
|
||||
const oldEntryId = source.entry_id;
|
||||
|
||||
// move photo to the target entry
|
||||
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
|
||||
|
||||
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
|
||||
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
|
||||
if (oldEntry && oldEntry.title === 'Gallery') {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
|
||||
if (remaining.c === 0) {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
|
||||
}
|
||||
|
||||
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
if (!photo) return null;
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
|
||||
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
|
||||
if (!fields.length) return photo;
|
||||
|
||||
values.push(photoId);
|
||||
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
if (!photo) return null;
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||
return photo;
|
||||
}
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
export function addContributor(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
if (targetUserId === userId) return false;
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
|
||||
).run(journeyId, targetUserId, role, ts());
|
||||
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function updateContributorRole(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare(
|
||||
'UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?'
|
||||
).run(role, journeyId, targetUserId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare(
|
||||
"DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'"
|
||||
).run(journeyId, targetUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Suggestions ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getSuggestions(userId: number) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
return db.prepare(`
|
||||
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
|
||||
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
|
||||
FROM trips t
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
|
||||
WHERE (t.user_id = ? OR tm.user_id = ?)
|
||||
AND t.end_date IS NOT NULL
|
||||
AND t.end_date >= ?
|
||||
AND t.end_date <= date('now')
|
||||
AND t.id NOT IN (SELECT trip_id FROM journey_trips)
|
||||
ORDER BY t.end_date DESC
|
||||
`).all(userId, userId, userId, thirtyDaysAgo);
|
||||
}
|
||||
|
||||
// ── User trips (for trip picker) ─────────────────────────────────────────
|
||||
|
||||
export function listUserTrips(userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
|
||||
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
|
||||
FROM trips t
|
||||
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
|
||||
WHERE t.user_id = ? OR tm.user_id = ?
|
||||
ORDER BY t.start_date DESC
|
||||
`).all(userId, userId, userId);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { db } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface JourneySharePermissions {
|
||||
share_timeline?: boolean;
|
||||
share_gallery?: boolean;
|
||||
share_map?: boolean;
|
||||
}
|
||||
|
||||
interface JourneyShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
share_timeline: boolean;
|
||||
share_gallery: boolean;
|
||||
share_map: boolean;
|
||||
}
|
||||
|
||||
export function createOrUpdateJourneyShareLink(
|
||||
journeyId: number,
|
||||
createdBy: number,
|
||||
permissions: JourneySharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
const {
|
||||
share_timeline = true,
|
||||
share_gallery = true,
|
||||
share_map = true,
|
||||
} = permissions;
|
||||
|
||||
const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
|
||||
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
|
||||
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteJourneyShareLink(journeyId: number): void {
|
||||
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
|
||||
}
|
||||
|
||||
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
if (!photo) return null;
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
|
||||
}
|
||||
|
||||
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
// Check if this asset belongs to any photo in the shared journey
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.owner_id FROM journey_photos jp
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.asset_id = ? AND je.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
if (!photo) {
|
||||
// Fallback: get journey owner
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
return { ownerId: photo.owner_id };
|
||||
}
|
||||
|
||||
export function getPublicJourney(token: string) {
|
||||
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
|
||||
const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
if (!journey) return null;
|
||||
|
||||
// Entries with photos
|
||||
const entries = db.prepare(`
|
||||
SELECT je.* FROM journey_entries je
|
||||
WHERE je.journey_id = ? AND je.type != 'skeleton'
|
||||
ORDER BY je.entry_date, je.sort_order
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT jp.* FROM journey_photos jp
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE je.journey_id = ?
|
||||
ORDER BY jp.sort_order
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photosByEntry: Record<number, any[]> = {};
|
||||
for (const p of photos) {
|
||||
(photosByEntry[p.entry_id] ||= []).push(p);
|
||||
}
|
||||
|
||||
const enrichedEntries = entries.map(e => ({
|
||||
...e,
|
||||
tags: e.tags ? JSON.parse(e.tags) : [],
|
||||
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
|
||||
photos: photosByEntry[e.id] || [],
|
||||
}));
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: photos.length,
|
||||
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
return {
|
||||
journey: {
|
||||
title: journey.title,
|
||||
subtitle: journey.subtitle,
|
||||
cover_image: journey.cover_image,
|
||||
status: journey.status,
|
||||
},
|
||||
entries: enrichedEntries,
|
||||
stats,
|
||||
permissions: {
|
||||
share_timeline: !!row.share_timeline,
|
||||
share_gallery: !!row.share_gallery,
|
||||
share_map: !!row.share_map,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -123,6 +123,31 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
if (requestingUserId === ownerUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Journey photos use tripId=0 — check journey_photos + journey_contributors
|
||||
if (tripId === '0') {
|
||||
const journeyPhoto = db.prepare(`
|
||||
SELECT jp.entry_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.asset_id = ?
|
||||
AND jp.provider = ?
|
||||
AND jp.owner_id = ?
|
||||
LIMIT 1
|
||||
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
|
||||
if (!journeyPhoto) return false;
|
||||
|
||||
// Check if requesting user is the journey owner or a contributor
|
||||
const access = db.prepare(`
|
||||
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// Regular trip photos
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
|
||||
@@ -357,3 +357,63 @@ export async function syncAlbumAssets(
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload to Immich ──────────────────────────────────────────────────────
|
||||
|
||||
export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return null;
|
||||
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
|
||||
const fullPath = path.join(__dirname, '../../../uploads', filePath);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(fullPath);
|
||||
const boundary = '----ImmichUpload' + Date.now();
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
|
||||
};
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
const addField = (name: string, value: string) => {
|
||||
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
|
||||
};
|
||||
addField('deviceAssetId', `trek-${Date.now()}`);
|
||||
addField('deviceId', 'TREK');
|
||||
addField('fileCreatedAt', now);
|
||||
addField('fileModifiedAt', now);
|
||||
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
|
||||
));
|
||||
parts.push(fileBuffer);
|
||||
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||||
|
||||
const body = Buffer.concat(parts);
|
||||
|
||||
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': creds.immich_api_key,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(body.length),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { id?: string };
|
||||
return data.id || null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,18 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
|
||||
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
|
||||
}
|
||||
|
||||
// Clean up journey entries synced from this trip before deleting
|
||||
// Delete skeleton entries (unfilled synced places)
|
||||
db.prepare(`
|
||||
DELETE FROM journey_entries
|
||||
WHERE source_trip_id = ? AND type = 'skeleton'
|
||||
`).run(tripId);
|
||||
// Detach filled entries (keep user's written content, just remove trip link)
|
||||
db.prepare(`
|
||||
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
|
||||
WHERE source_trip_id = ?
|
||||
`).run(tripId);
|
||||
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
|
||||
|
||||
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
|
||||
|
||||
@@ -301,3 +301,69 @@ export interface Participant {
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
// ── Journey addon ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface Journey {
|
||||
id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
cover_gradient?: string | null;
|
||||
cover_image?: string | null;
|
||||
status: 'draft' | 'active' | 'completed';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyEntry {
|
||||
id: number;
|
||||
journey_id: number;
|
||||
source_trip_id?: number | null;
|
||||
source_place_id?: number | null;
|
||||
author_id: number;
|
||||
type: 'entry' | 'checkin' | 'skeleton';
|
||||
title?: string | null;
|
||||
story?: string | null;
|
||||
entry_date: string;
|
||||
entry_time?: string | null;
|
||||
location_name?: string | null;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
mood?: string | null;
|
||||
weather?: string | null;
|
||||
tags?: string | null;
|
||||
visibility: 'private' | 'shared' | 'public';
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyPhoto {
|
||||
id: number;
|
||||
entry_id: number;
|
||||
provider: 'local' | 'immich' | 'synologyphotos';
|
||||
asset_id?: string | null;
|
||||
owner_id?: number | null;
|
||||
file_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
caption?: string | null;
|
||||
sort_order: number;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
shared: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
journey_id: number;
|
||||
trip_id: number;
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyContributor {
|
||||
journey_id: number;
|
||||
user_id: number;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user