diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx index 2b936892..429b5187 100644 --- a/client/src/components/Settings/NotificationsTab.tsx +++ b/client/src/components/Settings/NotificationsTab.tsx @@ -25,6 +25,7 @@ const EVENT_LABEL_KEYS: Record = { trip_invite: 'settings.notifyTripInvite', booking_change: 'settings.notifyBookingChange', trip_reminder: 'settings.notifyTripReminder', + todo_due: 'settings.notifyTodoDue', vacay_invite: 'settings.notifyVacayInvite', photos_shared: 'settings.notifyPhotosShared', collab_message: 'settings.notifyCollabMessage', diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 19f0a57a..4b46de46 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -204,6 +204,7 @@ const ar: Record = { 'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyBookingChange': 'تغييرات الحجز', 'settings.notifyTripReminder': 'تذكيرات الرحلات', + 'settings.notifyTodoDue': 'مهمة مستحقة', 'settings.notifyVacayInvite': 'دعوات دمج الإجازات', 'settings.notifyPhotosShared': 'صور مشتركة (Immich)', 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', @@ -1995,6 +1996,8 @@ const ar: Record = { 'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}', 'notif.trip_reminder.title': 'تذكير بالرحلة', 'notif.trip_reminder.text': 'رحلتك {trip} تقترب!', + 'notif.todo_due.title': 'مهمة مستحقة', + 'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}', 'notif.vacay_invite.title': 'دعوة دمج الإجازة', 'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة', 'notif.photos_shared.title': 'تمت مشاركة الصور', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 4219f9d6..846d3b62 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -199,6 +199,7 @@ const br: Record = { 'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyBookingChange': 'Alterações de reserva', 'settings.notifyTripReminder': 'Lembretes de viagem', + 'settings.notifyTodoDue': 'Tarefa com vencimento', 'settings.notifyVacayInvite': 'Convites de fusão Vacay', 'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)', 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', @@ -1935,6 +1936,8 @@ const br: Record = { 'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}', 'notif.trip_reminder.title': 'Lembrete de viagem', 'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!', + 'notif.todo_due.title': 'Tarefa com vencimento', + 'notif.todo_due.text': '{todo} em {trip} vence em {due}', 'notif.vacay_invite.title': 'Convite Vacay Fusion', 'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias', 'notif.photos_shared.title': 'Fotos compartilhadas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 24086d27..6bfbae08 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -200,6 +200,7 @@ const cs: Record = { 'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyBookingChange': 'Změny rezervací', 'settings.notifyTripReminder': 'Připomínky cest', + 'settings.notifyTodoDue': 'Úkol se blíží', 'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay', 'settings.notifyPhotosShared': 'Sdílené fotky (Immich)', 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)', @@ -1940,6 +1941,8 @@ const cs: Record = { 'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}', 'notif.trip_reminder.title': 'Připomínka výletu', 'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!', + 'notif.todo_due.title': 'Úkol se blíží', + 'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}', 'notif.vacay_invite.title': 'Pozvánka Vacay Fusion', 'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů', 'notif.photos_shared.title': 'Fotky sdíleny', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 6b79672d..1b75a3c2 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -204,6 +204,7 @@ const de: Record = { 'settings.notifyTripInvite': 'Trip-Einladungen', 'settings.notifyBookingChange': 'Buchungsänderungen', 'settings.notifyTripReminder': 'Trip-Erinnerungen', + 'settings.notifyTodoDue': 'Aufgabe bald fällig', 'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen', 'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)', 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', @@ -1945,6 +1946,8 @@ const de: Record = { 'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert', 'notif.trip_reminder.title': 'Reiseerinnerung', 'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!', + 'notif.todo_due.title': 'Aufgabe fällig', + 'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig', 'notif.vacay_invite.title': 'Vacay Fusion-Einladung', 'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen', 'notif.photos_shared.title': 'Fotos geteilt', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index be712440..05f07a7c 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -204,6 +204,7 @@ const en: Record = { 'settings.notifyTripInvite': 'Trip invitations', 'settings.notifyBookingChange': 'Booking changes', 'settings.notifyTripReminder': 'Trip reminders', + 'settings.notifyTodoDue': 'Todo due soon', 'settings.notifyVacayInvite': 'Vacay fusion invitations', 'settings.notifyPhotosShared': 'Shared photos (Immich)', 'settings.notifyCollabMessage': 'Chat messages (Collab)', @@ -1948,6 +1949,8 @@ const en: Record = { 'notif.booking_change.text': '{actor} updated a booking in {trip}', 'notif.trip_reminder.title': 'Trip Reminder', 'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!', + 'notif.todo_due.title': 'To-do due', + 'notif.todo_due.text': '{todo} in {trip} is due on {due}', 'notif.vacay_invite.title': 'Vacay Fusion Invite', 'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans', 'notif.photos_shared.title': 'Photos Shared', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 36904a6f..4369a26c 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -200,6 +200,7 @@ const es: Record = { 'settings.notifyTripInvite': 'Invitaciones de viaje', 'settings.notifyBookingChange': 'Cambios en reservas', 'settings.notifyTripReminder': 'Recordatorios de viaje', + 'settings.notifyTodoDue': 'Tarea próxima', 'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay', 'settings.notifyPhotosShared': 'Fotos compartidas (Immich)', 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)', @@ -1945,6 +1946,8 @@ const es: Record = { 'notif.booking_change.text': '{actor} actualizó una reserva en {trip}', 'notif.trip_reminder.title': 'Recordatorio de viaje', 'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!', + 'notif.todo_due.title': 'Tarea pendiente', + 'notif.todo_due.text': '{todo} en {trip} vence el {due}', 'notif.vacay_invite.title': 'Invitación Vacay Fusion', 'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones', 'notif.photos_shared.title': 'Fotos compartidas', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index fda73908..90c6f7e2 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -199,6 +199,7 @@ const fr: Record = { 'settings.notifyTripInvite': 'Invitations de voyage', 'settings.notifyBookingChange': 'Modifications de réservation', 'settings.notifyTripReminder': 'Rappels de voyage', + 'settings.notifyTodoDue': 'Tâche à échéance', 'settings.notifyVacayInvite': 'Invitations de fusion Vacay', 'settings.notifyPhotosShared': 'Photos partagées (Immich)', 'settings.notifyCollabMessage': 'Messages de chat (Collab)', @@ -1939,6 +1940,8 @@ const fr: Record = { 'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}', 'notif.trip_reminder.title': 'Rappel de voyage', 'notif.trip_reminder.text': 'Votre voyage {trip} approche !', + 'notif.todo_due.title': 'Tâche à échéance', + 'notif.todo_due.text': '{todo} dans {trip} est due le {due}', 'notif.vacay_invite.title': 'Invitation Vacay Fusion', 'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances', 'notif.photos_shared.title': 'Photos partagées', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 21e9ce52..e4a70e8f 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -199,6 +199,7 @@ const hu: Record = { 'settings.notifyTripInvite': 'Utazási meghívók', 'settings.notifyBookingChange': 'Foglalási változások', 'settings.notifyTripReminder': 'Utazási emlékeztetők', + 'settings.notifyTodoDue': 'Teendő esedékes', 'settings.notifyVacayInvite': 'Vacay összevonási meghívók', 'settings.notifyPhotosShared': 'Megosztott fotók (Immich)', 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)', @@ -1937,6 +1938,8 @@ const hu: Record = { 'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban', 'notif.trip_reminder.title': 'Utazás emlékeztető', 'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!', + 'notif.todo_due.title': 'Teendő esedékes', + 'notif.todo_due.text': '{todo} ({trip}) határideje: {due}', 'notif.vacay_invite.title': 'Vacay Fusion meghívó', 'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához', 'notif.photos_shared.title': 'Fotók megosztva', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index ab52258d..dea1b7fe 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -202,6 +202,7 @@ const id: Record = { 'settings.notifyTripInvite': 'Undangan perjalanan', 'settings.notifyBookingChange': 'Perubahan pemesanan', 'settings.notifyTripReminder': 'Pengingat perjalanan', + 'settings.notifyTodoDue': 'Tugas jatuh tempo', 'settings.notifyVacayInvite': 'Undangan Vacay fusion', 'settings.notifyPhotosShared': 'Foto dibagikan (Immich)', 'settings.notifyCollabMessage': 'Pesan chat (Collab)', @@ -1946,6 +1947,8 @@ const id: Record = { 'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}', 'notif.trip_reminder.title': 'Pengingat Perjalanan', 'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!', + 'notif.todo_due.title': 'Tugas jatuh tempo', + 'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}', 'notif.vacay_invite.title': 'Undangan Vacay Fusion', 'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan', 'notif.photos_shared.title': 'Foto Dibagikan', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8c5c95c4..5b52b143 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -199,6 +199,7 @@ const it: Record = { 'settings.notifyTripInvite': 'Inviti di viaggio', 'settings.notifyBookingChange': 'Modifiche alle prenotazioni', 'settings.notifyTripReminder': 'Promemoria di viaggio', + 'settings.notifyTodoDue': 'Attività in scadenza', 'settings.notifyVacayInvite': 'Inviti fusione Vacay', 'settings.notifyPhotosShared': 'Foto condivise (Immich)', 'settings.notifyCollabMessage': 'Messaggi chat (Collab)', @@ -1940,6 +1941,8 @@ const it: Record = { 'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}', 'notif.trip_reminder.title': 'Promemoria viaggio', 'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!', + 'notif.todo_due.title': 'Attività in scadenza', + 'notif.todo_due.text': '{todo} in {trip} scade il {due}', 'notif.vacay_invite.title': 'Invito Vacay Fusion', 'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza', 'notif.photos_shared.title': 'Foto condivise', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 485d801b..ee7819ec 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -199,6 +199,7 @@ const nl: Record = { 'settings.notifyTripInvite': 'Reisuitnodigingen', 'settings.notifyBookingChange': 'Boekingswijzigingen', 'settings.notifyTripReminder': 'Reisherinneringen', + 'settings.notifyTodoDue': 'Taak verloopt', 'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen', 'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)', 'settings.notifyCollabMessage': 'Chatberichten (Collab)', @@ -1939,6 +1940,8 @@ const nl: Record = { 'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}', 'notif.trip_reminder.title': 'Reisherinnering', 'notif.trip_reminder.text': 'Je reis {trip} komt eraan!', + 'notif.todo_due.title': 'Taak verloopt', + 'notif.todo_due.text': '{todo} in {trip} verloopt op {due}', 'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging', 'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren', 'notif.photos_shared.title': 'Foto\'s gedeeld', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 585f5e61..db234542 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -182,6 +182,7 @@ const pl: Record = { 'settings.notifyTripInvite': 'Zaproszenia do podróży', 'settings.notifyBookingChange': 'Zmiany w rezerwacjach', 'settings.notifyTripReminder': 'Przypomnienia o podróżach', + 'settings.notifyTodoDue': 'Zadanie z terminem', 'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy', 'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)', 'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)', @@ -1929,6 +1930,8 @@ const pl: Record = { 'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}', 'notif.trip_reminder.title': 'Przypomnienie o podróży', 'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!', + 'notif.todo_due.title': 'Zadanie z terminem', + 'notif.todo_due.text': '{todo} w {trip} — termin {due}', 'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion', 'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych', 'notif.photos_shared.title': 'Zdjęcia udostępnione', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index a0042c8b..db5b8b44 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -199,6 +199,7 @@ const ru: Record = { 'settings.notifyTripInvite': 'Приглашения в поездку', 'settings.notifyBookingChange': 'Изменения бронирований', 'settings.notifyTripReminder': 'Напоминания о поездке', + 'settings.notifyTodoDue': 'Задача к сроку', 'settings.notifyVacayInvite': 'Приглашения слияния Vacay', 'settings.notifyPhotosShared': 'Общие фото (Immich)', 'settings.notifyCollabMessage': 'Сообщения чата (Collab)', @@ -1936,6 +1937,8 @@ const ru: Record = { 'notif.booking_change.text': '{actor} обновил бронирование в {trip}', 'notif.trip_reminder.title': 'Напоминание о поездке', 'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!', + 'notif.todo_due.title': 'Задача к сроку', + 'notif.todo_due.text': '{todo} в {trip} — срок {due}', 'notif.vacay_invite.title': 'Приглашение Vacay Fusion', 'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска', 'notif.photos_shared.title': 'Фото опубликованы', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index f0a9fd93..15109322 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -199,6 +199,7 @@ const zh: Record = { 'settings.notifyTripInvite': '旅行邀请', 'settings.notifyBookingChange': '预订变更', 'settings.notifyTripReminder': '旅行提醒', + 'settings.notifyTodoDue': '待办事项即将到期', 'settings.notifyVacayInvite': 'Vacay 融合邀请', 'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyCollabMessage': '聊天消息 (Collab)', @@ -1936,6 +1937,8 @@ const zh: Record = { 'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订', 'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!', + 'notif.todo_due.title': '待办事项即将到期', + 'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期', 'notif.vacay_invite.title': 'Vacay 融合邀请', 'notif.vacay_invite.text': '{actor} 邀请您合并假期计划', 'notif.photos_shared.title': '照片已分享', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 5e2fa776..f9af554e 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -199,6 +199,7 @@ const zhTw: Record = { 'settings.notifyTripInvite': '旅行邀請', 'settings.notifyBookingChange': '預訂變更', 'settings.notifyTripReminder': '旅行提醒', + 'settings.notifyTodoDue': '待辦事項即將到期', 'settings.notifyVacayInvite': 'Vacay 融合邀請', 'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyCollabMessage': '聊天訊息 (Collab)', @@ -2195,6 +2196,8 @@ const zhTw: Record = { 'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂', 'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!', + 'notif.todo_due.title': '待辦事項即將到期', + 'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期', 'notif.vacay_invite.title': 'Vacay Fusion 邀請', 'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫', 'notif.photos_shared.title': '照片已分享', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index af0f4c35..dab1e6cb 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1792,6 +1792,13 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); `); }, + // Migration: todo due-date reminders — track when we last sent a + // reminder for each todo so we don't spam the same notification + // every day the scheduler runs. + () => { + try { db.exec('ALTER TABLE todo_items ADD COLUMN reminded_at DATETIME'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index c84a0839..3f1bf8dd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -46,6 +46,7 @@ const server = app.listen(PORT, () => { } scheduler.start(); scheduler.startTripReminders(); + scheduler.startTodoReminders(); scheduler.startVersionCheck(); scheduler.startDemoReset(); scheduler.startIdempotencyCleanup(); diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index b11dc11c..87979ecc 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -207,6 +207,81 @@ function startTripReminders(): void { }, { timezone: tz }); } +// Todo due-date reminders: daily check at 9 AM for unchecked todos +// whose due_date falls within the next TODO_REMINDER_LEAD_DAYS days. +// Each todo gets reminded at most once per 24 h (tracked via +// todo_items.reminded_at) so the scheduler doesn't spam the user every +// morning leading up to the deadline. +const TODO_REMINDER_LEAD_DAYS = 3; +let todoReminderTask: ScheduledTask | null = null; + +function startTodoReminders(): void { + if (todoReminderTask) { todoReminderTask.stop(); todoReminderTask = null; } + + const { db } = require('./db/database'); + const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; + const enabled = getSetting('notify_todo_due') !== 'false'; + if (!enabled) { + const { logInfo: li } = require('./services/auditLog'); + li('Todo due reminders: disabled in settings'); + return; + } + const { logInfo: liSetup } = require('./services/auditLog'); + liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`); + + const tz = process.env.TZ || 'UTC'; + todoReminderTask = cron.schedule('0 9 * * *', async () => { + try { + const { send } = require('./services/notificationService'); + + // Select unchecked todos with a due date inside the lead window + // that haven't been reminded in the last 24 hours. `due_date` is + // stored as a YYYY-MM-DD text; SQLite date() handles it directly. + const todos = db.prepare(` + SELECT ti.id, ti.trip_id, ti.name, ti.due_date, ti.assigned_user_id, + t.title AS trip_title, t.user_id AS trip_owner_id + FROM todo_items ti + JOIN trips t ON t.id = ti.trip_id + WHERE ti.checked = 0 + AND ti.due_date IS NOT NULL + AND ti.due_date <> '' + AND date(ti.due_date) <= date('now', '+' || ? || ' days') + AND date(ti.due_date) >= date('now') + AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 hours')) + `).all(TODO_REMINDER_LEAD_DAYS) as { + id: number; trip_id: number; name: string; due_date: string; + assigned_user_id: number | null; trip_title: string; trip_owner_id: number; + }[]; + + for (const todo of todos) { + const targetScope: 'user' | 'trip' = todo.assigned_user_id ? 'user' : 'trip'; + const targetId = todo.assigned_user_id ?? todo.trip_id; + await send({ + event: 'todo_due', + actorId: null, + scope: targetScope, + targetId, + params: { + todo: todo.name, + trip: todo.trip_title, + tripId: String(todo.trip_id), + due: todo.due_date, + }, + }).catch(() => {}); + db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id); + } + + const { logInfo: li } = require('./services/auditLog'); + if (todos.length > 0) { + li(`Todo reminders sent for ${todos.length} item(s)`); + } + } catch (err: unknown) { + const { logError: le } = require('./services/auditLog'); + le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`); + } + }, { timezone: tz }); +} + // Version check: daily at 9 AM — notify admins if a new TREK release is available let versionCheckTask: ScheduledTask | null = null; @@ -280,4 +355,4 @@ function stop(): void { if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts index 9175c418..454ab463 100644 --- a/server/src/services/notificationPreferencesService.ts +++ b/server/src/services/notificationPreferencesService.ts @@ -9,6 +9,7 @@ export type NotifEventType = | 'trip_invite' | 'booking_change' | 'trip_reminder' + | 'todo_due' | 'vacay_invite' | 'photos_shared' | 'collab_message' @@ -29,6 +30,7 @@ const IMPLEMENTED_COMBOS: Record = { trip_invite: ['inapp', 'email', 'webhook', 'ntfy'], booking_change: ['inapp', 'email', 'webhook', 'ntfy'], trip_reminder: ['inapp', 'email', 'webhook', 'ntfy'], + todo_due: ['inapp', 'email', 'webhook', 'ntfy'], vacay_invite: ['inapp', 'email', 'webhook', 'ntfy'], photos_shared: ['inapp', 'email', 'webhook', 'ntfy'], collab_message: ['inapp', 'email', 'webhook', 'ntfy'], diff --git a/server/src/services/notificationService.ts b/server/src/services/notificationService.ts index 8a8a94cf..f14e48d8 100644 --- a/server/src/services/notificationService.ts +++ b/server/src/services/notificationService.ts @@ -82,6 +82,13 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view_trip', navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null), }, + todo_due: { + inAppType: 'navigate', + titleKey: 'notif.todo_due.title', + textKey: 'notif.todo_due.text', + navigateTextKey: 'notif.action.view_trip', + navigateTarget: p => (p.tripId ? `/trips/${p.tripId}` : null), + }, vacay_invite: { inAppType: 'navigate', titleKey: 'notif.vacay_invite.title', diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index 1f28765d..0458c27e 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -100,6 +100,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Trip invite: "${p.trip}"`, body: `${p.actor} invited ${p.invitee || 'a member'} to the trip "${p.trip}".` }), booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }), trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }), + todo_due: p => ({ title: `To-do due: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" is due on ${p.due}.` }), vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }), photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }), collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -111,6 +112,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }), booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }), trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }), + todo_due: p => ({ title: `Aufgabe fällig: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" ist am ${p.due} fällig.` }), vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }), photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }), collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -122,6 +124,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }), booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }), trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }), + todo_due: p => ({ title: `Tâche à échéance : ${p.todo}`, body: `"${p.todo}" dans "${p.trip}" est due le ${p.due}.` }), vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }), photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }), collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }), @@ -133,6 +136,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }), booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }), trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }), + todo_due: p => ({ title: `Tarea pendiente: ${p.todo}`, body: `"${p.todo}" en "${p.trip}" vence el ${p.due}.` }), vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }), photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }), collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -144,6 +148,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }), booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }), trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }), + todo_due: p => ({ title: `Taak verloopt: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" verloopt op ${p.due}.` }), vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }), photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }), collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -155,6 +160,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }), booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }), trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }), + todo_due: p => ({ title: `Задача к сроку: ${p.todo}`, body: `"${p.todo}" в поездке "${p.trip}" — срок ${p.due}.` }), vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }), photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }), collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -166,6 +172,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }), booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"(${p.type})。` }), trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }), + todo_due: p => ({ title: `待办事项即将到期:${p.todo}`, body: `"${p.trip}" 中的"${p.todo}"将于 ${p.due} 到期。` }), vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }), photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }), collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}:${p.preview}` }), @@ -177,6 +184,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `邀請加入「${p.trip}」`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }), booking_change: p => ({ title: `新預訂:${p.booking}`, body: `${p.actor} 在「${p.trip}」中新增了預訂「${p.booking}」(${p.type})。` }), trip_reminder: p => ({ title: `行程提醒:${p.trip}`, body: `您的行程「${p.trip}」即將開始!` }), + todo_due: p => ({ title: `待辦事項即將到期:${p.todo}`, body: `「${p.trip}」中的「${p.todo}」將於 ${p.due} 到期。` }), vacay_invite: p => ({ title: 'Vacay 融合邀請', body: `${p.actor} 邀請您合併假期計畫。開啟 TREK 以接受或拒絕。` }), photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }), collab_message: p => ({ title: `「${p.trip}」中的新訊息`, body: `${p.actor}:${p.preview}` }), @@ -188,6 +196,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }), booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }), trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }), + todo_due: p => ({ title: `مهمة مستحقة: ${p.todo}`, body: `"${p.todo}" في "${p.trip}" مستحقة في ${p.due}.` }), vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }), photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }), collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -199,6 +208,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }), booking_change: p => ({ title: `Nova reserva: ${p.booking}`, body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".` }), trip_reminder: p => ({ title: `Lembrete: ${p.trip}`, body: `Sua viagem "${p.trip}" está chegando!` }), + todo_due: p => ({ title: `Tarefa com vencimento: ${p.todo}`, body: `"${p.todo}" em "${p.trip}" vence em ${p.due}.` }), vacay_invite: p => ({ title: 'Convite Vacay Fusion', body: `${p.actor} convidou você para fundir planos de férias. Abra o TREK para aceitar ou recusar.` }), photos_shared: p => ({ title: `${p.count} fotos compartilhadas`, body: `${p.actor} compartilhou ${p.count} foto(s) em "${p.trip}".` }), collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -210,6 +220,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }), booking_change: p => ({ title: `Nová rezervace: ${p.booking}`, body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".` }), trip_reminder: p => ({ title: `Připomínka výletu: ${p.trip}`, body: `Váš výlet "${p.trip}" se blíží!` }), + todo_due: p => ({ title: `Úkol se blíží: ${p.todo}`, body: `"${p.todo}" ve výletě "${p.trip}" má termín ${p.due}.` }), vacay_invite: p => ({ title: 'Pozvánka Vacay Fusion', body: `${p.actor} vás pozval ke spojení dovolenkových plánů. Otevřete TREK pro přijetí nebo odmítnutí.` }), photos_shared: p => ({ title: `${p.count} sdílených fotek`, body: `${p.actor} sdílel ${p.count} foto v "${p.trip}".` }), collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -221,6 +232,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }), booking_change: p => ({ title: `Új foglalás: ${p.booking}`, body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.` }), trip_reminder: p => ({ title: `Utazás emlékeztető: ${p.trip}`, body: `A(z) "${p.trip}" utazás hamarosan kezdődik!` }), + todo_due: p => ({ title: `Teendő esedékes: ${p.todo}`, body: `"${p.todo}" (${p.trip}) határideje: ${p.due}.` }), vacay_invite: p => ({ title: 'Vacay Fusion meghívó', body: `${p.actor} meghívott a nyaralási tervek összevonásához. Nyissa meg a TREK-et az elfogadáshoz vagy elutasításhoz.` }), photos_shared: p => ({ title: `${p.count} fotó megosztva`, body: `${p.actor} ${p.count} fotót osztott meg a(z) "${p.trip}" utazásban.` }), collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }), @@ -232,6 +244,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }), booking_change: p => ({ title: `Nuova prenotazione: ${p.booking}`, body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".` }), trip_reminder: p => ({ title: `Promemoria viaggio: ${p.trip}`, body: `Il tuo viaggio "${p.trip}" si avvicina!` }), + todo_due: p => ({ title: `Attività in scadenza: ${p.todo}`, body: `"${p.todo}" in "${p.trip}" scade il ${p.due}.` }), vacay_invite: p => ({ title: 'Invito Vacay Fusion', body: `${p.actor} ti ha invitato a fondere i piani vacanza. Apri TREK per accettare o rifiutare.` }), photos_shared: p => ({ title: `${p.count} foto condivise`, body: `${p.actor} ha condiviso ${p.count} foto in "${p.trip}".` }), collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -243,6 +256,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }), booking_change: p => ({ title: `Nowa rezerwacja: ${p.booking}`, body: `${p.actor} dodał rezerwację "${p.booking}" (${p.type}) do "${p.trip}".` }), trip_reminder: p => ({ title: `Przypomnienie o podróży: ${p.trip}`, body: `Twoja podróż "${p.trip}" zbliża się!` }), + todo_due: p => ({ title: `Zadanie z terminem: ${p.todo}`, body: `"${p.todo}" w "${p.trip}" — termin ${p.due}.` }), vacay_invite: p => ({ title: 'Zaproszenie Vacay Fusion', body: `${p.actor} zaprosił Cię do połączenia planów urlopowych. Otwórz TREK, aby zaakceptować lub odrzucić.` }), photos_shared: p => ({ title: `${p.count} zdjęć udostępnionych`, body: `${p.actor} udostępnił ${p.count} zdjęcie/zdjęcia w "${p.trip}".` }), collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }), @@ -254,6 +268,7 @@ const EVENT_TEXTS: Record> = { trip_invite: p => ({ title: `Undangan perjalanan: "${p.trip}"`, body: `${p.actor} mengundang ${p.invitee || 'seorang anggota'} ke perjalanan "${p.trip}".` }), booking_change: p => ({ title: `Pemesanan baru: ${p.booking}`, body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".` }), trip_reminder: p => ({ title: `Pengingat perjalanan: ${p.trip}`, body: `Perjalanan Anda "${p.trip}" akan segera tiba!` }), + todo_due: p => ({ title: `Tugas jatuh tempo: ${p.todo}`, body: `"${p.todo}" di "${p.trip}" jatuh tempo pada ${p.due}.` }), vacay_invite: p => ({ title: 'Undangan Penggabungan Vacay', body: `${p.actor} mengundang Anda untuk menggabungkan rencana liburan. Buka TREK untuk menerima atau menolak.` }), photos_shared: p => ({ title: `${p.count} foto dibagikan`, body: `${p.actor} membagikan ${p.count} foto di "${p.trip}".` }), collab_message: p => ({ title: `Pesan baru di "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),