Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot] 523bca3a20 chore: bump version to 3.0.0 [skip ci] 2026-04-22 16:59:12 +00:00
Maurice d5be528d4b Merge pull request #758 from mauriceboe/dev
V3.0.0
2026-04-22 18:58:23 +02:00
Julien G. 3ada075b1a Merge pull request #831 from mauriceboe/fix/transport-modal-price-budget-fields
fix: restore Price and Budget Category fields in Edit Transport dialog
2026-04-22 18:55:53 +02:00
jubnl afce302b59 fix: restore price and budget category fields in TransportModal 2026-04-22 18:50:42 +02:00
Maurice 8e8433fa9d docs: align Home.md + README addon list + Tags/Photo-Providers wording with dev state
- Home.md: addon list (9 real addons), MCP numbers (150+ tools, 30 resources, 27 scopes), admin-seeding text
- README.md: expand addon list from 5 to 9 (Lists/Budget/Documents/Naver/MCP in, Dashboard widgets out)
- Photo-Providers.md: 'Memories addon' -> photo provider toggles under Journey
- Admin-Addons.md: Journey works without photo providers; they are optional sub-toggles
- Tags-and-Categories.md: add Personal Tags section (user-scoped, MCP-only for now)
2026-04-22 18:22:22 +02:00
Maurice ff42fa0b8c docs: sync README with current dev state
- MCP: 80+ tools/27 resources -> 150+ tools/30 resources
- MCP: 24 -> 27 OAuth scopes
- i18n: 14 -> 15 languages
- admin seeding on first boot (not first-to-register)
- nginx: client_max_body_size 50m -> 500m, add proxy_read_timeout 86400 on /ws
2026-04-22 18:10:27 +02:00
jubnl ccea7f7a65 fix: restore map share toggle and fix public journey horizontal scroll
Re-adds the share_map permission toggle to the journey share settings UI so
owners can control whether the map is visible on the public share page.
Fixes horizontal scrollbar on the public journey page caused by decorative
hero circles with negative offsets overflowing the viewport.
2026-04-22 17:05:15 +02:00
jubnl 45a5b4e588 fix: remove obsolete map share toggle and make public desktop entries openable
Map permission is always enabled on new links (share always includes map).
Removed the toggle from the share settings UI since the map is now always
part of the combined timeline+map view with no standalone value in toggling it.

Desktop entry cards on the public share page now open MobileEntryView on click,
matching the mobile behaviour added in #826.
2026-04-22 16:33:04 +02:00
jubnl 82cce365f7 fix: validate image-only uploads and respect allowed_file_types setting for journey photos
Add fileFilter to the journey photo multer config (shared by entry photo
upload and gallery upload routes):
- Rejects any non-image MIME type (including SVG which carries XSS risk)
- Checks the extension against the admin-configured allowed_file_types setting
  (same getAllowedExtensions() used by the trip file upload route)
- Returns HTTP 400 with a descriptive message on rejection

Also fix the global error handler to return err.message for 4xx responses
instead of the generic 'Internal server error', so fileFilter rejections
produce a readable error on the client.
2026-04-22 16:16:35 +02:00
jubnl ed7e2badca fix: catch sharp errors in ensureLocalThumbnail and fall back to original
Sharp throws on unsupported formats (HEIC, corrupt files, etc.) and the
error was propagated outside the try/catch, crashing the server. Moved the
mkdir + sharp pipeline inside the catch block so any failure returns null
and streamPhoto falls through to serving the original file.
2026-04-22 16:11:38 +02:00
jubnl ba7b99fb7d fix: update backend tests and service bugs for gallery 1-to-N schema
updatePhoto: write sort_order to journey_entry_photos (junction) not journey_photos,
since JP_SELECT reads jep.sort_order — updating the gallery row had no visible effect.

deletePhoto: include id in return value so callers that check deleted.id still work.

Tests updated for new schema:
- journeyShareService: insertJourneyPhoto helper now inserts into journey_photos
  (keyed by journey_id) + journey_entry_photos junction instead of the old
  entry_id-keyed table
- SVC-081: deleteEntry cascades junction rows (journey_entry_photos), not gallery
  rows (journey_photos); assert junction is gone, gallery is preserved
- SVC-086: syncTripPhotos now populates the gallery directly — no [Trip Photos]
  wrapper entry; assert journey_photos gallery row instead
- INT-028: error message updated to 'journey_photo_id required'
2026-04-22 16:05:18 +02:00
19 changed files with 186 additions and 68 deletions
+14 -8
View File
@@ -127,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Collab** — chat, notes, polls, day-by-day attendance - **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **Journey** — magazine-style travel journal with entries, photos, maps, moods - **Naver List Import** — one-click import from shared Naver Maps lists
- **Dashboard widgets** — currency converter and timezone clocks - **MCP** — expose TREK to AI assistants via OAuth 2.1
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
#### 🤖 AI / MCP #### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources - **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 24 OAuth scopes across 13 permission groups - **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited - **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` - **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -152,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID - **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -172,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. The first user to register becomes admin. Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
<div align="center"> <div align="center">
@@ -338,7 +342,8 @@ server {
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 50m; # 500 MB covers backup-restore uploads (capped at 500 MB server-side).
client_max_body_size 500m;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -355,6 +360,7 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400;
} }
} }
``` ```
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 2.9.14 version: 3.0.0
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "2.9.14" appVersion: "3.0.0"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.0",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.14", "version": "3.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react' import { Plane, Train, Car, Ship } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types' import type { Day, Reservation, ReservationEndpoint } from '../../types'
@@ -75,6 +77,8 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -94,6 +98,13 @@ interface TransportModalProps {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '', meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined }) setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
} }
}, [isOpen, reservation, selectedDayId]) }, [isOpen, reservation, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat if (form.meta_seat) metadata.seat = form.meta_seat
} }
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, needs_review: false,
} }
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload) await onSave(payload)
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
@@ -422,6 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form> </form>
</Modal> </Modal>
) )
+2 -2
View File
@@ -317,7 +317,7 @@ export default function JourneyPublicPage() {
)} )}
{/* Content */} {/* Content */}
<div className="px-5 pt-4 pb-5"> <div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */} {/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && ( {photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3> <h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}> <div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
{journey.cover_image && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)} )}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.9.14", "version": "3.0.0",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+4 -2
View File
@@ -372,8 +372,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
const status = err.statusCode || 500; const status = err.statusCode || err.status || 500;
res.status(status).json({ error: 'Internal server error' }); // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error';
res.status(status).json({ error: message });
}); });
return app; return app;
+18
View File
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database'; import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService'; import { uploadToImmich } from '../services/memories/immichService';
import { getAllowedExtensions } from '../services/fileService';
const router = express.Router(); const router = express.Router();
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
}, },
}); });
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: imageFilter,
}); });
// ── Static prefix routes (MUST come before /:id) ───────────────────────── // ── Static prefix routes (MUST come before /:id) ─────────────────────────
+10 -12
View File
@@ -781,22 +781,20 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
if (!row) return null; if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null; if (!canEdit(row.journey_id, userId)) return null;
const fields: string[] = []; // caption lives on the gallery row; sort_order lives on the junction table
const values: unknown[] = []; // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); } // would not be reflected in the returned row).
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); } if (data.caption !== undefined) {
if (!fields.length) { db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
// no-op: return some photo row for this gallery item (first entry link) }
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; if (data.sort_order !== undefined) {
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
} }
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null; return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
} }
// deletePhoto: hard-delete (backwards compat name used by old route). // deletePhoto: hard-delete (backwards compat name used by old route).
export function deletePhoto(photoId: number, userId: number): { photo_id: number; file_path?: string | null; journey_id: number } | null { export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined; const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
if (!row) return null; if (!row) return null;
if (!canEdit(row.journey_id, userId)) return null; if (!canEdit(row.journey_id, userId)) return null;
@@ -806,7 +804,7 @@ export function deletePhoto(photoId: number, userId: number): { photo_id: number
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId); db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
deleteTrekPhotoIfOrphan(row.photo_id); deleteTrekPhotoIfOrphan(row.photo_id);
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id }; return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
} }
// ── Contributors ───────────────────────────────────────────────────────── // ── Contributors ─────────────────────────────────────────────────────────
@@ -27,7 +27,6 @@ export async function ensureLocalThumbnail(
const meta = await sharp(thumbAbs).metadata() const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} }
} catch { /* regenerate */ }
await fs.mkdir(path.dirname(thumbAbs), { recursive: true }) await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
await sharp(originalAbs) await sharp(originalAbs)
@@ -37,4 +36,8 @@ export async function ensureLocalThumbnail(
.toFile(thumbAbs) .toFile(thumbAbs)
const meta = await sharp(thumbAbs).metadata() const meta = await sharp(thumbAbs).metadata()
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 } return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
} catch {
// Unsupported format, corrupt file, etc. — fall back to original in caller.
return null
}
} }
+1 -1
View File
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({}); .send({});
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toBe('photo_id required'); expect(res.body.error).toBe('journey_photo_id required');
}); });
}); });
@@ -1325,9 +1325,10 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id); const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true); expect(result).toBe(true);
// Photo should be deleted with the entry // Junction row must be gone (ON DELETE CASCADE from journey_entries).
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any; // Gallery row (journey_photos) is preserved — photo may belong to other entries.
expect(deletedPhoto).toBeUndefined(); const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
expect(junctionRow).toBeUndefined();
}); });
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1395,17 +1396,12 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id); addTripToJourney(journey.id, trip.id, user.id);
// Should have a [Trip Photos] entry with the imported photo // Trip photos now go straight into the journey gallery (no wrapper entry).
const photoEntry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(` const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ? WHERE jp.journey_id = ?
`).all(photoEntry.id); `).all(journey.id);
expect(photos.length).toBe(1); expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1'); expect((photos[0] as any).asset_id).toBe('immich-photo-1');
}); });
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------ // -- Helpers ------------------------------------------------------------------
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */ /** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto( function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,10 +70,24 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now()); `).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number; const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(` testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?) VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now()); `).run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
testDb.prepare(`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response // Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind // and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId; return trekId;
+1 -1
View File
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
Some addons require credentials or environment variables before they are functional: Some addons require credentials or environment variables before they are functional:
- **Journey** — requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers). - **Journey** — works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly. - **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
## Related pages ## Related pages
+13 -7
View File
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- **Public Share Links** — share a read-only view of any trip - **Public Share Links** — share a read-only view of any trip
### Addons _(admin-toggleable)_ ### Addons _(admin-toggleable)_
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
- **Documents** — file manager for trips, places, and reservations
- **Collab** — group chat, shared notes, polls, day-by-day attendance
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking - **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown - **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
- **Journey** — travel journal linking entries to trips, with contributor roles - **Journey** — magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
- **Memories** — photo-focused trip memories - **Naver List Import** — import places from shared Naver Maps lists
- **Collab** — group chat, shared notes, polls, and activity sign-ups - **MCP** — expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
### AI / MCP Integration ### AI / MCP Integration
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication - **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more - **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
- **24 OAuth Scopes** — granular permissions across 13 permission groups - **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
- **27 OAuth Scopes** — granular permissions across 13 permission groups
- **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders - **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders
### Admin ### Admin
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
- Addon management, API key storage, scheduled auto-backups - Addon management, API key storage, scheduled auto-backups
- System notices for onboarding and announcements - System notices for onboarding and announcements
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically. > **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
## Get Started ## Get Started
+1 -1
View File
@@ -2,7 +2,7 @@
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser. TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access). > **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** — photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
--- ---
+20 -1
View File
@@ -1,7 +1,9 @@
# Tags and Categories # Tags and Categories
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users). TREK has two independent labelling systems for places:
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
<!-- TODO: screenshot: tag list on place detail --> <!-- TODO: screenshot: tag list on place detail -->
@@ -24,6 +26,23 @@ Categories appear in:
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them. > **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
## Personal Tags
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
**Fields per tag:**
- **Name** — free-form text.
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
### Where to manage them
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
## When to use which ## When to use which
| Use case | Use | | Use case | Use |