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 <noreply@anthropic.com>
This commit is contained in:
Marc Rejohn Castillano 2026-03-20 18:26:48 +08:00
parent 74197c525d
commit d484f62cbd
9 changed files with 1023 additions and 37 deletions

View File

@ -14,6 +14,7 @@ import 'app.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'providers/notifications_provider.dart'; import 'providers/notifications_provider.dart';
import 'providers/notification_navigation_provider.dart'; import 'providers/notification_navigation_provider.dart';
import 'providers/shift_countdown_provider.dart';
import 'utils/app_time.dart'; import 'utils/app_time.dart';
import 'utils/notification_permission.dart'; import 'utils/notification_permission.dart';
import 'utils/location_permission.dart'; import 'utils/location_permission.dart';
@ -197,7 +198,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Create a unique ID for the notification display // Create a unique ID for the notification display
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000; 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 = <String>[]; final payloadParts = <String>[];
final taskId = final taskId =
(message.data['task_id'] ?? (message.data['task_id'] ??
@ -209,7 +210,11 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
message.data['ticketId'] ?? message.data['ticketId'] ??
message.data['ticket']?.toString() ?? message.data['ticket']?.toString() ??
''; '';
final navigateTo = message.data['navigate_to']?.toString() ?? '';
if (navigateTo.isNotEmpty) {
payloadParts.add('route:$navigateTo');
}
if (taskId != null && taskId.isNotEmpty) { if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId'); payloadParts.add('task:$taskId');
} }
@ -424,6 +429,25 @@ Future<void> main() async {
} catch (_) {} } catch (_) {}
final formatted = _formatNotificationFromData(dataForFormatting); 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 = String? stableId =
(message.data['notification_id'] as String?) ?? message.messageId; (message.data['notification_id'] as String?) ?? message.messageId;
if (stableId == null) { if (stableId == null) {
@ -483,7 +507,7 @@ Future<void> main() async {
recent[stableId] = now; recent[stableId] = now;
await prefs.setString('recent_notifs', jsonEncode(recent)); 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 = <String>[]; final payloadParts = <String>[];
final taskId = final taskId =
(message.data['task_id'] ?? (message.data['task_id'] ??
@ -495,7 +519,12 @@ Future<void> main() async {
message.data['ticketId'] ?? message.data['ticketId'] ??
message.data['ticket']?.toString() ?? message.data['ticket']?.toString() ??
''; '';
final navigateTo =
message.data['navigate_to']?.toString() ?? '';
if (navigateTo.isNotEmpty) {
payloadParts.add('route:$navigateTo');
}
if (taskId != null && taskId.isNotEmpty) { if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId'); payloadParts.add('task:$taskId');
} }
@ -554,17 +583,20 @@ Future<void> main() async {
// initialize the local notifications plugin so we can post alerts later // initialize the local notifications plugin so we can post alerts later
await NotificationService.initialize( await NotificationService.initialize(
onDidReceiveNotificationResponse: (response) { onDidReceiveNotificationResponse: (response) {
// handle user tapping a notification; the payload format is "ticket:ID", // handle user tapping a notification; the payload format is "route:PATH",
// "task:ID", "tasknum:NUMBER", or a combination separated by "|" // "ticket:ID", "task:ID", or a combination separated by "|"
final payload = response.payload; final payload = response.payload;
if (payload != null && payload.isNotEmpty) { 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('|'); final parts = payload.split('|');
String? ticketId; String? ticketId;
String? taskId; String? taskId;
String? route;
for (final part in parts) { 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); ticketId = part.substring('ticket:'.length);
} else if (part.startsWith('task:')) { } else if (part.startsWith('task:')) {
taskId = part.substring('task:'.length); taskId = part.substring('task:'.length);
@ -572,14 +604,22 @@ Future<void> main() async {
} }
// Update the pending navigation provider. // Update the pending navigation provider.
// Prefer task over ticket assignment notifications include both // Prefer route (from scheduled reminders), then task, then ticket.
// IDs but the primary entity is the task. if (route != null && route.isNotEmpty) {
if (taskId != null && taskId.isNotEmpty) { _globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier)
.state = (
type: 'route',
id: '',
route: route,
);
} else if (taskId != null && taskId.isNotEmpty) {
_globalProviderContainer _globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier) .read(pendingNotificationNavigationProvider.notifier)
.state = ( .state = (
type: 'task', type: 'task',
id: taskId, id: taskId,
route: null,
); );
} else if (ticketId != null && ticketId.isNotEmpty) { } else if (ticketId != null && ticketId.isNotEmpty) {
_globalProviderContainer _globalProviderContainer
@ -587,6 +627,7 @@ Future<void> main() async {
.state = ( .state = (
type: 'ticket', type: 'ticket',
id: ticketId, id: ticketId,
route: null,
); );
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
typedef PendingNavigation = ({String type, String id})?; typedef PendingNavigation = ({String type, String id, String? route})?;
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>( final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
(ref) => null, (ref) => null,

View File

@ -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<DateTime?>((ref) => null);

View File

@ -51,13 +51,25 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
} }
} }
/// Navigate to the task or ticket detail screen using GoRouter. /// Navigate to the appropriate screen using GoRouter.
void _goToDetail({String? taskId, String? ticketId}) { ///
/// 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); 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'); router.go('/tasks/$taskId');
} else if (ticketId != null && ticketId.isNotEmpty) { } else if (ticketId != null && ticketId.isNotEmpty) {
router.go('/tickets/$ticketId'); router.go('/tickets/$ticketId');
} else if (itServiceRequestId != null && itServiceRequestId.isNotEmpty) {
router.go('/it-service-requests/$itServiceRequestId');
} }
} }
@ -96,20 +108,34 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
}); });
} }
/// 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` / /// Prefers the `navigate_to` field (used by scheduled notification
/// `ticket_id` / `ticketId` these are the same keys used in the /// reminders) and falls back to legacy `task_id` / `ticket_id` keys.
/// background handler ([_firebaseMessagingBackgroundHandler]).
void _handleMessageTap(RemoteMessage message) { void _handleMessageTap(RemoteMessage message) {
final data = message.data; final data = message.data;
final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task']) // New: use navigate_to route if present (scheduled reminder notifications)
?.toString(); 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 = final String? ticketId =
(data['ticket_id'] ?? data['ticketId'] ?? data['ticket'])?.toString(); (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 @override
@ -138,8 +164,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
) { ) {
if (next != null) { if (next != null) {
_goToDetail( _goToDetail(
route: next.route,
taskId: next.type == 'task' ? next.id : null, taskId: next.type == 'task' ? next.id : null,
ticketId: next.type == 'ticket' ? 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 // Clear the pending navigation after handling
ref.read(pendingNotificationNavigationProvider.notifier).state = null; ref.read(pendingNotificationNavigationProvider.notifier).state = null;

View File

@ -10,6 +10,8 @@ import '../providers/notifications_provider.dart';
import '../providers/profile_provider.dart'; import '../providers/profile_provider.dart';
import 'app_breakpoints.dart'; import 'app_breakpoints.dart';
import 'profile_avatar.dart'; import 'profile_avatar.dart';
import 'pass_slip_countdown_banner.dart';
import 'shift_countdown_banner.dart';
final GlobalKey notificationBellKey = GlobalKey(); final GlobalKey notificationBellKey = GlobalKey();
@ -328,7 +330,9 @@ class _ShellBackground extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ColoredBox( return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: child, child: ShiftCountdownBanner(
child: PassSlipCountdownBanner(child: child),
),
); );
} }
} }

View File

@ -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<PassSlipCountdownBanner> createState() =>
_PassSlipCountdownBannerState();
}
class _PassSlipCountdownBannerState
extends ConsumerState<PassSlipCountdownBanner> {
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),
],
);
}
}

View File

@ -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<ShiftCountdownBanner> createState() =>
_ShiftCountdownBannerState();
}
class _ShiftCountdownBannerState extends ConsumerState<ShiftCountdownBanner> {
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<DateTime?>(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),
],
);
}
}

View File

@ -54,7 +54,9 @@ async function processBatch() {
const notifyType = r.notify_type const notifyType = r.notify_type
const rowId = r.id 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 // Attempt to mark idempotent push
const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId }) const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId })
@ -71,18 +73,74 @@ async function processBatch() {
continue continue
} }
// Prepare message // Prepare message based on notify_type
let title = '' let title = ''
let body = '' let body = ''
if (notifyType === 'start_15') { const data: Record<string, string> = {
title = 'Shift starting soon' notification_id: notificationId,
body = 'Your shift starts in 15 minutes. Don\'t forget to check in.' type: notifyType,
} else if (notifyType === 'end') { }
title = 'Shift ended'
body = 'Your shift has ended. Please remember to check out if you haven\'t.' // Include reference IDs in data payload
} else { if (scheduleId) data.schedule_id = scheduleId
title = 'Shift reminder' if (r.task_id) data.task_id = r.task_id
body = 'Reminder about your shift.' 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) // Call send_fcm endpoint to deliver push (reuses existing implementation)
@ -90,11 +148,7 @@ async function processBatch() {
user_ids: [userId], user_ids: [userId],
title, title,
body, body,
data: { data,
notification_id: notificationId,
schedule_id: scheduleId,
type: notifyType,
},
} }
const res = await fetch(SEND_FCM_URL, { const res = await fetch(SEND_FCM_URL, {

View File

@ -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 $$;