v2.6.0 — Collab overhaul, route travel times, chat & notes redesign

## Collab — Complete Redesign
- iMessage-style live chat with blue bubbles, grouped messages, date separators
- Emoji reactions via right-click (desktop) or double-tap (mobile)
- Twemoji (Apple-style) emoji picker with categories
- Link previews with OG image/title/description
- Soft-delete messages with "deleted a message" placeholder
- Message reactions with real-time WebSocket sync
- Chat timestamps respect 12h/24h setting and timezone

## Collab Notes
- Redesigned note cards with colored header bar (booking-card style)
- 2-column grid layout (desktop), 1-column (mobile)
- Category settings modal for managing categories with colors
- File/image attachments on notes with mini-preview thumbnails
- Website links with OG image preview on note cards
- File preview portal (lightbox for images, inline viewer for PDF/TXT)
- Note files appear in Files tab with "From Collab Notes" badge
- Pin highlighting with tinted background
- Author avatar chip in header bar with custom tooltip

## Collab Polls
- Complete rewrite — clean Apple-style poll cards
- Animated progress bars with vote percentages
- Blue check circles for own votes, voter avatars
- Create poll modal with multi-choice toggle
- Active/closed poll sections
- Custom tooltips on voter chips

## What's Next Widget
- New widget showing upcoming trip activities
- Time display with "until" separator
- Participant chips per activity
- Day grouping (Today, Tomorrow, dates)
- Respects 12h/24h and locale settings

## Route Travel Times
- Auto-calculated walking + driving times via OSRM (free, no API key)
- Floating badge on each route segment between places
- Walking person icon + car icon with times
- Hides when zoomed out (< zoom 16)
- Toggle in Settings > Display to enable/disable

## Other Improvements
- Collab addon enabled by default for new installations
- Coming Soon removed from Collab in admin settings
- Tab state persisted across page reloads (sessionStorage)
- Day sidebar expanded/collapsed state persisted
- File preview with extension badges (PDF, TXT, etc.) in Files tab
- Collab Notes filter tab in Files
- Reservations section in Day Detail view
- Dark mode fix for invite button text color
- Chat scroll hidden (no visible scrollbar)
- Mobile: tab icons removed for space, touch-friendly UI
- Fixed 6 backend data structure bugs in Collab (polls, chat, notes)
- Soft-delete for chat messages (persists in history)
- Message reactions table (migration 28)
- Note attachments via trip_files with note_id (migration 30)

## Database Migrations
- Migration 27: budget_item_members table
- Migration 28: collab_message_reactions table
- Migration 29: soft-delete column on collab_messages
- Migration 30: note_id on trip_files, website on collab_notes
This commit is contained in:
Maurice
2026-03-25 22:59:39 +01:00
parent 17288f9a0e
commit 068b90ed72
21 changed files with 2367 additions and 2013 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "nomad-server",
"version": "2.5.7",
"version": "2.6.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",

View File

@@ -553,7 +553,7 @@ function initDb() {
`);
// Ensure collab addon exists for existing installations
try {
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 0, 6)").run();
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run();
} catch {}
},
// 26: Per-assignment times (instead of shared place times)
@@ -583,6 +583,29 @@ function initDb() {
CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id);
`);
},
// 28: Message reactions
() => {
_db.exec(`
CREATE TABLE IF NOT EXISTS collab_message_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id);
`);
},
// 29: Soft-delete for chat messages
() => {
try { _db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {}
},
// 30: Note attachments + website field
() => {
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
},
// Future migrations go here (append only, never reorder)
];
@@ -629,7 +652,7 @@ function initDb() {
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 0, sort_order: 6 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
];
const insertAddon = _db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);

View File

@@ -1,22 +1,68 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
const filesDir = path.join(__dirname, '../../uploads/files');
const noteUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
filename: (req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
}),
limits: { fileSize: 50 * 1024 * 1024 },
});
const router = express.Router({ mergeParams: true });
function verifyTripAccess(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function avatarUrl(user) {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function formatNote(note) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id);
return {
...note,
avatar_url: avatarUrl(note),
attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })),
};
}
function loadReactions(messageId) {
return db.prepare(`
SELECT r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId);
}
function groupReactions(reactions) {
const map = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
}
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
}
function formatMessage(msg, reactions) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
}
// ─── NOTES ───────────────────────────────────────────────────────────────────
// GET /notes - list all notes for trip
// GET /notes
router.get('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare(`
SELECT n.*, u.username, u.avatar
@@ -26,42 +72,35 @@ router.get('/notes', authenticate, (req, res) => {
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId);
res.json({ notes });
res.json({ notes: notes.map(formatNote) });
});
// POST /notes - create note
// POST /notes
router.post('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
const { title, content, category, color } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { title, content, category, color, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1');
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1', website || null);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.id = ?
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ note });
broadcast(tripId, 'collab:note:created', { note }, req.headers['x-socket-id']);
const formatted = formatNote(note);
res.status(201).json({ note: formatted });
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id']);
});
// PUT /notes/:id - update note
// PUT /notes/:id
router.put('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { title, content, category, color, pinned } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { title, content, category, color, pinned, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -73,6 +112,7 @@ router.put('/notes/:id', authenticate, (req, res) => {
category = COALESCE(?, category),
color = COALESCE(?, color),
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
website = CASE WHEN ? THEN ? ELSE website END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
@@ -81,38 +121,77 @@ router.put('/notes/:id', authenticate, (req, res) => {
category || null,
color || null,
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
website !== undefined ? 1 : 0, website !== undefined ? website : null,
id
);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.id = ?
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(id);
res.json({ note });
broadcast(tripId, 'collab:note:updated', { note }, req.headers['x-socket-id']);
const formatted = formatNote(note);
res.json({ note: formatted });
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id']);
});
// DELETE /notes/:id - delete note
// DELETE /notes/:id
router.delete('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
// Delete attached files (physical + DB)
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(id);
for (const f of noteFiles) {
const filePath = path.join(__dirname, '../../uploads', f.filename);
try { fs.unlinkSync(filePath) } catch {}
}
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(id);
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
});
// POST /notes/:id/files — upload attachment to note
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req, res) => {
const { tripId, id } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!note) return res.status(404).json({ error: 'Note not found' });
const result = db.prepare(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, id, `files/${req.file.filename}`, req.file.originalname, req.file.size, req.file.mimetype);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ file: { ...file, url: `/uploads/${file.filename}` } });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
});
// DELETE /notes/:id/files/:fileId — remove attachment
router.delete('/notes/:id/files/:fileId', authenticate, (req, res) => {
const { tripId, id, fileId } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id);
if (!file) return res.status(404).json({ error: 'File not found' });
// Delete physical file
const filePath = path.join(__dirname, '../../uploads', file.filename);
try { fs.unlinkSync(filePath) } catch {}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
res.json({ success: true });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
});
// ─── POLLS ───────────────────────────────────────────────────────────────────
// Helper: fetch a poll with aggregated votes
function getPollWithVotes(pollId) {
const poll = db.prepare(`
SELECT p.*, u.username, u.avatar
@@ -123,7 +202,7 @@ function getPollWithVotes(pollId) {
if (!poll) return null;
poll.options = JSON.parse(poll.options);
const options = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
@@ -132,73 +211,64 @@ function getPollWithVotes(pollId) {
WHERE v.poll_id = ?
`).all(pollId);
poll.votes = votes;
return poll;
// Transform: nest voters into each option (frontend expects options[i].voters)
const formattedOptions = options.map((label, idx) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
}));
return {
...poll,
avatar_url: avatarUrl(poll),
options: formattedOptions,
is_closed: !!poll.closed,
multiple_choice: !!poll.multiple,
};
}
// GET /polls - list all polls with votes
// GET /polls
router.get('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const rows = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.trip_id = ?
ORDER BY p.created_at DESC
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
`).all(tripId);
const polls = rows.map(poll => {
poll.options = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(poll.id);
poll.votes = votes;
return poll;
});
const polls = rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
res.json({ polls });
});
// POST /polls - create poll
// POST /polls
router.post('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
const { question, options, multiple, deadline } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { question, options, multiple, multiple_choice, deadline } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
}
// Accept both 'multiple' and 'multiple_choice' from frontend
const isMultiple = multiple || multiple_choice;
const result = db.prepare(`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, question, JSON.stringify(options), multiple ? 1 : 0, deadline || null);
`).run(tripId, req.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null);
const poll = getPollWithVotes(result.lastInsertRowid);
res.status(201).json({ poll });
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
});
// POST /polls/:id/vote - toggle vote on poll
// POST /polls/:id/vote
router.post('/polls/:id/vote', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { option_index } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -217,27 +287,21 @@ router.post('/polls/:id/vote', authenticate, (req, res) => {
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
} else {
// If not multiple choice, remove any existing votes by this user first
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
}
db.prepare(
'INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)'
).run(id, req.user.id, option_index);
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(id, req.user.id, option_index);
}
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// PUT /polls/:id/close - close poll
// PUT /polls/:id/close
router.put('/polls/:id/close', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -245,17 +309,14 @@ router.put('/polls/:id/close', authenticate, (req, res) => {
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// DELETE /polls/:id - delete poll
// DELETE /polls/:id
router.delete('/polls/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -267,66 +328,61 @@ router.delete('/polls/:id', authenticate, (req, res) => {
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
// GET /messages - list messages (last 100, with pagination via ?before=id)
// GET /messages
router.get('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { before } = req.query;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const query = `
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''}
ORDER BY m.id DESC
LIMIT 100
`;
let messages;
if (before) {
messages = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ? AND m.id < ?
ORDER BY m.id DESC
LIMIT 100
`).all(tripId, before);
} else {
messages = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ?
ORDER BY m.id DESC
LIMIT 100
`).all(tripId);
}
const messages = before
? db.prepare(query).all(tripId, before)
: db.prepare(query).all(tripId);
// Return in chronological order (oldest first)
messages.reverse();
res.json({ messages });
// Batch-load reactions
const msgIds = messages.map(m => m.id);
const reactionsByMsg = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds);
for (const r of allReactions) {
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
reactionsByMsg[r.message_id].push(r);
}
}
res.json({ messages: messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))) });
});
// POST /messages - send message
// POST /messages
router.post('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { text, reply_to } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
// Validate reply_to if provided
if (reply_to) {
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
}
const result = db.prepare(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to)
VALUES (?, ?, ?, ?)
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`).run(tripId, req.user.id, text.trim(), reply_to || null);
const message = db.prepare(`
@@ -339,27 +395,92 @@ router.post('/messages', authenticate, (req, res) => {
WHERE m.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ message });
broadcast(tripId, 'collab:message:created', { message }, req.headers['x-socket-id']);
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id']);
});
// DELETE /messages/:id - delete own message
// POST /messages/:id/react — toggle emoji reaction
router.post('/messages/:id/react', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { emoji } = req.body;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!msg) return res.status(404).json({ error: 'Message not found' });
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(id, req.user.id, emoji);
}
const reactions = groupReactions(loadReactions(id));
res.json({ reactions });
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions }, req.headers['x-socket-id']);
});
// DELETE /messages/:id (soft-delete)
router.delete('/messages/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!message) return res.status(404).json({ error: 'Message not found' });
if (Number(message.user_id) !== Number(req.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' });
if (message.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only delete your own messages' });
}
db.prepare('DELETE FROM collab_messages WHERE id = ?').run(id);
db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: message.username || req.user.username }, req.headers['x-socket-id']);
});
// ─── LINK PREVIEW ────────────────────────────────────────────────────────────
router.get('/link-preview', authenticate, (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'URL is required' });
try {
const fetch = require('node-fetch');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
})
.then(r => {
clearTimeout(timeout);
if (!r.ok) throw new Error('Fetch failed');
return r.text();
})
.then(html => {
const get = (prop) => {
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
res.json({
title: get('title') || (titleTag ? titleTag[1].trim() : null),
description: get('description') || (descMeta ? descMeta[1].trim() : null),
image: get('image') || null,
site_name: get('site_name') || null,
url,
});
})
.catch(() => {
clearTimeout(timeout);
res.json({ title: null, description: null, image: null, url });
});
} catch {
res.json({ title: null, description: null, image: null, url });
}
});
module.exports = router;

View File

@@ -57,7 +57,7 @@ function verifyTripOwnership(tripId, userId) {
function formatFile(file) {
return {
...file,
url: `/uploads/files/${file.filename}`,
url: file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`,
};
}