Merge branch 'main' into feat/mfa
This commit is contained in:
@@ -196,6 +196,16 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch {}
|
||||
try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {}
|
||||
},
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {}
|
||||
try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {}
|
||||
},
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
|
||||
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import Database from 'better-sqlite3';
|
||||
|
||||
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
|
||||
const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin';
|
||||
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
|
||||
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@trek.app';
|
||||
const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345';
|
||||
const DEMO_EMAIL = 'demo@nomad.app';
|
||||
const DEMO_EMAIL = 'demo@trek.app';
|
||||
const DEMO_PASS = 'demo12345';
|
||||
|
||||
// Create admin user if not exists
|
||||
|
||||
@@ -63,9 +63,10 @@ app.use(helmet({
|
||||
}
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: process.env.FORCE_HTTPS === 'true' ? { maxAge: 31536000, includeSubDomains: false } : false,
|
||||
}));
|
||||
// Redirect HTTP to HTTPS in production
|
||||
if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') {
|
||||
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
||||
if (process.env.FORCE_HTTPS === 'true') {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
@@ -172,7 +173,7 @@ import * as scheduler from './scheduler';
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`NOMAD API running on port ${PORT}`);
|
||||
console.log(`TREK API running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
||||
|
||||
@@ -94,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
||||
|
||||
router.delete('/users/:id', (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (parseInt(req.params.id) === authReq.user.id) {
|
||||
if (parseInt(req.params.id as string) === authReq.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete own account' });
|
||||
}
|
||||
|
||||
@@ -122,16 +122,18 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
||||
client_id: get('oidc_client_id'),
|
||||
client_secret_set: !!secret,
|
||||
display_name: get('oidc_display_name'),
|
||||
oidc_only: get('oidc_only') === 'true',
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/oidc', (req: Request, res: Response) => {
|
||||
const { issuer, client_id, client_secret, display_name } = req.body;
|
||||
const { issuer, client_id, client_secret, display_name, oidc_only } = req.body;
|
||||
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||
set('oidc_issuer', issuer);
|
||||
set('oidc_client_id', client_id);
|
||||
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
|
||||
set('oidc_display_name', display_name);
|
||||
set('oidc_only', oidc_only ? 'true' : 'false');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -171,7 +173,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
|
||||
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
|
||||
);
|
||||
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
|
||||
const data = await resp.json() as { tag_name?: string; html_url?: string };
|
||||
|
||||
@@ -24,12 +24,18 @@ const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
};
|
||||
|
||||
function getCountryFromCoords(lat: number, lng: number): string | null {
|
||||
let bestCode: string | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
|
||||
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
||||
return code;
|
||||
const area = (maxLng - minLng) * (maxLat - minLat);
|
||||
if (area < bestArea) {
|
||||
bestArea = area;
|
||||
bestCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return bestCode;
|
||||
}
|
||||
|
||||
const NAME_TO_CODE: Record<string, string> = {
|
||||
|
||||
@@ -91,6 +91,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) {
|
||||
}
|
||||
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
|
||||
|
||||
function isOidcOnlyMode(): boolean {
|
||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
const enabled = get('oidc_only') === 'true';
|
||||
if (!enabled) return false;
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
||||
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
||||
);
|
||||
return oidcConfigured;
|
||||
}
|
||||
|
||||
function maskKey(key: string | null | undefined): string | null {
|
||||
if (!key) return null;
|
||||
if (key.length <= 8) return '--------';
|
||||
@@ -116,11 +127,13 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const { version } = require('../../package.json');
|
||||
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||
const oidcDisplayName = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
|
||||
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
|
||||
const oidcConfigured = !!(
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value &&
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value
|
||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
|
||||
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
|
||||
);
|
||||
const oidcOnlySetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||
res.json({
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
has_users: userCount > 0,
|
||||
@@ -128,9 +141,10 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
oidc_only_mode: oidcOnlyMode,
|
||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
});
|
||||
});
|
||||
@@ -139,7 +153,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app') as User | undefined;
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
|
||||
if (!user) return res.status(500).json({ error: 'Demo user not found' });
|
||||
const token = generateToken(user);
|
||||
const safe = stripUserForClient(user) as Record<string, unknown>;
|
||||
@@ -150,6 +164,9 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
if (userCount > 0 && isOidcOnlyMode()) {
|
||||
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
|
||||
}
|
||||
if (userCount > 0) {
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
if (setting?.value === 'false') {
|
||||
@@ -199,6 +216,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
if (isOidcOnlyMode()) {
|
||||
return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' });
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
@@ -247,7 +268,10 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
if (isOidcOnlyMode()) {
|
||||
return res.status(403).json({ error: 'Password authentication is disabled.' });
|
||||
}
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
|
||||
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
|
||||
}
|
||||
const { current_password, new_password } = req.body;
|
||||
@@ -271,7 +295,7 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
|
||||
|
||||
router.delete('/me', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') {
|
||||
return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' });
|
||||
}
|
||||
if (authReq.user.role === 'admin') {
|
||||
|
||||
@@ -5,7 +5,7 @@ import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import scheduler from '../scheduler';
|
||||
import * as scheduler from '../scheduler';
|
||||
import { db, closeDb, reinitialize } from '../db/database';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -211,19 +211,52 @@ router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request,
|
||||
});
|
||||
|
||||
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
try {
|
||||
res.json({ settings: scheduler.loadSettings() });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] GET auto-settings:', err);
|
||||
res.status(500).json({ error: 'Could not load backup settings' });
|
||||
}
|
||||
});
|
||||
|
||||
function parseAutoBackupBody(body: Record<string, unknown>): {
|
||||
enabled: boolean;
|
||||
interval: string;
|
||||
keep_days: number;
|
||||
} {
|
||||
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
||||
const rawInterval = body.interval;
|
||||
const interval =
|
||||
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
|
||||
? rawInterval
|
||||
: 'daily';
|
||||
const rawKeep = body.keep_days;
|
||||
let keepNum: number;
|
||||
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
|
||||
keepNum = Math.floor(rawKeep);
|
||||
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
|
||||
keepNum = parseInt(rawKeep, 10);
|
||||
} else {
|
||||
keepNum = NaN;
|
||||
}
|
||||
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
|
||||
return { enabled, interval, keep_days };
|
||||
}
|
||||
|
||||
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
const { enabled, interval, keep_days } = req.body;
|
||||
const settings = {
|
||||
enabled: !!enabled,
|
||||
interval: scheduler.VALID_INTERVALS.includes(interval) ? interval : 'daily',
|
||||
keep_days: Number.isInteger(keep_days) && keep_days >= 0 ? keep_days : 7,
|
||||
};
|
||||
scheduler.saveSettings(settings);
|
||||
scheduler.start();
|
||||
res.json({ settings });
|
||||
try {
|
||||
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
||||
scheduler.saveSettings(settings);
|
||||
scheduler.start();
|
||||
res.json({ settings });
|
||||
} catch (err: unknown) {
|
||||
console.error('[backup] PUT auto-settings:', err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
res.status(500).json({
|
||||
error: 'Could not save auto-backup settings',
|
||||
detail: process.env.NODE_ENV !== 'production' ? msg : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:filename', (req: Request, res: Response) => {
|
||||
|
||||
@@ -219,9 +219,27 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
|
||||
|
||||
const accommodation = getAccommodationWithPlace(result.lastInsertRowid);
|
||||
const accommodationId = result.lastInsertRowid;
|
||||
|
||||
// Auto-create linked reservation for this accommodation
|
||||
const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel';
|
||||
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
|
||||
const meta: Record<string, string> = {};
|
||||
if (check_in) meta.check_in_time = check_in;
|
||||
if (check_out) meta.check_out_time = check_out;
|
||||
db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)
|
||||
`).run(
|
||||
tripId, start_day_id, placeName, startDayDate || null, null,
|
||||
confirmation || null, notes || null, accommodationId,
|
||||
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
|
||||
);
|
||||
|
||||
const accommodation = getAccommodationWithPlace(accommodationId);
|
||||
res.status(201).json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
@@ -260,6 +278,16 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
|
||||
|
||||
// Sync check-in/out/confirmation to linked reservation
|
||||
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
|
||||
if (linkedRes) {
|
||||
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
|
||||
if (newCheckIn) meta.check_in_time = newCheckIn;
|
||||
if (newCheckOut) meta.check_out_time = newCheckOut;
|
||||
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
|
||||
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
|
||||
}
|
||||
|
||||
const accommodation = getAccommodationWithPlace(Number(id));
|
||||
res.json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
@@ -271,6 +299,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque
|
||||
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
// Delete linked reservation
|
||||
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
|
||||
if (linkedRes) {
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: linkedRes.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
@@ -57,6 +57,13 @@ function verifyTripOwnership(tripId: string | number, userId: number) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
const FILE_SELECT = `
|
||||
SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar
|
||||
FROM trip_files f
|
||||
LEFT JOIN reservations r ON f.reservation_id = r.id
|
||||
LEFT JOIN users u ON f.uploaded_by = u.id
|
||||
`;
|
||||
|
||||
function formatFile(file: TripFile) {
|
||||
return {
|
||||
...file,
|
||||
@@ -64,24 +71,23 @@ function formatFile(file: TripFile) {
|
||||
};
|
||||
}
|
||||
|
||||
// List files (excludes soft-deleted by default)
|
||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const showTrash = req.query.trash === 'true';
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const files = db.prepare(`
|
||||
SELECT f.*, r.title as reservation_title
|
||||
FROM trip_files f
|
||||
LEFT JOIN reservations r ON f.reservation_id = r.id
|
||||
WHERE f.trip_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
`).all(tripId) as TripFile[];
|
||||
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
|
||||
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
|
||||
res.json({ files: files.map(formatFile) });
|
||||
});
|
||||
|
||||
// Upload file
|
||||
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { place_id, description, reservation_id } = req.body;
|
||||
|
||||
@@ -90,8 +96,8 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
place_id || null,
|
||||
@@ -100,19 +106,16 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
|
||||
req.file.originalname,
|
||||
req.file.size,
|
||||
req.file.mimetype,
|
||||
description || null
|
||||
description || null,
|
||||
authReq.user.id
|
||||
);
|
||||
|
||||
const file = db.prepare(`
|
||||
SELECT f.*, r.title as reservation_title
|
||||
FROM trip_files f
|
||||
LEFT JOIN reservations r ON f.reservation_id = r.id
|
||||
WHERE f.id = ?
|
||||
`).get(result.lastInsertRowid) as TripFile;
|
||||
const file = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile;
|
||||
res.status(201).json({ file: formatFile(file) });
|
||||
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Update file metadata
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
@@ -126,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
db.prepare(`
|
||||
UPDATE trip_files SET
|
||||
description = COALESCE(?, description),
|
||||
description = ?,
|
||||
place_id = ?,
|
||||
reservation_id = ?
|
||||
WHERE id = ?
|
||||
@@ -137,16 +140,31 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT f.*, r.title as reservation_title
|
||||
FROM trip_files f
|
||||
LEFT JOIN reservations r ON f.reservation_id = r.id
|
||||
WHERE f.id = ?
|
||||
`).get(id) as TripFile;
|
||||
const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
||||
res.json({ file: formatFile(updated) });
|
||||
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Toggle starred
|
||||
router.patch('/:id/star', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
const newStarred = file.starred ? 0 : 1;
|
||||
db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id);
|
||||
|
||||
const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
||||
res.json({ file: formatFile(updated) });
|
||||
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Soft-delete (move to trash)
|
||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
@@ -157,6 +175,40 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
|
||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||
|
||||
db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Restore from trash
|
||||
router.post('/:id/restore', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
|
||||
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||
|
||||
db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id);
|
||||
|
||||
const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
||||
res.json({ file: formatFile(restored) });
|
||||
broadcast(tripId, 'file:created', { file: formatFile(restored) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Permanently delete from trash
|
||||
router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
|
||||
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||
|
||||
const filePath = path.join(filesDir, file.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
|
||||
@@ -167,4 +219,24 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// Empty entire trash
|
||||
router.delete('/trash/empty', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
|
||||
for (const file of trashed) {
|
||||
const filePath = path.join(filesDir, file.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
|
||||
res.json({ success: true, deleted: trashed.length });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,6 +13,166 @@ interface NominatimResult {
|
||||
lon: string;
|
||||
}
|
||||
|
||||
interface OverpassElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface WikiCommonsPage {
|
||||
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
}
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
|
||||
|
||||
// ── OSM Enrichment: Overpass API for details ──────────────────────────────────
|
||||
|
||||
async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
|
||||
const oType = typeMap[osmType];
|
||||
if (!oType) return null;
|
||||
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { elements?: OverpassElement[] };
|
||||
return data.elements?.[0] || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const result: string[] = LONG.map(d => `${d}: ?`);
|
||||
|
||||
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
||||
for (const segment of ohString.split(';')) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
|
||||
if (!match) continue;
|
||||
const [, daysPart, timePart] = match;
|
||||
const dayIndices = new Set<number>();
|
||||
for (const range of daysPart.split(',')) {
|
||||
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
|
||||
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
||||
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
|
||||
dayIndices.add(parts[1]);
|
||||
} else if (parts[0] >= 0) {
|
||||
dayIndices.add(parts[0]);
|
||||
}
|
||||
}
|
||||
for (const idx of dayIndices) {
|
||||
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute openNow
|
||||
let openNow: boolean | null = null;
|
||||
try {
|
||||
const now = new Date();
|
||||
const jsDay = now.getDay();
|
||||
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
|
||||
const todayLine = result[dayIdx];
|
||||
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
||||
if (timeRanges.length > 0) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
openNow = timeRanges.some(m => {
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||||
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
||||
});
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
return { weekdayDescriptions: result, openNow };
|
||||
}
|
||||
|
||||
function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
|
||||
let opening_hours: string[] | null = null;
|
||||
let open_now: boolean | null = null;
|
||||
if (tags.opening_hours) {
|
||||
const parsed = parseOpeningHours(tags.opening_hours);
|
||||
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
|
||||
if (hasData) {
|
||||
opening_hours = parsed.weekdayDescriptions;
|
||||
open_now = parsed.openNow;
|
||||
}
|
||||
}
|
||||
return {
|
||||
website: tags['contact:website'] || tags.website || null,
|
||||
phone: tags['contact:phone'] || tags.phone || null,
|
||||
opening_hours,
|
||||
open_now,
|
||||
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
|
||||
summary: tags.description || null,
|
||||
source: 'openstreetmap' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Wikimedia Commons: Free place photos ──────────────────────────────────────
|
||||
|
||||
async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
|
||||
// Strategy 1: Search Wikipedia for the place name → get the article image
|
||||
if (name) {
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
action: 'query', format: 'json',
|
||||
titles: name,
|
||||
prop: 'pageimages',
|
||||
piprop: 'original',
|
||||
pilimit: '1',
|
||||
redirects: '1',
|
||||
});
|
||||
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { query?: { pages?: Record<string, { original?: { source?: string } }> } };
|
||||
const pages = data.query?.pages;
|
||||
if (pages) {
|
||||
for (const page of Object.values(pages)) {
|
||||
if (page.original?.source) {
|
||||
return { photoUrl: page.original.source, attribution: 'Wikipedia' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fall through to geosearch */ }
|
||||
}
|
||||
|
||||
// Strategy 2: Wikimedia Commons geosearch by coordinates
|
||||
const params = new URLSearchParams({
|
||||
action: 'query', format: 'json',
|
||||
generator: 'geosearch',
|
||||
ggsprimary: 'all',
|
||||
ggsnamespace: '6',
|
||||
ggsradius: '300',
|
||||
ggscoord: `${lat}|${lng}`,
|
||||
ggslimit: '5',
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|extmetadata|mime',
|
||||
iiurlwidth: '600',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
|
||||
const pages = data.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const page of Object.values(pages)) {
|
||||
const info = page.imageinfo?.[0];
|
||||
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
|
||||
const mime = (info as { mime?: string })?.mime || '';
|
||||
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
||||
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
||||
return { photoUrl: info.url, attribution };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
interface GooglePlaceResult {
|
||||
id: string;
|
||||
displayName?: { text: string };
|
||||
@@ -69,13 +229,13 @@ async function searchNominatim(query: string, lang?: string) {
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' },
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)' },
|
||||
});
|
||||
if (!response.ok) throw new Error('Nominatim API error');
|
||||
const data = await response.json() as NominatimResult[];
|
||||
return data.map(item => ({
|
||||
google_place_id: null,
|
||||
osm_id: `${item.osm_type}/${item.osm_id}`,
|
||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
||||
name: item.name || item.display_name?.split(',')[0] || '',
|
||||
address: item.display_name || '',
|
||||
lat: parseFloat(item.lat) || null,
|
||||
@@ -145,6 +305,21 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
|
||||
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
||||
if (placeId.includes(':')) {
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
try {
|
||||
const element = await fetchOverpassDetails(osmType, osmId);
|
||||
if (!element?.tags) return res.json({ place: buildOsmDetails({}, osmType, osmId) });
|
||||
res.json({ place: buildOsmDetails(element.tags, osmType, osmId) });
|
||||
} catch (err: unknown) {
|
||||
console.error('OSM details error:', err);
|
||||
res.status(500).json({ error: 'Error fetching OSM details' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Google details
|
||||
const apiKey = getMapsKey(authReq.user.id);
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
||||
@@ -187,6 +362,7 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
|
||||
time: r.relativePublishTimeDescription || null,
|
||||
photo: r.authorAttribution?.photoUri || null,
|
||||
})),
|
||||
source: 'google' as const,
|
||||
};
|
||||
|
||||
res.json({ place });
|
||||
@@ -205,11 +381,28 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
|
||||
}
|
||||
|
||||
// Wikimedia Commons fallback for OSM places (using lat/lng query params)
|
||||
const lat = parseFloat(req.query.lat as string);
|
||||
const lng = parseFloat(req.query.lng as string);
|
||||
|
||||
const apiKey = getMapsKey(authReq.user.id);
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'Google Maps API key not configured' });
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// No Google key or coordinate-only lookup → try Wikimedia
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, req.query.name as string);
|
||||
if (wiki) {
|
||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||
return res.json(wiki);
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return res.status(404).json({ error: 'No photo available' });
|
||||
}
|
||||
|
||||
// Google Photos
|
||||
try {
|
||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
@@ -259,4 +452,26 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
// Reverse geocoding via Nominatim
|
||||
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
||||
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
||||
'accept-language': lang || 'en',
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!response.ok) return res.json({ name: null, address: null });
|
||||
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||
const addr = data.address || {};
|
||||
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
||||
res.json({ name, address: data.display_name || null });
|
||||
} catch {
|
||||
res.json({ name: null, address: null });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -52,10 +52,10 @@ setInterval(() => {
|
||||
|
||||
function getOidcConfig() {
|
||||
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
const issuer = get('oidc_issuer');
|
||||
const clientId = get('oidc_client_id');
|
||||
const clientSecret = get('oidc_client_secret');
|
||||
const displayName = get('oidc_display_name') || 'SSO';
|
||||
const issuer = process.env.OIDC_ISSUER || get('oidc_issuer');
|
||||
const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
|
||||
const clientSecret = process.env.OIDC_CLIENT_SECRET || get('oidc_client_secret');
|
||||
const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
|
||||
if (!issuer || !clientId || !clientSecret) return null;
|
||||
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface UnsplashSearchResponse {
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
const { tripId } = req.params
|
||||
const { search, category, tag } = req.query;
|
||||
|
||||
let query = `
|
||||
@@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
|
||||
|
||||
if (category) {
|
||||
query += ' AND p.category_id = ?';
|
||||
params.push(category);
|
||||
params.push(category as string);
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
|
||||
params.push(tag);
|
||||
params.push(tag as string);
|
||||
}
|
||||
|
||||
query += ' ORDER BY p.created_at DESC';
|
||||
@@ -73,12 +73,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) =
|
||||
});
|
||||
|
||||
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||
const { tripId } = req.params;
|
||||
const { tripId } = req.params
|
||||
|
||||
const {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone,
|
||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
||||
transport_mode, tags = []
|
||||
} = req.body;
|
||||
|
||||
@@ -89,13 +89,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
const result = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId, name, description || null, lat || null, lng || null, address || null,
|
||||
category_id || null, price || null, currency || null,
|
||||
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
|
||||
google_place_id || null, website || null, phone || null, transport_mode || 'walking'
|
||||
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking'
|
||||
);
|
||||
|
||||
const placeId = result.lastInsertRowid;
|
||||
@@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
|
||||
}
|
||||
}
|
||||
|
||||
const place = getPlaceWithTags(placeId);
|
||||
const place = getPlaceWithTags(Number(placeId));
|
||||
res.status(201).json({ place });
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { tripId, id } = req.params
|
||||
|
||||
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!placeCheck) {
|
||||
@@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response
|
||||
|
||||
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { tripId, id } = req.params
|
||||
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
||||
if (!place) {
|
||||
@@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { tripId, id } = req.params
|
||||
|
||||
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
|
||||
if (!existingPlace) {
|
||||
@@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const { tripId, id } = req.params;
|
||||
const { tripId, id } = req.params
|
||||
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!place) {
|
||||
|
||||
@@ -18,10 +18,13 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`).all(tripId);
|
||||
@@ -32,16 +35,29 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
|
||||
// Auto-create accommodation for hotel reservations
|
||||
let resolvedAccommodationId = accommodation_id || null;
|
||||
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (accPlaceId && start_day_id && end_day_id) {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
resolvedAccommodationId = accResult.lastInsertRowid;
|
||||
broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
day_id || null,
|
||||
@@ -54,14 +70,32 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
status || 'pending',
|
||||
type || 'other'
|
||||
type || 'other',
|
||||
resolvedAccommodationId,
|
||||
metadata ? JSON.stringify(metadata) : null
|
||||
);
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
if (accommodation_id && metadata) {
|
||||
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
.run(confirmation_number, accommodation_id);
|
||||
}
|
||||
}
|
||||
|
||||
const reservation = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
@@ -72,7 +106,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
@@ -80,6 +114,24 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
|
||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
// Update or create accommodation for hotel reservations
|
||||
let resolvedAccId = accommodation_id !== undefined ? (accommodation_id || null) : reservation.accommodation_id;
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (accPlaceId && start_day_id && end_day_id) {
|
||||
if (resolvedAccId) {
|
||||
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
|
||||
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
|
||||
} else {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
||||
resolvedAccId = accResult.lastInsertRowid;
|
||||
}
|
||||
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE reservations SET
|
||||
title = COALESCE(?, title),
|
||||
@@ -92,7 +144,9 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
type = COALESCE(?, type)
|
||||
type = COALESCE(?, type),
|
||||
accommodation_id = ?,
|
||||
metadata = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
@@ -106,14 +160,34 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id,
|
||||
status || null,
|
||||
type || null,
|
||||
resolvedAccId,
|
||||
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : reservation.metadata,
|
||||
id
|
||||
);
|
||||
|
||||
// Sync check-in/out to accommodation if linked
|
||||
const resolvedMeta = metadata !== undefined ? metadata : (reservation.metadata ? JSON.parse(reservation.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : reservation.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
.run(resolvedConf, resolvedAccId);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id
|
||||
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
|
||||
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
|
||||
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
|
||||
WHERE r.id = ?
|
||||
`).get(id);
|
||||
|
||||
@@ -128,9 +202,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; accommodation_id: number | null } | undefined;
|
||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
// Delete linked accommodation if exists
|
||||
if (reservation.accommodation_id) {
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
@@ -69,6 +69,7 @@ function getOwnPlan(userId: number) {
|
||||
const yr = new Date().getFullYear();
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
@@ -296,11 +297,15 @@ router.post('/invite/accept', (req: Request, res: Response) => {
|
||||
const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488'];
|
||||
const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, authReq.user.id) as { color: string }[]).map(r => r.color);
|
||||
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, plan_id) as { color: string } | undefined;
|
||||
if (myColor && existingColors.includes(myColor.color)) {
|
||||
const effectiveColor = myColor?.color || '#6366f1';
|
||||
if (existingColors.includes(effectiveColor)) {
|
||||
const available = COLORS.find(c => !existingColors.includes(c));
|
||||
if (available) {
|
||||
db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, authReq.user.id, plan_id);
|
||||
db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(authReq.user.id, plan_id, available);
|
||||
}
|
||||
} else if (!myColor) {
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(authReq.user.id, plan_id, effectiveColor);
|
||||
}
|
||||
|
||||
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id) as { year: number }[];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import cron from 'node-cron';
|
||||
import cron, { type ScheduledTask } from 'node-cron';
|
||||
import archiver from 'archiver';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -23,7 +23,7 @@ interface BackupSettings {
|
||||
keep_days: number;
|
||||
}
|
||||
|
||||
let currentTask: cron.ScheduledTask | null = null;
|
||||
let currentTask: ScheduledTask | null = null;
|
||||
|
||||
function loadSettings(): BackupSettings {
|
||||
try {
|
||||
@@ -110,7 +110,7 @@ function start(): void {
|
||||
}
|
||||
|
||||
// Demo mode: hourly reset of demo user data
|
||||
let demoTask: cron.ScheduledTask | null = null;
|
||||
let demoTask: ScheduledTask | null = null;
|
||||
|
||||
function startDemoReset(): void {
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface Place {
|
||||
notes?: string | null;
|
||||
image_url?: string | null;
|
||||
google_place_id?: string | null;
|
||||
osm_id?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
transport_mode?: string;
|
||||
@@ -147,6 +148,8 @@ export interface Reservation {
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
type: string;
|
||||
accommodation_id?: number | null;
|
||||
metadata?: string | null;
|
||||
created_at?: string;
|
||||
day_number?: number;
|
||||
place_name?: string;
|
||||
@@ -158,11 +161,15 @@ export interface TripFile {
|
||||
place_id?: number | null;
|
||||
reservation_id?: number | null;
|
||||
note_id?: number | null;
|
||||
uploaded_by?: number | null;
|
||||
uploaded_by_name?: string | null;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
description?: string | null;
|
||||
starred?: number;
|
||||
deleted_at?: string | null;
|
||||
created_at?: string;
|
||||
reservation_title?: string;
|
||||
url?: string;
|
||||
|
||||
Reference in New Issue
Block a user