Compare commits
5 Commits
74197c525d
...
b2c3202317
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c3202317 | |||
| 758869920c | |||
| d81e2cde26 | |||
| 20720ba541 | |||
| d484f62cbd |
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
8
lib/providers/shift_countdown_provider.dart
Normal file
8
lib/providers/shift_countdown_provider.dart
Normal 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);
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -786,13 +786,11 @@ class _M3ErrorShakeState extends State<M3ErrorShake>
|
||||||
// M3BounceIcon — entrance + idle pulse for empty/error states
|
// M3BounceIcon — entrance + idle pulse for empty/error states
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/// Displays an [icon] inside a colored circular badge with:
|
/// Displays an [icon] inside a colored circular badge with a spring-bounce
|
||||||
/// 1. A spring-bounce entrance animation on first build.
|
/// entrance animation: scales from 0 → 1.0 with a slight overshoot.
|
||||||
/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats
|
|
||||||
/// indefinitely to draw attention without being distracting.
|
|
||||||
///
|
///
|
||||||
/// Respects [m3ReducedMotion] — both animations are skipped when reduced
|
/// Respects [m3ReducedMotion] — the animation is skipped when reduced
|
||||||
/// motion is enabled.
|
/// motion is enabled, showing the badge immediately.
|
||||||
class M3BounceIcon extends StatefulWidget {
|
class M3BounceIcon extends StatefulWidget {
|
||||||
const M3BounceIcon({
|
const M3BounceIcon({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -814,36 +812,22 @@ class M3BounceIcon extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _M3BounceIconState extends State<M3BounceIcon>
|
class _M3BounceIconState extends State<M3BounceIcon>
|
||||||
with TickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _entranceCtrl;
|
late final AnimationController _entranceCtrl;
|
||||||
late final AnimationController _pulseCtrl;
|
|
||||||
late final Animation<double> _entrance;
|
late final Animation<double> _entrance;
|
||||||
late final Animation<double> _pulse;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// One-shot spring entrance: scale 0 → 1.0 with natural overshoot.
|
||||||
// Entrance: scale 0 → 1.0 with spring overshoot.
|
|
||||||
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||||||
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
||||||
|
|
||||||
// Idle pulse: 1.0 → 1.06 → 1.0, repeating every 2.5 s.
|
|
||||||
_pulseCtrl = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 2500),
|
|
||||||
)..repeat(reverse: true);
|
|
||||||
_pulse = Tween<double>(begin: 1.0, end: 1.06).animate(
|
|
||||||
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
|
|
||||||
_entranceCtrl.forward();
|
_entranceCtrl.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_entranceCtrl.dispose();
|
_entranceCtrl.dispose();
|
||||||
_pulseCtrl.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -861,15 +845,7 @@ class _M3BounceIconState extends State<M3BounceIcon>
|
||||||
|
|
||||||
if (m3ReducedMotion(context)) return badge;
|
if (m3ReducedMotion(context)) return badge;
|
||||||
|
|
||||||
return ScaleTransition(
|
return ScaleTransition(scale: _entrance, child: badge);
|
||||||
scale: _entrance,
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: _pulse,
|
|
||||||
builder: (_, child) =>
|
|
||||||
Transform.scale(scale: _pulse.value, child: child),
|
|
||||||
child: badge,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
lib/widgets/pass_slip_countdown_banner.dart
Normal file
170
lib/widgets/pass_slip_countdown_banner.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
lib/widgets/shift_countdown_banner.dart
Normal file
164
lib/widgets/shift_countdown_banner.dart
Normal 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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@ import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||||
|
|
||||||
// Minimal Deno Edge Function to process queued scheduled_notifications
|
// Minimal Deno Edge Function to process queued scheduled_notifications
|
||||||
// Environment variables required:
|
// Environment variables required:
|
||||||
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SEND_FCM_URL
|
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
|
||||||
|
// Optional: SEND_FCM_URL (defaults to ${SUPABASE_URL}/functions/v1/send_fcm)
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
|
@ -10,7 +11,8 @@ const corsHeaders = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
||||||
const SEND_FCM_URL = Deno.env.get('SEND_FCM_URL')!
|
const SEND_FCM_URL = Deno.env.get('SEND_FCM_URL')
|
||||||
|
|| `${Deno.env.get('SUPABASE_URL')!}/functions/v1/send_fcm`
|
||||||
const BATCH_SIZE = Number(Deno.env.get('PROCESSOR_BATCH_SIZE') || '50')
|
const BATCH_SIZE = Number(Deno.env.get('PROCESSOR_BATCH_SIZE') || '50')
|
||||||
|
|
||||||
// deterministic UUIDv5-like from a name using SHA-1
|
// deterministic UUIDv5-like from a name using SHA-1
|
||||||
|
|
@ -54,35 +56,82 @@ 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
|
// Idempotency is handled by send_fcm via try_mark_notification_pushed.
|
||||||
const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId })
|
// Do NOT call it here — doing so would mark the notification as pushed
|
||||||
if (markErr) {
|
// before send_fcm sees it, causing send_fcm to skip the actual FCM send.
|
||||||
console.warn('try_mark_notification_pushed error', markErr)
|
|
||||||
// do not mark processed; increment retry
|
|
||||||
await supabase.from('scheduled_notifications').update({ retry_count: r.retry_count + 1, last_error: String(markErr) }).eq('id', rowId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (markData === false) {
|
// Prepare message based on notify_type
|
||||||
console.log('Notification already pushed, skipping', notificationId)
|
|
||||||
await supabase.from('scheduled_notifications').update({ processed: true, processed_at: new Date().toISOString() }).eq('id', rowId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare message
|
|
||||||
let title = ''
|
let title = ''
|
||||||
let body = ''
|
let body = ''
|
||||||
if (notifyType === 'start_15') {
|
const data: Record<string, string> = {
|
||||||
|
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'
|
title = 'Shift starting soon'
|
||||||
body = 'Your shift starts in 15 minutes. Don\'t forget to check in.'
|
body = "Your shift starts in 15 minutes. Don't forget to check in."
|
||||||
} else if (notifyType === 'end') {
|
data.navigate_to = '/attendance'
|
||||||
|
break
|
||||||
|
case 'end':
|
||||||
title = 'Shift ended'
|
title = 'Shift ended'
|
||||||
body = 'Your shift has ended. Please remember to check out if you haven\'t.'
|
body = "Your shift has ended. Please remember to check out."
|
||||||
} else {
|
data.navigate_to = '/attendance'
|
||||||
title = 'Shift reminder'
|
break
|
||||||
body = 'Reminder about your shift.'
|
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 +139,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, {
|
||||||
|
|
|
||||||
515
supabase/migrations/20260321_extend_scheduled_notifications.sql
Normal file
515
supabase/migrations/20260321_extend_scheduled_notifications.sql
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
-- 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 '90 seconds'
|
||||||
|
AND now() + interval '15 minutes' + interval '90 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 '90 seconds' AND now() + interval '90 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 '90 seconds'
|
||||||
|
AND now() + interval '60 minutes' + interval '90 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 '90 seconds'
|
||||||
|
AND now() + interval '15 minutes' + interval '90 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 '90 seconds' AND now() + interval '90 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 ' || quote_literal('7 days') || '; DELETE FROM notification_pushes WHERE pushed_at < now() - interval ' || quote_literal('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(%L, %L, %L);', 'notification_enqueue_every_min', '*/1 * * * *', 'SELECT public.enqueue_all_notifications();';
|
||||||
|
RAISE NOTICE ' SELECT cron.schedule(%L, %L, %L);', 'notification_process_every_min', '*/1 * * * *', 'SELECT public.process_notification_queue();';
|
||||||
|
RAISE NOTICE ' SELECT cron.schedule(%L, %L, %L);', 'cleanup_old_notifications', '0 3 * * *', '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') || ';';
|
||||||
|
END $$;
|
||||||
Loading…
Reference in New Issue
Block a user