Compare commits
No commits in common. "b2c3202317e45be50776960ae479960a7464b42c" and "74197c525ddec548882992538b028c3431328470" have entirely different histories.
b2c3202317
...
74197c525d
|
|
@ -14,7 +14,6 @@ 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';
|
||||||
|
|
@ -198,7 +197,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/route information for navigation
|
// Build payload string with ticket/task information for navigation
|
||||||
final payloadParts = <String>[];
|
final payloadParts = <String>[];
|
||||||
final taskId =
|
final taskId =
|
||||||
(message.data['task_id'] ??
|
(message.data['task_id'] ??
|
||||||
|
|
@ -210,11 +209,7 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
@ -429,25 +424,6 @@ 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) {
|
||||||
|
|
@ -507,7 +483,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 route/ticket/task information for navigation
|
// Build payload string with ticket/task information for navigation
|
||||||
final payloadParts = <String>[];
|
final payloadParts = <String>[];
|
||||||
final taskId =
|
final taskId =
|
||||||
(message.data['task_id'] ??
|
(message.data['task_id'] ??
|
||||||
|
|
@ -519,12 +495,7 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
@ -583,20 +554,17 @@ 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 "route:PATH",
|
// handle user tapping a notification; the payload format is "ticket:ID",
|
||||||
// "ticket:ID", "task:ID", or a combination separated by "|"
|
// "task:ID", "tasknum:NUMBER", 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 navigation information
|
// Parse the payload to extract ticket and task 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('route:')) {
|
if (part.startsWith('ticket:')) {
|
||||||
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);
|
||||||
|
|
@ -604,22 +572,14 @@ Future<void> main() async {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the pending navigation provider.
|
// Update the pending navigation provider.
|
||||||
// Prefer route (from scheduled reminders), then task, then ticket.
|
// Prefer task over ticket — assignment notifications include both
|
||||||
if (route != null && route.isNotEmpty) {
|
// IDs but the primary entity is the task.
|
||||||
_globalProviderContainer
|
if (taskId != null && taskId.isNotEmpty) {
|
||||||
.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
|
||||||
|
|
@ -627,7 +587,6 @@ 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, String? route})?;
|
typedef PendingNavigation = ({String type, String id})?;
|
||||||
|
|
||||||
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
|
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
|
||||||
(ref) => null,
|
(ref) => null,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
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,25 +51,13 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to the appropriate screen using GoRouter.
|
/// Navigate to the task or ticket detail 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 (route != null && route.isNotEmpty) {
|
if (taskId != null && taskId.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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,34 +96,20 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract navigation target from FCM data payload and navigate.
|
/// Extract task/ticket IDs from the FCM data payload and navigate.
|
||||||
///
|
///
|
||||||
/// Prefers the `navigate_to` field (used by scheduled notification
|
/// The FCM data map contains keys such as `task_id` / `taskId` /
|
||||||
/// reminders) and falls back to legacy `task_id` / `ticket_id` keys.
|
/// `ticket_id` / `ticketId` — these are the same keys used in the
|
||||||
|
/// background handler ([_firebaseMessagingBackgroundHandler]).
|
||||||
void _handleMessageTap(RemoteMessage message) {
|
void _handleMessageTap(RemoteMessage message) {
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
|
|
||||||
// New: use navigate_to route if present (scheduled reminder notifications)
|
final String? taskId = (data['task_id'] ?? data['taskId'] ?? data['task'])
|
||||||
final String? navigateTo = data['navigate_to']?.toString();
|
?.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(
|
_goToDetail(taskId: taskId, ticketId: ticketId);
|
||||||
taskId: taskId,
|
|
||||||
ticketId: ticketId,
|
|
||||||
itServiceRequestId: isrId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -164,11 +138,8 @@ 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,11 +786,13 @@ 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 a spring-bounce
|
/// Displays an [icon] inside a colored circular badge with:
|
||||||
/// entrance animation: scales from 0 → 1.0 with a slight overshoot.
|
/// 1. A spring-bounce entrance animation on first build.
|
||||||
|
/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats
|
||||||
|
/// indefinitely to draw attention without being distracting.
|
||||||
///
|
///
|
||||||
/// Respects [m3ReducedMotion] — the animation is skipped when reduced
|
/// Respects [m3ReducedMotion] — both animations are skipped when reduced
|
||||||
/// motion is enabled, showing the badge immediately.
|
/// motion is enabled.
|
||||||
class M3BounceIcon extends StatefulWidget {
|
class M3BounceIcon extends StatefulWidget {
|
||||||
const M3BounceIcon({
|
const M3BounceIcon({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -812,22 +814,36 @@ class M3BounceIcon extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _M3BounceIconState extends State<M3BounceIcon>
|
class _M3BounceIconState extends State<M3BounceIcon>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -845,7 +861,15 @@ class _M3BounceIconState extends State<M3BounceIcon>
|
||||||
|
|
||||||
if (m3ReducedMotion(context)) return badge;
|
if (m3ReducedMotion(context)) return badge;
|
||||||
|
|
||||||
return ScaleTransition(scale: _entrance, child: badge);
|
return ScaleTransition(
|
||||||
|
scale: _entrance,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _pulse,
|
||||||
|
builder: (_, child) =>
|
||||||
|
Transform.scale(scale: _pulse.value, child: child),
|
||||||
|
child: badge,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -330,9 +328,7 @@ 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: ShiftCountdownBanner(
|
child: child,
|
||||||
child: PassSlipCountdownBanner(child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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,8 +2,7 @@ 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
|
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SEND_FCM_URL
|
||||||
// Optional: SEND_FCM_URL (defaults to ${SUPABASE_URL}/functions/v1/send_fcm)
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
|
@ -11,8 +10,7 @@ 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
|
||||||
|
|
@ -56,82 +54,35 @@ async function processBatch() {
|
||||||
const notifyType = r.notify_type
|
const notifyType = r.notify_type
|
||||||
const rowId = r.id
|
const rowId = r.id
|
||||||
|
|
||||||
// Build a unique ID that accounts for all reference columns + epoch
|
const notificationId = await uuidFromName(`${scheduleId}-${userId}-${notifyType}`)
|
||||||
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)
|
|
||||||
|
|
||||||
// Idempotency is handled by send_fcm via try_mark_notification_pushed.
|
// Attempt to mark idempotent push
|
||||||
// Do NOT call it here — doing so would mark the notification as pushed
|
const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId })
|
||||||
// before send_fcm sees it, causing send_fcm to skip the actual FCM send.
|
if (markErr) {
|
||||||
|
console.warn('try_mark_notification_pushed error', markErr)
|
||||||
// Prepare message based on notify_type
|
// do not mark processed; increment retry
|
||||||
let title = ''
|
await supabase.from('scheduled_notifications').update({ retry_count: r.retry_count + 1, last_error: String(markErr) }).eq('id', rowId)
|
||||||
let body = ''
|
continue
|
||||||
const data: Record<string, string> = {
|
|
||||||
notification_id: notificationId,
|
|
||||||
type: notifyType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include reference IDs in data payload
|
if (markData === false) {
|
||||||
if (scheduleId) data.schedule_id = scheduleId
|
console.log('Notification already pushed, skipping', notificationId)
|
||||||
if (r.task_id) data.task_id = r.task_id
|
await supabase.from('scheduled_notifications').update({ processed: true, processed_at: new Date().toISOString() }).eq('id', rowId)
|
||||||
if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id
|
continue
|
||||||
if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id
|
}
|
||||||
|
|
||||||
switch (notifyType) {
|
// Prepare message
|
||||||
case 'start_15':
|
let title = ''
|
||||||
|
let body = ''
|
||||||
|
if (notifyType === '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.'
|
||||||
data.navigate_to = '/attendance'
|
} else if (notifyType === 'end') {
|
||||||
break
|
|
||||||
case 'end':
|
|
||||||
title = 'Shift ended'
|
title = 'Shift ended'
|
||||||
body = "Your shift has ended. Please remember to check out."
|
body = 'Your shift has ended. Please remember to check out if you haven\'t.'
|
||||||
data.navigate_to = '/attendance'
|
} else {
|
||||||
break
|
title = 'Shift reminder'
|
||||||
case 'end_hourly':
|
body = 'Reminder about your shift.'
|
||||||
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)
|
||||||
|
|
@ -139,7 +90,11 @@ 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, {
|
||||||
|
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
-- 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