Health endpoint, file types config, budget rename, UI fixes
- Add /api/health endpoint (returns 200 OK without auth) - Update docker-compose healthcheck to use /api/health - Admin: configurable allowed file types - Budget categories can now be renamed (inline edit) - Place inspector: opening hours + files side by side on desktop - Address clamped to 2 lines, coordinates hidden on mobile - Category icon-only on mobile, rating hidden on mobile - Time validation: "10" becomes "10:00" - Hotel picker: separate save button, edit opens full popup - Day header background improved for dark mode - Notes: 150 char limit with counter, textarea input - Files grid: full width when no opening hours - Various responsive fixes
This commit is contained in:
@@ -316,6 +316,54 @@ function initDb() {
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Collab addon tables
|
||||
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category TEXT DEFAULT 'General',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
pinned INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multiple INTEGER DEFAULT 0,
|
||||
closed INTEGER DEFAULT 0,
|
||||
deadline TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
option_index INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(poll_id, user_id, option_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
@@ -457,6 +505,57 @@ function initDb() {
|
||||
)
|
||||
`);
|
||||
},
|
||||
// 25: Collab addon tables
|
||||
() => {
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category TEXT DEFAULT 'General',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
pinned INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multiple INTEGER DEFAULT 0,
|
||||
closed INTEGER DEFAULT 0,
|
||||
deadline TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
option_index INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(poll_id, user_id, option_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||
`);
|
||||
// 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();
|
||||
} catch {}
|
||||
},
|
||||
// Future migrations go here (append only, never reorder)
|
||||
];
|
||||
|
||||
@@ -505,6 +604,7 @@ function initDb() {
|
||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', sort_order: 6 },
|
||||
];
|
||||
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
|
||||
|
||||
@@ -71,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
|
||||
const weatherRoutes = require('./routes/weather');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const budgetRoutes = require('./routes/budget');
|
||||
const collabRoutes = require('./routes/collab');
|
||||
const backupRoutes = require('./routes/backup');
|
||||
|
||||
const oidcRoutes = require('./routes/oidc');
|
||||
@@ -83,8 +84,10 @@ app.use('/api/trips/:tripId/places', placesRoutes);
|
||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||
app.use('/api', assignmentsRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
|
||||
365
server/src/routes/collab.js
Normal file
365
server/src/routes/collab.js
Normal file
@@ -0,0 +1,365 @@
|
||||
const express = require('express');
|
||||
const { db, canAccessTrip } = require('../db/database');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
function verifyTripAccess(tripId, userId) {
|
||||
return canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
// ─── NOTES ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /notes - list all notes for trip
|
||||
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' });
|
||||
|
||||
const notes = db.prepare(`
|
||||
SELECT n.*, u.username, u.avatar
|
||||
FROM collab_notes n
|
||||
JOIN users u ON n.user_id = u.id
|
||||
WHERE n.trip_id = ?
|
||||
ORDER BY n.pinned DESC, n.updated_at DESC
|
||||
`).all(tripId);
|
||||
|
||||
res.json({ notes });
|
||||
});
|
||||
|
||||
// POST /notes - create note
|
||||
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' });
|
||||
|
||||
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');
|
||||
|
||||
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 = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ note });
|
||||
broadcast(tripId, 'collab:note:created', { note }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// PUT /notes/:id - update note
|
||||
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 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' });
|
||||
|
||||
db.prepare(`
|
||||
UPDATE collab_notes SET
|
||||
title = COALESCE(?, title),
|
||||
content = CASE WHEN ? THEN ? ELSE content END,
|
||||
category = COALESCE(?, category),
|
||||
color = COALESCE(?, color),
|
||||
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
content !== undefined ? 1 : 0, content !== undefined ? content : null,
|
||||
category || null,
|
||||
color || null,
|
||||
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
|
||||
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 = ?
|
||||
`).get(id);
|
||||
|
||||
res.json({ note });
|
||||
broadcast(tripId, 'collab:note:updated', { note }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /notes/:id - delete note
|
||||
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' });
|
||||
|
||||
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' });
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
// ─── POLLS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: fetch a poll with aggregated votes
|
||||
function getPollWithVotes(pollId) {
|
||||
const poll = db.prepare(`
|
||||
SELECT p.*, u.username, u.avatar
|
||||
FROM collab_polls p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ?
|
||||
`).get(pollId);
|
||||
|
||||
if (!poll) return null;
|
||||
|
||||
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(pollId);
|
||||
|
||||
poll.votes = votes;
|
||||
return poll;
|
||||
}
|
||||
|
||||
// GET /polls - list all polls with votes
|
||||
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' });
|
||||
|
||||
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
|
||||
`).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;
|
||||
});
|
||||
|
||||
res.json({ polls });
|
||||
});
|
||||
|
||||
// POST /polls - create poll
|
||||
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' });
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
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' });
|
||||
|
||||
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' });
|
||||
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
|
||||
|
||||
const options = JSON.parse(poll.options);
|
||||
if (option_index < 0 || option_index >= options.length) {
|
||||
return res.status(400).json({ error: 'Invalid option index' });
|
||||
}
|
||||
|
||||
// Toggle: if vote exists, remove it; otherwise add it
|
||||
const existingVote = db.prepare(
|
||||
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
|
||||
).get(id, req.user.id, option_index);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
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' });
|
||||
|
||||
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' });
|
||||
|
||||
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
|
||||
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' });
|
||||
|
||||
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' });
|
||||
|
||||
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /messages - list messages (last 100, with pagination via ?before=id)
|
||||
router.get('/messages', authenticate, (req, res) => {
|
||||
const { tripId } = req.params;
|
||||
const { before } = req.query;
|
||||
|
||||
const trip = verifyTripAccess(tripId, req.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Return in chronological order (oldest first)
|
||||
messages.reverse();
|
||||
|
||||
res.json({ messages });
|
||||
});
|
||||
|
||||
// POST /messages - send message
|
||||
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 (!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 (?, ?, ?, ?)
|
||||
`).run(tripId, req.user.id, text.trim(), reply_to || null);
|
||||
|
||||
const message = 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.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
res.status(201).json({ message });
|
||||
broadcast(tripId, 'collab:message:created', { message }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// DELETE /messages/:id - delete own message
|
||||
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' });
|
||||
|
||||
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 (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);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user