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:
Maurice
2026-04-11 19:01:34 +02:00
parent 0df90086bf
commit 13956804c2
56 changed files with 10843 additions and 332 deletions
+6 -1
View File
@@ -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);
+432
View File
@@ -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) {
+1
View File
@@ -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);
+290
View File
@@ -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;
+50
View File
@@ -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;
+4
View File
@@ -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' });
+727
View File
@@ -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);
}
+143
View File
@@ -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;
}
}
+12
View File
@@ -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 };
+66
View File
@@ -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;
}