mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Merge remote-tracking branch 'origin/dev' into dev-maurice
# Conflicts: # client/src/components/Todo/TodoListPanel.tsx # server/src/db/migrations.ts
This commit is contained in:
@@ -1634,7 +1634,55 @@ function runMigrations(db: Database.Database): void {
|
||||
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
|
||||
// Migration 105: Persistent Google place photo disk cache registry
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
|
||||
place_id TEXT PRIMARY KEY,
|
||||
attribution TEXT,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
error_at INTEGER
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 106: Persistent Place Details row cache
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_details_cache (
|
||||
place_id TEXT NOT NULL,
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
expanded INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (place_id, lang, expanded)
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
|
||||
{ raw: () => {
|
||||
db.exec(`
|
||||
UPDATE places
|
||||
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE google_place_id IS NOT NULL
|
||||
AND image_url IS NOT NULL
|
||||
AND image_url != ''
|
||||
AND (
|
||||
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
)
|
||||
`);
|
||||
}},
|
||||
// Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology)
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trek_photo_cache_meta (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
|
||||
`),
|
||||
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reservation_endpoints (
|
||||
|
||||
@@ -49,6 +49,7 @@ const server = app.listen(PORT, () => {
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -201,6 +201,63 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Photos ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-photos', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesPhotos());
|
||||
});
|
||||
|
||||
router.put('/places-photos', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesPhotos(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_photos',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-autocomplete', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesAutocomplete());
|
||||
});
|
||||
|
||||
router.put('/places-autocomplete', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesAutocomplete(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_autocomplete',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Details ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-details', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesDetails());
|
||||
});
|
||||
|
||||
router.put('/places-details', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesDetails(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_details',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
|
||||
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, asset_ids, caption } = req.body || {};
|
||||
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
|
||||
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||
|
||||
// Batch mode: { provider, asset_ids: string[] }
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
|
||||
|
||||
// Single mode (backward compat)
|
||||
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);
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
res.status(201).json(photo);
|
||||
});
|
||||
|
||||
@@ -4,11 +4,14 @@ import { AuthRequest } from '../types';
|
||||
import {
|
||||
searchPlaces,
|
||||
getPlaceDetails,
|
||||
getPlaceDetailsExpanded,
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
autocompletePlaces,
|
||||
} from '../services/mapsService';
|
||||
import { db } from '../db/database';
|
||||
import { serveFilePath } from '../services/placePhotoCache';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -32,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
// POST /autocomplete
|
||||
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { input, lang, locationBias } = req.body;
|
||||
|
||||
@@ -70,11 +76,18 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
|
||||
|
||||
// GET /details/:placeId
|
||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
const expand = req.query.expand as string | undefined;
|
||||
|
||||
try {
|
||||
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||
const refresh = req.query.refresh === '1';
|
||||
const result = expand
|
||||
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
|
||||
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
@@ -88,6 +101,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
|
||||
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
|
||||
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
|
||||
if (!placeId.startsWith('coords:')) {
|
||||
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
|
||||
}
|
||||
const lat = parseFloat(req.query.lat as string);
|
||||
const lng = parseFloat(req.query.lng as string);
|
||||
|
||||
@@ -102,6 +121,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
|
||||
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
|
||||
const { placeId } = req.params;
|
||||
const fp = serveFilePath(placeId);
|
||||
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.sendFile(fp);
|
||||
});
|
||||
|
||||
// GET /reverse
|
||||
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
||||
|
||||
+27
-8
@@ -166,14 +166,9 @@ function startTripReminders(): void {
|
||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
||||
const hasWebhook = activeChannels.includes('webhook');
|
||||
const channelReady = hasEmail || hasWebhook;
|
||||
|
||||
if (!channelReady || !reminderEnabled) {
|
||||
if (!reminderEnabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
||||
li(`Trip reminders: disabled (${reason})`);
|
||||
li('Trip reminders: disabled in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,12 +248,36 @@ function startIdempotencyCleanup(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
|
||||
let trekPhotoCacheTask: ScheduledTask | null = null;
|
||||
|
||||
function startTrekPhotoCacheCleanup(): void {
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
|
||||
// Run once immediately on startup to evict any entries left over from a previous run
|
||||
try {
|
||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||
sweepExpired();
|
||||
} catch { /* cache dir may not exist yet — harmless */ }
|
||||
|
||||
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
|
||||
try {
|
||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||
sweepExpired();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -459,6 +459,42 @@ export function updateBagTracking(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesPhotos() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesPhotos(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Autocomplete ────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesAutocomplete() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesAutocomplete(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Details ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesDetails() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesDetails(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
|
||||
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||
'notify_trip_reminder',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
];
|
||||
|
||||
@@ -227,8 +228,13 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||
const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
|
||||
const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesDetailsEnabled = placesDetailsSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
@@ -258,6 +264,9 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
places_photos_enabled: placesPhotosEnabled,
|
||||
places_autocomplete_enabled: placesAutocompleteEnabled,
|
||||
places_details_enabled: placesDetailsEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
|
||||
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
|
||||
(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 place_count,
|
||||
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
||||
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
||||
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 })[];
|
||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
|
||||
}
|
||||
|
||||
export function createJourney(userId: number, data: {
|
||||
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
// 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))];
|
||||
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
const userPrefs = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
entries: enrichedEntries,
|
||||
trips,
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
};
|
||||
}
|
||||
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
}>): Journey | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||
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)) {
|
||||
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
}
|
||||
@@ -628,12 +632,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: 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 trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||
@@ -714,6 +718,7 @@ export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & {
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||
deleteTrekPhotoIfOrphan(photo.photo_id);
|
||||
|
||||
// clean up empty Gallery entries left behind
|
||||
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
|
||||
|
||||
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
|
||||
const stats = {
|
||||
entries: entries.length,
|
||||
photos: photos.length,
|
||||
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,19 @@ import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
let googleApiCallCount = 0;
|
||||
|
||||
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
|
||||
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
return fetch(endpoint, init);
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface NominatimResult {
|
||||
@@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// ── Photo cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of photoCache) {
|
||||
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
|
||||
}
|
||||
if (photoCache.size > CACHE_MAX_ENTRIES) {
|
||||
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||||
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
||||
toDelete.forEach(([key]) => photoCache.delete(key));
|
||||
}
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -371,7 +366,7 @@ export async function autocompletePlaces(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
// Google details
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) {
|
||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
|
||||
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
|
||||
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const place = {
|
||||
google_place_id: data.id,
|
||||
name: data.displayName?.text || '',
|
||||
address: data.formattedAddress || '',
|
||||
lat: data.location?.latitude || null,
|
||||
lng: data.location?.longitude || null,
|
||||
rating: data.rating || null,
|
||||
rating_count: data.userRatingCount || null,
|
||||
website: data.websiteUri || null,
|
||||
phone: data.nationalPhoneNumber || null,
|
||||
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
||||
open_now: data.regularOpeningHours?.openNow ?? null,
|
||||
google_maps_url: data.googleMapsUri || null,
|
||||
summary: null,
|
||||
reviews: [],
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
// Check DB cache for expanded result
|
||||
if (!refresh) {
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
|
||||
).get(placeId, langKey) as { payload_json: string } | undefined;
|
||||
if (cached) return { place: JSON.parse(cached.payload_json) };
|
||||
}
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
@@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
photo: r.authorAttribution?.photoUri || null,
|
||||
})),
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
|
||||
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
|
||||
|
||||
export async function getPlacePhoto(
|
||||
userId: number,
|
||||
@@ -508,84 +579,110 @@ export async function getPlacePhoto(
|
||||
lng: number,
|
||||
name?: string,
|
||||
): Promise<{ photoUrl: string; attribution: string | null }> {
|
||||
// Check cache first
|
||||
const cached = photoCache.get(placeId);
|
||||
if (cached) {
|
||||
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
||||
if (Date.now() - cached.fetchedAt < ttl) {
|
||||
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
|
||||
// Disk cache hit — serve immediately, no Google call
|
||||
const diskHit = placePhotoCache.get(placeId);
|
||||
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
|
||||
|
||||
// Recent error — don't hammer the API
|
||||
if (placePhotoCache.getErrored(placeId)) {
|
||||
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests for the same placeId
|
||||
const existing = placePhotoCache.getInFlight(placeId);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
}
|
||||
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
// Wikimedia photos: fetch bytes and cache to disk
|
||||
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
||||
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
||||
const imgRes = await fetch(wiki.photoUrl);
|
||||
if (imgRes.ok) {
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
photoCache.delete(placeId);
|
||||
}
|
||||
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
// No Google key or coordinate-only lookup -> try Wikimedia
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||
return wiki;
|
||||
} else {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
// Google Photos
|
||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
|
||||
}
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
if (!details.photos?.length) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
|
||||
}
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
if (!mediaRes.ok) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaRes = await fetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
const mediaData = await mediaRes.json() as { photoUri?: string };
|
||||
const photoUrl = mediaData.photoUri;
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!photoUrl) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
|
||||
}
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
// Persist photo URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
|
||||
).run(photoUrl, placeId, '');
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
return { filePath: cached.filePath, attribution };
|
||||
})();
|
||||
|
||||
return { photoUrl, attribution };
|
||||
placePhotoCache.setInFlight(placeId, fetchPromise);
|
||||
|
||||
const result = await fetchPromise;
|
||||
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
}
|
||||
|
||||
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -230,6 +230,30 @@ export async function getAssetInfo(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImmichThumbnailBytes(
|
||||
userId: number,
|
||||
assetId: string,
|
||||
ownerUserId?: number
|
||||
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
|
||||
const effectiveUserId = ownerUserId ?? userId;
|
||||
const creds = getImmichCredentials(effectiveUserId);
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
|
||||
try {
|
||||
const resp = await safeFetch(url, {
|
||||
headers: { 'x-api-key': creds.immich_api_key },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
|
||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||
const bytes = Buffer.from(await resp.arrayBuffer());
|
||||
return { bytes, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamImmichAsset(
|
||||
response: Response,
|
||||
userId: number,
|
||||
|
||||
@@ -3,11 +3,12 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
import * as photoCache from './trekPhotoCache';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,7 +23,7 @@ export function getOrCreateTrekPhoto(
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
@@ -57,6 +58,36 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
async function streamCachedThumbnail(
|
||||
res: Response,
|
||||
photo: TrekPhoto,
|
||||
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
|
||||
fallback: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
|
||||
|
||||
if (photoCache.serveFresh(res, key)) return;
|
||||
|
||||
const existing = photoCache.getInFlight(key);
|
||||
if (existing) {
|
||||
const bytes = await existing;
|
||||
if (bytes && photoCache.serveFresh(res, key)) return;
|
||||
await fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = fetchBytes().then(async result => {
|
||||
if ('error' in result) return null;
|
||||
await photoCache.put(key, result.bytes, result.contentType);
|
||||
return result.bytes;
|
||||
});
|
||||
photoCache.setInFlight(key, promise);
|
||||
|
||||
const bytes = await promise;
|
||||
if (bytes && photoCache.serveFresh(res, key)) return;
|
||||
await fallback();
|
||||
}
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
@@ -84,11 +115,27 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
if (kind === 'thumbnail') {
|
||||
await streamCachedThumbnail(
|
||||
res, photo,
|
||||
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
|
||||
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
if (kind === 'thumbnail') {
|
||||
await streamCachedThumbnail(
|
||||
res, photo,
|
||||
() => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
|
||||
() => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
|
||||
return;
|
||||
}
|
||||
@@ -145,6 +192,19 @@ export function setTrekPhotoProvider(
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Orphan cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
export function deleteTrekPhotoIfOrphan(photoId: number): void {
|
||||
const stillUsed = db.prepare(`
|
||||
SELECT 1 FROM trip_photos WHERE photo_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_photos WHERE photo_id = ?
|
||||
LIMIT 1
|
||||
`).get(photoId, photoId);
|
||||
if (stillUsed) return;
|
||||
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
|
||||
@@ -604,6 +604,47 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ
|
||||
return success(normalized);
|
||||
}
|
||||
|
||||
export async function fetchSynologyThumbnailBytes(
|
||||
userId: number,
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
passphrase?: string,
|
||||
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
|
||||
|
||||
const synology_credentials = _getSynologyCredentials(targetUserId);
|
||||
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
|
||||
|
||||
const sid = await _getSynologySession(targetUserId);
|
||||
if (!sid.success) return { error: 'Session error', status: 500 };
|
||||
if (!sid.data) return { error: 'Session ID missing', status: 500 };
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
try {
|
||||
const resp = await safeFetch(url);
|
||||
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
|
||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||
const bytes = Buffer.from(await resp.arrayBuffer());
|
||||
return { bytes, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamSynologyAsset(
|
||||
response: Response,
|
||||
userId: number,
|
||||
@@ -637,27 +678,20 @@ export async function streamSynologyAsset(
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const resolvedSize = size || 'sm';
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: resolvedSize,
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
: new URLSearchParams({
|
||||
api: 'SYNO.Foto.Download',
|
||||
method: 'download',
|
||||
version: '2',
|
||||
cache_key: parsedId.cacheKey,
|
||||
unit_id: `[${parsedId.id}]`,
|
||||
_sid: sid.data,
|
||||
});
|
||||
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
|
||||
// (original uses xl size to get a full-resolution JPEG-compatible render)
|
||||
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
|
||||
const params = new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: resolvedSize,
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { Response } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek');
|
||||
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const inFlight = new Map<string, Promise<Buffer | null>>();
|
||||
|
||||
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
|
||||
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
|
||||
}
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(TREK_PHOTO_DIR)) {
|
||||
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cachedFilePath(key: string): string {
|
||||
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
|
||||
}
|
||||
|
||||
export function getFresh(key: string): { filePath: string; contentType: string } | null {
|
||||
const row = db.prepare(
|
||||
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
|
||||
).get(key) as { content_type: string; fetched_at: number } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (Date.now() - row.fetched_at >= CACHE_TTL) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fp = cachedFilePath(key);
|
||||
if (!fs.existsSync(fp)) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { filePath: fp, contentType: row.content_type };
|
||||
}
|
||||
|
||||
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
|
||||
ensureDir();
|
||||
const fp = cachedFilePath(key);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
|
||||
).run(key, contentType, Date.now());
|
||||
}
|
||||
|
||||
export function serveFresh(res: Response, key: string): boolean {
|
||||
const entry = getFresh(key);
|
||||
if (!entry) return false;
|
||||
|
||||
res.set('Content-Type', entry.contentType);
|
||||
res.set('Cache-Control', 'public, max-age=3600');
|
||||
res.sendFile(entry.filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
|
||||
return inFlight.get(key);
|
||||
}
|
||||
|
||||
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
|
||||
inFlight.set(key, promise);
|
||||
promise.finally(() => inFlight.delete(key));
|
||||
}
|
||||
|
||||
export function sweepExpired(): void {
|
||||
const cutoff = Date.now() - CACHE_TTL * 2;
|
||||
const stale = db.prepare(
|
||||
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
|
||||
).all(cutoff) as { cache_key: string }[];
|
||||
|
||||
for (const row of stale) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
|
||||
const fp = cachedFilePath(row.cache_key);
|
||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export function removeTripPhoto(
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
deleteTrekPhotoIfOrphan(photoId);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
return success(true);
|
||||
@@ -269,13 +270,20 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
|
||||
}
|
||||
|
||||
try {
|
||||
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.all(tripId, linkId) as Array<{ photo_id: number }>;
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.run(tripId, linkId);
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(linkId, tripId, userId);
|
||||
})();
|
||||
|
||||
|
||||
for (const { photo_id } of linkedPhotos) {
|
||||
deleteTrekPhotoIfOrphan(photo_id);
|
||||
}
|
||||
|
||||
return success(true);
|
||||
} catch (error) {
|
||||
return mapDbError(error, 'Failed to remove album link');
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||
const ERROR_TTL = 5 * 60 * 1000;
|
||||
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
|
||||
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
|
||||
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
|
||||
}
|
||||
|
||||
function proxyUrl(placeId: string): string {
|
||||
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||
}
|
||||
|
||||
interface CachedPhoto {
|
||||
photoUrl: string;
|
||||
filePath: string;
|
||||
attribution: string | null;
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const fp = filePath(placeId);
|
||||
if (!fs.existsSync(fp)) {
|
||||
// File missing (e.g. volume wiped) — clear row so it refetches
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
}
|
||||
|
||||
export function markError(placeId: string): void {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
ensureDir();
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise.finally(() => inFlight.delete(placeId));
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
const fp = filePath(placeId);
|
||||
return fs.existsSync(fp) ? fp : null;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 90,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -55,6 +57,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 80,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -78,6 +82,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -98,6 +104,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 70,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -112,6 +120,8 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 95,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import { createRequire } from 'module';
|
||||
import semver from 'semver';
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNoticeDTO } from './types.js';
|
||||
import type { SystemNotice, SystemNoticeDTO } from './types.js';
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
return process.env.APP_VERSION || '0.0.0';
|
||||
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
const pkg = require('../../package.json') as { version?: string };
|
||||
return semver.valid(pkg.version ?? '') ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
|
||||
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
if (n.minVersion !== undefined) {
|
||||
const min = semver.valid(n.minVersion);
|
||||
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
|
||||
if (semver.lt(appVersion, min)) return false;
|
||||
}
|
||||
if (n.maxVersion !== undefined) {
|
||||
const max = semver.valid(n.maxVersion);
|
||||
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
|
||||
if (semver.gte(appVersion, max)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function severityWeight(s: string): number {
|
||||
@@ -35,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (dismissedIds.has(n.id)) return false;
|
||||
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
|
||||
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -45,7 +69,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
|
||||
}
|
||||
|
||||
export function dismissNotice(userId: number, noticeId: string): boolean {
|
||||
|
||||
@@ -37,9 +37,10 @@ export interface SystemNotice {
|
||||
dismissible: boolean;
|
||||
conditions: NoticeCondition[];
|
||||
publishedAt: string;
|
||||
expiresAt?: string;
|
||||
minVersion?: string;
|
||||
maxVersion?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// DTO sent to client (same shape minus the conditions — server evaluates those)
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
|
||||
|
||||
+1
-1
@@ -329,7 +329,7 @@ export interface Journey {
|
||||
subtitle?: string | null;
|
||||
cover_gradient?: string | null;
|
||||
cover_image?: string | null;
|
||||
status: 'draft' | 'active' | 'completed';
|
||||
status: 'draft' | 'active' | 'completed' | 'archived';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user