feat: notifications, audit logging, and admin improvements

- Add centralized notification service with webhook (Discord/Slack) and
  email (SMTP) support, triggered for trip invites, booking changes,
  collab messages, and trip reminders
- Webhook sends one message per event (group channel); email sends
  individually per trip member, excluding the actor
- Discord invite notifications now include the invited user's name
- Add LOG_LEVEL env var (info/debug) controlling console and file output
- INFO logs show user email, action, and IP for audit events; errors
  for HTTP requests
- DEBUG logs show every request with full body/query (passwords redacted),
  audit details, notification params, and webhook payloads
- Add persistent trek.log file logging with 10MB rotation (5 files)
  in /app/data/logs/
- Color-coded log levels in Docker console output
- Timestamps without timezone name (user sets TZ via Docker)
- Add Test Webhook and Save buttons to admin notification settings
- Move notification event toggles to admin panel
- Add daily trip reminder scheduler (9 AM, timezone-aware)
- Wire up booking create/update/delete and collab message notifications
- Add i18n keys for notification UI across all 13 languages

Made-with: Cursor
This commit is contained in:
Andrei Brebene
2026-03-31 15:01:33 +03:00
parent f7160e6dec
commit 9b2f083e4b
35 changed files with 1004 additions and 249 deletions
+4 -1
View File
@@ -7,6 +7,7 @@ import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
const router = express.Router();
@@ -147,6 +148,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title } });
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
res.status(201).json({ trip });
});
@@ -229,6 +231,7 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
if (!isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id);
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId } });
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
@@ -287,7 +290,7 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
// Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => {
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, invitee: target.username } }).catch(() => {});
});
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });