mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
feat: email notifications, webhook support, ICS export — closes #110
Email Notifications: - SMTP configuration in Admin > Settings (host, port, user, pass, from) - App URL setting for email CTA links - Webhook URL support (Discord, Slack, custom) - Test email button with SMTP validation - Beautiful HTML email template with TREK logo, slogan, red heart footer - All notification texts translated in 8 languages (en/de/fr/es/nl/ru/zh/ar) - Emails sent in each user's language preference Notification Events: - Trip invitation (member added) - Booking created (new reservation) - Vacay fusion invite - Photos shared (Immich) - Collab chat message - Packing list category assignment User Notification Preferences: - Per-user toggle for each event type in Settings - Addon-aware: Vacay/Collab/Photos toggles hidden when addon disabled - Webhook opt-in per user ICS Calendar Export: - Download button next to PDF in day plan header - Exports trip dates + all reservations with details - Compatible with Google Calendar, Apple Calendar, Outlook Technical: - Nodemailer for SMTP - notification_preferences DB table with per-event columns - GET/PUT /auth/app-settings for admin config persistence - POST /notifications/test-smtp for validation - Dynamic imports for non-blocking notification sends
This commit is contained in:
@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
|
||||
|
||||
// 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(() => {});
|
||||
});
|
||||
|
||||
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
|
||||
});
|
||||
|
||||
@@ -301,4 +307,69 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ICS calendar export
|
||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
|
||||
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
|
||||
const fmtDate = (d: string) => d.replace(/-/g, '');
|
||||
const fmtDateTime = (d: string) => d.replace(/[-:]/g, '').replace('T', 'T') + (d.includes('T') ? '00' : '');
|
||||
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
|
||||
|
||||
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
|
||||
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
|
||||
|
||||
// Trip as all-day event
|
||||
if (trip.start_date && trip.end_date) {
|
||||
const endNext = new Date(trip.end_date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
|
||||
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
// Reservations as events
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
const hasTime = r.reservation_time.includes('T');
|
||||
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
|
||||
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\n`;
|
||||
if (hasTime) {
|
||||
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
|
||||
if (r.reservation_end_time) ics += `DTEND:${fmtDateTime(r.reservation_end_time)}\r\n`;
|
||||
} else {
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
|
||||
}
|
||||
ics += `SUMMARY:${esc(r.title)}\r\n`;
|
||||
|
||||
let desc = r.type ? `Type: ${r.type}` : '';
|
||||
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
|
||||
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
|
||||
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
|
||||
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\\n${r.notes}`;
|
||||
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
|
||||
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
ics += 'END:VCALENDAR\r\n';
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
|
||||
res.send(ics);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user