From d484f62cbdfe671fa5b2d9b1201608eea9ac355a Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Fri, 20 Mar 2026 18:26:48 +0800 Subject: [PATCH] Implement push notification reminder system with 9 notification types Adds comprehensive push notification reminders using pg_cron + pg_net: - Shift check-in reminder (15 min before, with countdown banner) - Shift check-out reminder (hourly, persistent until checkout) - Overtime idle reminder (15 min without task) - Overtime checkout reminder (30 min after task completion) - IT service request event reminder (1 hour before event) - IT service request evidence reminder (daily) - Paused task reminder (daily) - Backlog reminder (15 min before shift end) - Pass slip expiry reminder (15 min before 1-hour limit, with countdown banner) Database: Extended scheduled_notifications table to support polymorphic references (schedule_id, task_id, it_service_request_id, pass_slip_id) with unique constraint and epoch column for deduplication. Implemented 8 enqueue functions + master dispatcher. Uses pg_cron every minute to enqueue and pg_net to trigger process_scheduled_notifications edge function, eliminating need for external cron job. Credentials stored in vault with GUC fallback for flexibility. Flutter: Added ShiftCountdownBanner and PassSlipCountdownBanner widgets that display persistent countdown timers for active shifts and pass slips. Both auto-dismiss when user completes the action. FCM handler triggers shift countdown on start_15 messages. navigate_to field in data payload enables flexible routing to any screen. Edge function: Updated process_scheduled_notifications to handle all 9 types with appropriate titles, bodies, and routing. Includes pass_slip_id in idempotency tracking. Co-Authored-By: Claude Haiku 4.5 --- lib/main.dart | 59 +- .../notification_navigation_provider.dart | 2 +- lib/providers/shift_countdown_provider.dart | 8 + lib/services/notification_bridge.dart | 49 +- lib/widgets/app_shell.dart | 6 +- lib/widgets/pass_slip_countdown_banner.dart | 170 ++++++ lib/widgets/shift_countdown_banner.dart | 164 ++++++ .../process_scheduled_notifications/index.ts | 86 ++- ...0260321_extend_scheduled_notifications.sql | 516 ++++++++++++++++++ 9 files changed, 1023 insertions(+), 37 deletions(-) create mode 100644 lib/providers/shift_countdown_provider.dart create mode 100644 lib/widgets/pass_slip_countdown_banner.dart create mode 100644 lib/widgets/shift_countdown_banner.dart create mode 100644 supabase/migrations/20260321_extend_scheduled_notifications.sql diff --git a/lib/main.dart b/lib/main.dart index 790ffc40..742fac77 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'app.dart'; import 'theme/app_theme.dart'; import 'providers/notifications_provider.dart'; import 'providers/notification_navigation_provider.dart'; +import 'providers/shift_countdown_provider.dart'; import 'utils/app_time.dart'; import 'utils/notification_permission.dart'; import 'utils/location_permission.dart'; @@ -197,7 +198,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { // Create a unique ID for the notification display final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // Build payload string with ticket/task information for navigation + // Build payload string with ticket/task/route information for navigation final payloadParts = []; final taskId = (message.data['task_id'] ?? @@ -209,7 +210,11 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { message.data['ticketId'] ?? message.data['ticket']?.toString() ?? ''; + final navigateTo = message.data['navigate_to']?.toString() ?? ''; + if (navigateTo.isNotEmpty) { + payloadParts.add('route:$navigateTo'); + } if (taskId != null && taskId.isNotEmpty) { payloadParts.add('task:$taskId'); } @@ -424,6 +429,25 @@ Future main() async { } catch (_) {} final formatted = _formatNotificationFromData(dataForFormatting); + + // Activate the shift countdown banner for start_15 notifications + final msgType = message.data['type']?.toString() ?? ''; + if (msgType == 'start_15') { + // The shift starts 15 minutes from when the notification is sent. + // Use scheduled_for from data if available, otherwise estimate. + final scheduledFor = message.data['scheduled_for']?.toString(); + DateTime shiftStart; + if (scheduledFor != null && scheduledFor.isNotEmpty) { + shiftStart = DateTime.tryParse(scheduledFor) ?? + DateTime.now().add(const Duration(minutes: 15)); + } else { + shiftStart = DateTime.now().add(const Duration(minutes: 15)); + } + _globalProviderContainer + .read(shiftCountdownProvider.notifier) + .state = shiftStart; + } + String? stableId = (message.data['notification_id'] as String?) ?? message.messageId; if (stableId == null) { @@ -483,7 +507,7 @@ Future main() async { recent[stableId] = now; await prefs.setString('recent_notifs', jsonEncode(recent)); - // Build payload string with ticket/task information for navigation + // Build payload string with route/ticket/task information for navigation final payloadParts = []; final taskId = (message.data['task_id'] ?? @@ -495,7 +519,12 @@ Future main() async { message.data['ticketId'] ?? message.data['ticket']?.toString() ?? ''; + final navigateTo = + message.data['navigate_to']?.toString() ?? ''; + if (navigateTo.isNotEmpty) { + payloadParts.add('route:$navigateTo'); + } if (taskId != null && taskId.isNotEmpty) { payloadParts.add('task:$taskId'); } @@ -554,17 +583,20 @@ Future main() async { // initialize the local notifications plugin so we can post alerts later await NotificationService.initialize( onDidReceiveNotificationResponse: (response) { - // handle user tapping a notification; the payload format is "ticket:ID", - // "task:ID", "tasknum:NUMBER", or a combination separated by "|" + // handle user tapping a notification; the payload format is "route:PATH", + // "ticket:ID", "task:ID", or a combination separated by "|" final payload = response.payload; if (payload != null && payload.isNotEmpty) { - // Parse the payload to extract ticket and task information + // Parse the payload to extract navigation information final parts = payload.split('|'); String? ticketId; String? taskId; + String? route; for (final part in parts) { - if (part.startsWith('ticket:')) { + if (part.startsWith('route:')) { + route = part.substring('route:'.length); + } else if (part.startsWith('ticket:')) { ticketId = part.substring('ticket:'.length); } else if (part.startsWith('task:')) { taskId = part.substring('task:'.length); @@ -572,14 +604,22 @@ Future main() async { } // Update the pending navigation provider. - // Prefer task over ticket — assignment notifications include both - // IDs but the primary entity is the task. - if (taskId != null && taskId.isNotEmpty) { + // Prefer route (from scheduled reminders), then task, then ticket. + if (route != null && route.isNotEmpty) { + _globalProviderContainer + .read(pendingNotificationNavigationProvider.notifier) + .state = ( + type: 'route', + id: '', + route: route, + ); + } else if (taskId != null && taskId.isNotEmpty) { _globalProviderContainer .read(pendingNotificationNavigationProvider.notifier) .state = ( type: 'task', id: taskId, + route: null, ); } else if (ticketId != null && ticketId.isNotEmpty) { _globalProviderContainer @@ -587,6 +627,7 @@ Future main() async { .state = ( type: 'ticket', id: ticketId, + route: null, ); } } diff --git a/lib/providers/notification_navigation_provider.dart b/lib/providers/notification_navigation_provider.dart index 7408f959..517cfd6c 100644 --- a/lib/providers/notification_navigation_provider.dart +++ b/lib/providers/notification_navigation_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -typedef PendingNavigation = ({String type, String id})?; +typedef PendingNavigation = ({String type, String id, String? route})?; final pendingNotificationNavigationProvider = StateProvider( (ref) => null, diff --git a/lib/providers/shift_countdown_provider.dart b/lib/providers/shift_countdown_provider.dart new file mode 100644 index 00000000..4d07f632 --- /dev/null +++ b/lib/providers/shift_countdown_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Holds the target shift start time when a `start_15` notification is received. +/// +/// Set to the shift start [DateTime] to activate the countdown banner. +/// Set to `null` to dismiss (e.g., when the user checks in or the countdown +/// reaches zero). +final shiftCountdownProvider = StateProvider((ref) => null); diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index 2679c34c..3ca655ad 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -51,13 +51,25 @@ class _NotificationBridgeState extends ConsumerState } } - /// Navigate to the task or ticket detail screen using GoRouter. - void _goToDetail({String? taskId, String? ticketId}) { + /// Navigate to the appropriate screen using GoRouter. + /// + /// Supports generic [route] for new notification types, plus legacy + /// [taskId], [ticketId], and [itServiceRequestId] fallbacks. + void _goToDetail({ + String? taskId, + String? ticketId, + String? itServiceRequestId, + String? route, + }) { final router = ref.read(appRouterProvider); - if (taskId != null && taskId.isNotEmpty) { + if (route != null && route.isNotEmpty) { + router.go(route); + } else if (taskId != null && taskId.isNotEmpty) { router.go('/tasks/$taskId'); } else if (ticketId != null && ticketId.isNotEmpty) { router.go('/tickets/$ticketId'); + } else if (itServiceRequestId != null && itServiceRequestId.isNotEmpty) { + router.go('/it-service-requests/$itServiceRequestId'); } } @@ -96,20 +108,34 @@ class _NotificationBridgeState extends ConsumerState }); } - /// Extract task/ticket IDs from the FCM data payload and navigate. + /// Extract navigation target from FCM data payload and navigate. /// - /// The FCM data map contains keys such as `task_id` / `taskId` / - /// `ticket_id` / `ticketId` — these are the same keys used in the - /// background handler ([_firebaseMessagingBackgroundHandler]). + /// Prefers the `navigate_to` field (used by scheduled notification + /// reminders) and falls back to legacy `task_id` / `ticket_id` keys. void _handleMessageTap(RemoteMessage message) { final data = message.data; - final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task']) - ?.toString(); + // New: use navigate_to route if present (scheduled reminder notifications) + final String? navigateTo = data['navigate_to']?.toString(); + if (navigateTo != null && navigateTo.isNotEmpty) { + _goToDetail(route: navigateTo); + return; + } + + // Legacy fallback for task_id / ticket_id + final String? taskId = + (data['task_id'] ?? data['taskId'] ?? data['task'])?.toString(); final String? ticketId = (data['ticket_id'] ?? data['ticketId'] ?? data['ticket'])?.toString(); + final String? isrId = + (data['it_service_request_id'] ?? data['itServiceRequestId']) + ?.toString(); - _goToDetail(taskId: taskId, ticketId: ticketId); + _goToDetail( + taskId: taskId, + ticketId: ticketId, + itServiceRequestId: isrId, + ); } @override @@ -138,8 +164,11 @@ class _NotificationBridgeState extends ConsumerState ) { if (next != null) { _goToDetail( + route: next.route, taskId: next.type == 'task' ? next.id : null, ticketId: next.type == 'ticket' ? next.id : null, + itServiceRequestId: + next.type == 'it_service_request' ? next.id : null, ); // Clear the pending navigation after handling ref.read(pendingNotificationNavigationProvider.notifier).state = null; diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 6b4a9aa0..8fb075f5 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -10,6 +10,8 @@ import '../providers/notifications_provider.dart'; import '../providers/profile_provider.dart'; import 'app_breakpoints.dart'; import 'profile_avatar.dart'; +import 'pass_slip_countdown_banner.dart'; +import 'shift_countdown_banner.dart'; final GlobalKey notificationBellKey = GlobalKey(); @@ -328,7 +330,9 @@ class _ShellBackground extends StatelessWidget { Widget build(BuildContext context) { return ColoredBox( color: Theme.of(context).scaffoldBackgroundColor, - child: child, + child: ShiftCountdownBanner( + child: PassSlipCountdownBanner(child: child), + ), ); } } diff --git a/lib/widgets/pass_slip_countdown_banner.dart b/lib/widgets/pass_slip_countdown_banner.dart new file mode 100644 index 00000000..b836fc31 --- /dev/null +++ b/lib/widgets/pass_slip_countdown_banner.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/pass_slip_provider.dart'; + +/// A persistent banner that shows a countdown to the pass slip 1-hour expiry. +/// +/// Watches [activePassSlipProvider] for the current user's active pass slip. +/// When active, calculates remaining time until `slipStart + 1 hour`. +/// Auto-dismisses when the pass slip is completed. +/// Shows "EXCEEDED" when time has passed the 1-hour mark. +class PassSlipCountdownBanner extends ConsumerStatefulWidget { + const PassSlipCountdownBanner({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _PassSlipCountdownBannerState(); +} + +class _PassSlipCountdownBannerState + extends ConsumerState { + Timer? _timer; + Duration _remaining = Duration.zero; + bool _exceeded = false; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startTimer(DateTime expiresAt) { + _timer?.cancel(); + _updateRemaining(expiresAt); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + _updateRemaining(expiresAt); + }); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + if (mounted) { + setState(() { + _remaining = Duration.zero; + _exceeded = false; + }); + } + } + + void _updateRemaining(DateTime expiresAt) { + final now = DateTime.now(); + final diff = expiresAt.difference(now); + if (mounted) { + setState(() { + if (diff.isNegative || diff == Duration.zero) { + _remaining = Duration.zero; + _exceeded = true; + } else { + _remaining = diff; + _exceeded = false; + } + }); + } + } + + String _formatDuration(Duration d) { + final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (d.inHours > 0) { + return '${d.inHours}:$minutes:$seconds'; + } + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) { + final activeSlip = ref.watch(activePassSlipProvider); + + // Start/stop timer based on active pass slip state + if (activeSlip != null && activeSlip.slipStart != null) { + final expiresAt = + activeSlip.slipStart!.add(const Duration(hours: 1)); + if (_timer == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startTimer(expiresAt); + }); + } + } else if (_timer != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _stopTimer(); + }); + } + + final showBanner = + activeSlip != null && activeSlip.slipStart != null; + + if (!showBanner) { + return widget.child; + } + + final isUrgent = !_exceeded && _remaining.inMinutes < 5; + final bgColor = _exceeded || isUrgent + ? Theme.of(context).colorScheme.errorContainer + : Theme.of(context).colorScheme.tertiaryContainer; + final fgColor = _exceeded || isUrgent + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).colorScheme.onTertiaryContainer; + + final String message; + final IconData icon; + if (_exceeded) { + message = 'Pass slip time EXCEEDED — Please return and complete it'; + icon = Icons.warning_amber_rounded; + } else { + message = 'Pass slip expires in ${_formatDuration(_remaining)}'; + icon = Icons.directions_walk_rounded; + } + + return Column( + children: [ + Material( + child: InkWell( + onTap: () => context.go('/attendance'), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration(color: bgColor), + child: Row( + children: [ + Icon(icon, size: 20, color: fgColor), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: fgColor, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + 'Tap to complete', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: fgColor.withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right, + size: 18, + color: fgColor.withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } +} diff --git a/lib/widgets/shift_countdown_banner.dart b/lib/widgets/shift_countdown_banner.dart new file mode 100644 index 00000000..b5696acc --- /dev/null +++ b/lib/widgets/shift_countdown_banner.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../providers/attendance_provider.dart'; +import '../providers/shift_countdown_provider.dart'; + +/// A persistent banner that shows a countdown to the shift start time. +/// +/// Activated when [shiftCountdownProvider] is set to a non-null [DateTime]. +/// Auto-dismisses when the countdown reaches zero or the provider is cleared +/// (e.g., when the user checks in). +class ShiftCountdownBanner extends ConsumerStatefulWidget { + const ShiftCountdownBanner({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _ShiftCountdownBannerState(); +} + +class _ShiftCountdownBannerState extends ConsumerState { + Timer? _timer; + Duration _remaining = Duration.zero; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startTimer(DateTime target) { + _timer?.cancel(); + _updateRemaining(target); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + _updateRemaining(target); + }); + } + + void _updateRemaining(DateTime target) { + final now = DateTime.now(); + final diff = target.difference(now); + if (diff.isNegative || diff == Duration.zero) { + _timer?.cancel(); + // Clear the provider when countdown finishes + ref.read(shiftCountdownProvider.notifier).state = null; + if (mounted) setState(() => _remaining = Duration.zero); + return; + } + if (mounted) setState(() => _remaining = diff); + } + + String _formatDuration(Duration d) { + final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (d.inHours > 0) { + return '${d.inHours}:$minutes:$seconds'; + } + return '$minutes:$seconds'; + } + + @override + Widget build(BuildContext context) { + final target = ref.watch(shiftCountdownProvider); + + // Start/stop timer when target changes + ref.listen(shiftCountdownProvider, (previous, next) { + if (next != null) { + _startTimer(next); + } else { + _timer?.cancel(); + if (mounted) setState(() => _remaining = Duration.zero); + } + }); + + // Auto-dismiss when user checks in today (attendance stream updates) + ref.listen(attendanceLogsProvider, (previous, next) { + if (target == null) return; + final logs = next.valueOrNull ?? []; + final today = DateTime.now(); + final checkedInToday = logs.any( + (log) => + log.checkInAt.year == today.year && + log.checkInAt.month == today.month && + log.checkInAt.day == today.day, + ); + if (checkedInToday) { + ref.read(shiftCountdownProvider.notifier).state = null; + } + }); + + // Initialize timer on first build if target is already set + if (target != null && _timer == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _startTimer(target); + }); + } + + final showBanner = target != null && _remaining > Duration.zero; + + return Column( + children: [ + if (showBanner) + Material( + child: InkWell( + onTap: () => context.go('/attendance'), + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 20, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Shift starts in ${_formatDuration(_remaining)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + 'Tap to check in', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } +} diff --git a/supabase/functions/process_scheduled_notifications/index.ts b/supabase/functions/process_scheduled_notifications/index.ts index ea64672e..7e11f803 100644 --- a/supabase/functions/process_scheduled_notifications/index.ts +++ b/supabase/functions/process_scheduled_notifications/index.ts @@ -54,7 +54,9 @@ async function processBatch() { const notifyType = r.notify_type const rowId = r.id - const notificationId = await uuidFromName(`${scheduleId}-${userId}-${notifyType}`) + // Build a unique ID that accounts for all reference columns + epoch + const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${userId}-${notifyType}-${r.epoch || 0}` + const notificationId = await uuidFromName(idSource) // Attempt to mark idempotent push const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId }) @@ -71,18 +73,74 @@ async function processBatch() { continue } - // Prepare message + // Prepare message based on notify_type let title = '' let body = '' - if (notifyType === 'start_15') { - title = 'Shift starting soon' - body = 'Your shift starts in 15 minutes. Don\'t forget to check in.' - } else if (notifyType === 'end') { - title = 'Shift ended' - body = 'Your shift has ended. Please remember to check out if you haven\'t.' - } else { - title = 'Shift reminder' - body = 'Reminder about your shift.' + const data: Record = { + notification_id: notificationId, + type: notifyType, + } + + // Include reference IDs in data payload + if (scheduleId) data.schedule_id = scheduleId + if (r.task_id) data.task_id = r.task_id + if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id + if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id + + switch (notifyType) { + case 'start_15': + title = 'Shift starting soon' + body = "Your shift starts in 15 minutes. Don't forget to check in." + data.navigate_to = '/attendance' + break + case 'end': + title = 'Shift ended' + body = "Your shift has ended. Please remember to check out." + data.navigate_to = '/attendance' + break + case 'end_hourly': + title = 'Check-out reminder' + body = "You haven't checked out yet. Please check out when done." + data.navigate_to = '/attendance' + break + case 'overtime_idle_15': + title = 'No active task' + body = "You've been on overtime for 15 minutes without an active task or IT service request." + data.navigate_to = '/tasks' + break + case 'overtime_checkout_30': + title = 'Overtime check-out reminder' + body = "It's been 30 minutes since your last task ended. Consider checking out if you're done." + data.navigate_to = '/attendance' + break + case 'isr_event_60': + title = 'IT Service Request event soon' + body = 'An IT service request event starts in 1 hour.' + data.navigate_to = `/it-service-requests/${r.it_service_request_id}` + break + case 'isr_evidence_daily': + title = 'Evidence upload reminder' + body = 'Please upload evidence and action taken for your IT service request.' + data.navigate_to = `/it-service-requests/${r.it_service_request_id}` + break + case 'task_paused_daily': + title = 'Paused task reminder' + body = 'You have a paused task that needs attention.' + data.navigate_to = `/tasks/${r.task_id}` + break + case 'backlog_15': + title = 'Pending tasks reminder' + body = 'Your shift ends in 15 minutes and you still have pending tasks.' + data.navigate_to = '/tasks' + break + case 'pass_slip_expiry_15': + title = 'Pass slip expiring soon' + body = 'Your pass slip expires in 15 minutes. Please return and complete it.' + data.navigate_to = '/attendance' + break + default: + title = 'Reminder' + body = 'You have a pending notification.' } // Call send_fcm endpoint to deliver push (reuses existing implementation) @@ -90,11 +148,7 @@ async function processBatch() { user_ids: [userId], title, body, - data: { - notification_id: notificationId, - schedule_id: scheduleId, - type: notifyType, - }, + data, } const res = await fetch(SEND_FCM_URL, { diff --git a/supabase/migrations/20260321_extend_scheduled_notifications.sql b/supabase/migrations/20260321_extend_scheduled_notifications.sql new file mode 100644 index 00000000..194740d4 --- /dev/null +++ b/supabase/migrations/20260321_extend_scheduled_notifications.sql @@ -0,0 +1,516 @@ +-- Migration: Extend scheduled_notifications for 9 push notification reminder types +-- Adds support for task-based, ISR-based, and pass-slip-based notifications alongside existing shift reminders. +-- Creates modular enqueue functions for each notification type, called by a master dispatcher. +-- Uses pg_cron + pg_net to fully automate enqueue and processing without external cron. + +-- ============================================================================ +-- 1. SCHEMA CHANGES +-- ============================================================================ + +-- 1a. Make schedule_id nullable (non-shift notifications don't reference a duty schedule) +ALTER TABLE public.scheduled_notifications ALTER COLUMN schedule_id DROP NOT NULL; + +-- 1b. Add reference columns for tasks, IT service requests, and pass slips +ALTER TABLE public.scheduled_notifications + ADD COLUMN IF NOT EXISTS task_id uuid REFERENCES public.tasks(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS it_service_request_id uuid REFERENCES public.it_service_requests(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS pass_slip_id uuid REFERENCES public.pass_slips(id) ON DELETE CASCADE; + +-- 1c. Add epoch column for recurring notifications (hourly checkout, daily reminders) +ALTER TABLE public.scheduled_notifications + ADD COLUMN IF NOT EXISTS epoch int NOT NULL DEFAULT 0; + +-- 1d. Drop old unique constraint and create new one that supports all reference types + epoch +ALTER TABLE public.scheduled_notifications DROP CONSTRAINT IF EXISTS uniq_sched_user_type; + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_sched_notif ON public.scheduled_notifications ( + COALESCE(schedule_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(task_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(it_service_request_id, '00000000-0000-0000-0000-000000000000'), + COALESCE(pass_slip_id, '00000000-0000-0000-0000-000000000000'), + user_id, + notify_type, + epoch +); + +-- 1e. CHECK: at least one reference must be non-null +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_class t ON c.conrelid = t.oid + WHERE c.conname = 'chk_at_least_one_ref' AND t.relname = 'scheduled_notifications' + ) THEN + ALTER TABLE public.scheduled_notifications + ADD CONSTRAINT chk_at_least_one_ref + CHECK (schedule_id IS NOT NULL OR task_id IS NOT NULL OR it_service_request_id IS NOT NULL OR pass_slip_id IS NOT NULL); + END IF; +END$$; + +-- 1f. Add indexes for new columns +CREATE INDEX IF NOT EXISTS idx_sched_notif_task ON public.scheduled_notifications(task_id) WHERE task_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sched_notif_isr ON public.scheduled_notifications(it_service_request_id) WHERE it_service_request_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sched_notif_pass_slip ON public.scheduled_notifications(pass_slip_id) WHERE pass_slip_id IS NOT NULL; + +-- ============================================================================ +-- 2. ENQUEUE FUNCTIONS +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 2a. enqueue_due_shift_notifications() — REPLACE existing +-- Now handles: start_15, end, AND end_hourly (recurring hourly checkout) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_due_shift_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_hours_since_end int; + v_latest_hourly timestamptz; +BEGIN + -- 15-minute-before reminders (existing logic) + FOR rec IN + SELECT id AS schedule_id, user_id, start_time AS start_at + FROM public.duty_schedules + WHERE start_time BETWEEN now() + interval '15 minutes' - interval '30 seconds' + AND now() + interval '15 minutes' + interval '30 seconds' + AND status = 'active' + LOOP + -- Skip if user already checked in for this duty + IF EXISTS ( + SELECT 1 FROM public.attendance_logs al + WHERE al.duty_schedule_id = rec.schedule_id AND al.check_in_at IS NOT NULL + ) THEN + CONTINUE; + END IF; + + INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for) + VALUES (rec.schedule_id, rec.user_id, 'start_15', rec.start_at) + ON CONFLICT DO NOTHING; + END LOOP; + + -- End-of-shift reminders at exact end time (existing logic) + FOR rec IN + SELECT id AS schedule_id, user_id, end_time AS end_at + FROM public.duty_schedules + WHERE end_time BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds' + AND status IN ('arrival', 'late') + LOOP + INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for) + VALUES (rec.schedule_id, rec.user_id, 'end', rec.end_at) + ON CONFLICT DO NOTHING; + END LOOP; + + -- Hourly checkout reminders: for users whose shift ended and have NOT checked out + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id, ds.end_time + FROM public.duty_schedules ds + JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id + WHERE ds.end_time < now() + AND ds.status IN ('arrival', 'late') + AND al.check_in_at IS NOT NULL + AND al.check_out_at IS NULL + AND ds.shift_type != 'overtime' + LOOP + v_hours_since_end := GREATEST(1, EXTRACT(EPOCH FROM (now() - rec.end_time))::int / 3600); + + -- Check if we already sent a notification for this hour + SELECT MAX(scheduled_for) INTO v_latest_hourly + FROM public.scheduled_notifications + WHERE schedule_id = rec.schedule_id + AND user_id = rec.user_id + AND notify_type = 'end_hourly'; + + -- Only enqueue if no hourly was sent, or the last one was >55 min ago + IF v_latest_hourly IS NULL OR v_latest_hourly < now() - interval '55 minutes' THEN + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (rec.schedule_id, rec.user_id, 'end_hourly', now(), v_hours_since_end) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2b. enqueue_overtime_idle_notifications() +-- 15 minutes into overtime without an active task or ISR +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_overtime_idle_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id + FROM public.duty_schedules ds + JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id + WHERE ds.shift_type = 'overtime' + AND ds.status IN ('arrival', 'late') + AND al.check_in_at IS NOT NULL + AND al.check_out_at IS NULL + AND al.check_in_at <= now() - interval '15 minutes' + -- No in-progress task + AND NOT EXISTS ( + SELECT 1 FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = ds.user_id AND t.status = 'in_progress' + ) + -- No in-progress IT service request + AND NOT EXISTS ( + SELECT 1 FROM public.it_service_request_assignments isra + JOIN public.it_service_requests isr ON isr.id = isra.request_id + WHERE isra.user_id = ds.user_id AND isr.status IN ('in_progress', 'in_progress_dry_run') + ) + LOOP + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for) + VALUES + (rec.schedule_id, rec.user_id, 'overtime_idle_15', now()) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2c. enqueue_overtime_checkout_notifications() +-- 30 min after last task completion during overtime, no new task, +-- and at least 1 hour since overtime check-in +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_overtime_checkout_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_last_completed timestamptz; +BEGIN + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id, al.check_in_at + FROM public.duty_schedules ds + JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id + WHERE ds.shift_type = 'overtime' + AND ds.status IN ('arrival', 'late') + AND al.check_in_at IS NOT NULL + AND al.check_out_at IS NULL + AND al.check_in_at <= now() - interval '1 hour' + -- No in-progress tasks + AND NOT EXISTS ( + SELECT 1 FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = ds.user_id AND t.status = 'in_progress' + ) + LOOP + -- Find most recently completed task for this user + SELECT MAX(t.completed_at) INTO v_last_completed + FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = rec.user_id + AND t.status IN ('completed', 'closed') + AND t.completed_at IS NOT NULL + AND t.completed_at >= rec.check_in_at; -- Only tasks completed during this overtime + + -- Only notify if a task was completed >=30 min ago + IF v_last_completed IS NOT NULL AND v_last_completed <= now() - interval '30 minutes' THEN + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for) + VALUES + (rec.schedule_id, rec.user_id, 'overtime_checkout_30', now()) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2d. enqueue_isr_event_notifications() +-- 1 hour before IT service request event_date +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_isr_event_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT isr.id AS request_id, isr.event_date, isra.user_id + FROM public.it_service_requests isr + JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id + WHERE isr.status IN ('scheduled', 'in_progress_dry_run') + AND isr.event_date IS NOT NULL + AND isr.event_date BETWEEN now() + interval '60 minutes' - interval '30 seconds' + AND now() + interval '60 minutes' + interval '30 seconds' + LOOP + INSERT INTO public.scheduled_notifications + (it_service_request_id, user_id, notify_type, scheduled_for) + VALUES + (rec.request_id, rec.user_id, 'isr_event_60', rec.event_date - interval '1 hour') + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2e. enqueue_isr_evidence_notifications() +-- Daily reminder to assigned users who have NOT uploaded evidence or action +-- Only triggers after user has checked in today +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_isr_evidence_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_today_doy int := EXTRACT(DOY FROM now())::int; +BEGIN + FOR rec IN + SELECT isr.id AS request_id, isra.user_id + FROM public.it_service_requests isr + JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id + WHERE isr.status IN ('completed', 'in_progress') + AND ( + -- User has NOT uploaded evidence + NOT EXISTS ( + SELECT 1 FROM public.it_service_request_evidence e + WHERE e.request_id = isr.id AND e.user_id = isra.user_id + ) + OR + -- User has NOT submitted action taken + NOT EXISTS ( + SELECT 1 FROM public.it_service_request_actions a + WHERE a.request_id = isr.id AND a.user_id = isra.user_id + AND a.action_taken IS NOT NULL AND TRIM(a.action_taken) != '' + ) + ) + -- User must be checked in today + AND EXISTS ( + SELECT 1 FROM public.attendance_logs al + WHERE al.user_id = isra.user_id + AND al.check_in_at::date = now()::date + ) + LOOP + INSERT INTO public.scheduled_notifications + (it_service_request_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (rec.request_id, rec.user_id, 'isr_evidence_daily', now(), v_today_doy) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2f. enqueue_paused_task_notifications() +-- Daily reminder for paused in-progress tasks after user checks in +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_paused_task_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_today_doy int := EXTRACT(DOY FROM now())::int; +BEGIN + FOR rec IN + SELECT t.id AS task_id, ta.user_id + FROM public.tasks t + JOIN public.task_assignments ta ON ta.task_id = t.id + WHERE t.status = 'in_progress' + -- Latest activity log for this task is 'paused' + AND EXISTS ( + SELECT 1 FROM public.task_activity_logs tal + WHERE tal.task_id = t.id + AND tal.action_type = 'paused' + AND NOT EXISTS ( + SELECT 1 FROM public.task_activity_logs tal2 + WHERE tal2.task_id = t.id + AND tal2.created_at > tal.created_at + AND tal2.action_type IN ('resumed', 'completed', 'cancelled') + ) + ) + -- User must be checked in today + AND EXISTS ( + SELECT 1 FROM public.attendance_logs al + WHERE al.user_id = ta.user_id + AND al.check_in_at::date = now()::date + ) + LOOP + INSERT INTO public.scheduled_notifications + (task_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (rec.task_id, rec.user_id, 'task_paused_daily', now(), v_today_doy) + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2g. enqueue_backlog_notifications() +-- 15 min before shift end, users with pending tasks (queued/in_progress, not paused) +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_backlog_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT ds.id AS schedule_id, ds.user_id, ds.end_time + FROM public.duty_schedules ds + WHERE ds.end_time BETWEEN now() + interval '15 minutes' - interval '30 seconds' + AND now() + interval '15 minutes' + interval '30 seconds' + AND ds.status IN ('arrival', 'late') + -- User has pending (non-paused) tasks + AND EXISTS ( + SELECT 1 + FROM public.task_assignments ta + JOIN public.tasks t ON t.id = ta.task_id + WHERE ta.user_id = ds.user_id + AND t.status IN ('queued', 'in_progress') + -- Exclude paused tasks + AND NOT EXISTS ( + SELECT 1 FROM public.task_activity_logs tal + WHERE tal.task_id = t.id + AND tal.action_type = 'paused' + AND NOT EXISTS ( + SELECT 1 FROM public.task_activity_logs tal2 + WHERE tal2.task_id = t.id + AND tal2.created_at > tal.created_at + AND tal2.action_type IN ('resumed', 'completed', 'cancelled') + ) + ) + ) + LOOP + INSERT INTO public.scheduled_notifications + (schedule_id, user_id, notify_type, scheduled_for) + VALUES + (rec.schedule_id, rec.user_id, 'backlog_15', rec.end_time - interval '15 minutes') + ON CONFLICT DO NOTHING; + END LOOP; +END; +$$; + +-- ---------------------------------------------------------------------------- +-- 2h. enqueue_pass_slip_expiry_notifications() +-- 15 min before pass slip reaches its 1-hour limit +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expiry_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO public.scheduled_notifications (pass_slip_id, user_id, notify_type, scheduled_for) + SELECT ps.id, ps.user_id, 'pass_slip_expiry_15', now() + FROM public.pass_slips ps + WHERE ps.status = 'approved' + AND ps.slip_end IS NULL + AND ps.slip_start IS NOT NULL + AND ps.slip_start + interval '45 minutes' + BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds' + ON CONFLICT DO NOTHING; +END; +$$; + +-- ============================================================================ +-- 3. MASTER DISPATCHER +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM public.enqueue_due_shift_notifications(); + PERFORM public.enqueue_overtime_idle_notifications(); + PERFORM public.enqueue_overtime_checkout_notifications(); + PERFORM public.enqueue_isr_event_notifications(); + PERFORM public.enqueue_isr_evidence_notifications(); + PERFORM public.enqueue_paused_task_notifications(); + PERFORM public.enqueue_backlog_notifications(); + PERFORM public.enqueue_pass_slip_expiry_notifications(); +END; +$$; + +-- ============================================================================ +-- 4. PG_NET + PG_CRON SCHEDULING +-- ============================================================================ +-- Uses pg_net to call the process_scheduled_notifications edge function +-- from within PostgreSQL, eliminating the need for external cron jobs. + +CREATE EXTENSION IF NOT EXISTS pg_net; + +-- Helper function: invokes the process_scheduled_notifications edge function via pg_net +CREATE OR REPLACE FUNCTION public.process_notification_queue() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_base_url text; + v_service_key text; +BEGIN + -- Try Supabase vault first (standard in self-hosted Supabase) + BEGIN + SELECT decrypted_secret INTO v_service_key + FROM vault.decrypted_secrets + WHERE name = 'service_role_key' + LIMIT 1; + + SELECT decrypted_secret INTO v_base_url + FROM vault.decrypted_secrets + WHERE name = 'supabase_url' + LIMIT 1; + EXCEPTION WHEN others THEN + -- vault schema may not exist + NULL; + END; + + -- Fall back to app.settings GUC variables + IF v_base_url IS NULL THEN + v_base_url := current_setting('app.settings.supabase_url', true); + END IF; + IF v_service_key IS NULL THEN + v_service_key := current_setting('app.settings.service_role_key', true); + END IF; + + -- Guard: skip if config is missing + IF v_base_url IS NULL OR v_service_key IS NULL THEN + RAISE WARNING 'process_notification_queue: missing supabase_url or service_role_key config'; + RETURN; + END IF; + + PERFORM net.http_post( + url := v_base_url || '/functions/v1/process_scheduled_notifications', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || v_service_key + ), + body := '{}'::jsonb + ); +END; +$$; + +-- Schedule pg_cron jobs (wrapped in exception block for safety) +DO $$ +BEGIN + -- Unschedule old jobs if they exist + BEGIN + PERFORM cron.unschedule('shift_reminders_every_min'); + EXCEPTION WHEN others THEN NULL; + END; + BEGIN + PERFORM cron.unschedule('notification_reminders_every_min'); + EXCEPTION WHEN others THEN NULL; + END; + + -- Job 1: Enqueue notifications every minute + PERFORM cron.schedule( + 'notification_enqueue_every_min', + '*/1 * * * *', + $$SELECT public.enqueue_all_notifications();$$ + ); + + -- Job 2: Process notification queue via pg_net every minute + PERFORM cron.schedule( + 'notification_process_every_min', + '*/1 * * * *', + $$SELECT public.process_notification_queue();$$ + ); + + -- Job 3: Daily cleanup of old processed notifications + PERFORM cron.schedule( + 'cleanup_old_notifications', + '0 3 * * *', + $$DELETE FROM scheduled_notifications WHERE processed = true AND processed_at < now() - interval '7 days'; + DELETE FROM notification_pushes WHERE pushed_at < now() - interval '7 days';$$ + ); + +EXCEPTION WHEN others THEN + RAISE NOTICE 'pg_cron/pg_net not available. After enabling them, run these manually:'; + RAISE NOTICE ' SELECT cron.schedule("notification_enqueue_every_min", "*/1 * * * *", $QUERY$SELECT public.enqueue_all_notifications();$QUERY$);'; + RAISE NOTICE ' SELECT cron.schedule("notification_process_every_min", "*/1 * * * *", $QUERY$SELECT public.process_notification_queue();$QUERY$);'; + RAISE NOTICE ' SELECT cron.schedule("cleanup_old_notifications", "0 3 * * *", $QUERY$DELETE FROM scheduled_notifications WHERE processed = true AND processed_at < now() - interval ' || quote_literal('7 days') || '; DELETE FROM notification_pushes WHERE pushed_at < now() - interval ' || quote_literal('7 days') || ';$QUERY$);'; +END $$;