Ensures allowing assigned IT Staff to check in outside geofence when and IT Service Request venue is outside premise
This commit is contained in:
parent
eeab3b1fcf
commit
3e3e4d560e
|
|
@ -100,8 +100,7 @@ class AttendanceController {
|
||||||
'p_attendance_id': attendanceId,
|
'p_attendance_id': attendanceId,
|
||||||
'p_lat': lat,
|
'p_lat': lat,
|
||||||
'p_lng': lng,
|
'p_lng': lng,
|
||||||
// ignore: use_null_aware_elements
|
'p_justification': justification,
|
||||||
if (justification != null) 'p_justification': justification,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/reports_provider.dart';
|
import '../../providers/reports_provider.dart';
|
||||||
import '../../providers/whereabouts_provider.dart';
|
import '../../providers/whereabouts_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
|
import '../../providers/it_service_request_provider.dart';
|
||||||
|
import '../../models/it_service_request.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
import '../../utils/location_permission.dart';
|
import '../../utils/location_permission.dart';
|
||||||
|
|
@ -375,22 +377,22 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = _currentTime;
|
final now = _currentTime;
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final todayStart = DateTime(now.year, now.month, now.day);
|
||||||
|
final tomorrowStart = todayStart.add(const Duration(days: 1));
|
||||||
final noon = DateTime(now.year, now.month, now.day, 12, 0);
|
final noon = DateTime(now.year, now.month, now.day, 12, 0);
|
||||||
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
|
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
|
||||||
|
|
||||||
// Find today's schedule for the current user.
|
// Find today's schedule for the current user.
|
||||||
|
// Includes schedules where the user is a reliever (not only the primary user).
|
||||||
// Exclude overtime schedules – they only belong in the Logbook.
|
// Exclude overtime schedules – they only belong in the Logbook.
|
||||||
|
// We treat a schedule as "today" if it overlaps the local calendar day window.
|
||||||
final schedules = schedulesAsync.valueOrNull ?? [];
|
final schedules = schedulesAsync.valueOrNull ?? [];
|
||||||
final todaySchedule = schedules.where((s) {
|
final todaySchedule = schedules.where((s) {
|
||||||
final sDay = DateTime(
|
final isAssigned =
|
||||||
s.startTime.year,
|
s.userId == profile.id || s.relieverIds.contains(profile.id);
|
||||||
s.startTime.month,
|
final overlapsToday =
|
||||||
s.startTime.day,
|
s.startTime.isBefore(tomorrowStart) && s.endTime.isAfter(todayStart);
|
||||||
);
|
return isAssigned && overlapsToday;
|
||||||
return s.userId == profile.id &&
|
|
||||||
sDay == today &&
|
|
||||||
s.shiftType != 'overtime';
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Find active attendance log (checked in but not out)
|
// Find active attendance log (checked in but not out)
|
||||||
|
|
@ -402,14 +404,67 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
.where((l) => (l.justification ?? '').trim().isNotEmpty)
|
.where((l) => (l.justification ?? '').trim().isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Roles allowed to render overtime anytime
|
// IT Service Request override (allows check-in/out outside geofence)
|
||||||
final allowedOvertimeRoles = {
|
final itRequests = ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
|
||||||
'admin',
|
final itAssignments =
|
||||||
'programmer',
|
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
|
||||||
'dispatcher',
|
final assignedRequestIds = itAssignments
|
||||||
'it_staff',
|
.where((a) => a.userId == profile.id)
|
||||||
};
|
.map((a) => a.requestId)
|
||||||
final canRenderOvertime = allowedOvertimeRoles.contains(profile.role);
|
.toSet();
|
||||||
|
final hasGeofenceOverride = itRequests.any((r) {
|
||||||
|
return assignedRequestIds.contains(r.id) &&
|
||||||
|
r.outsidePremiseAllowed &&
|
||||||
|
(r.status == ItServiceRequestStatus.scheduled ||
|
||||||
|
r.status == ItServiceRequestStatus.inProgressDryRun ||
|
||||||
|
r.status == ItServiceRequestStatus.inProgress);
|
||||||
|
});
|
||||||
|
|
||||||
|
final hasScheduleToday = todaySchedule.isNotEmpty;
|
||||||
|
final latestScheduleEnd = hasScheduleToday
|
||||||
|
? todaySchedule
|
||||||
|
.map((s) => s.endTime)
|
||||||
|
.reduce((a, b) => a.isAfter(b) ? a : b)
|
||||||
|
: null;
|
||||||
|
final isPastScheduleEnd =
|
||||||
|
latestScheduleEnd != null && now.isAfter(latestScheduleEnd);
|
||||||
|
|
||||||
|
// If the user has an approved IT Service Request override, treat it as a "schedule" for
|
||||||
|
// purposes of showing the normal check-in UI (even if the duty schedule list is empty).
|
||||||
|
final hasEffectiveSchedule = hasScheduleToday || hasGeofenceOverride;
|
||||||
|
|
||||||
|
final showOvertimeCard =
|
||||||
|
(activeOvertimeLog.isEmpty && _overtimeLogId == null) &&
|
||||||
|
activeLog.isEmpty &&
|
||||||
|
(!hasEffectiveSchedule || isPastScheduleEnd);
|
||||||
|
|
||||||
|
if (kDebugMode && showOvertimeCard) {
|
||||||
|
final assignedSchedules = schedules
|
||||||
|
.where(
|
||||||
|
(s) => s.userId == profile.id || s.relieverIds.contains(profile.id),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final assignedTodaySchedules = todaySchedule;
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'Attendance: showOvertimeCard=true (profile=${profile.id}, hasScheduleToday=$hasScheduleToday, isPastScheduleEnd=$isPastScheduleEnd, schedules=${schedules.length}, assigned=${assignedSchedules.length}, assignedToday=${assignedTodaySchedules.length})',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignedSchedules.isNotEmpty) {
|
||||||
|
for (final s in assignedSchedules.take(6)) {
|
||||||
|
debugPrint(
|
||||||
|
' assigned: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assignedTodaySchedules.isNotEmpty) {
|
||||||
|
for (final s in assignedTodaySchedules) {
|
||||||
|
debugPrint(
|
||||||
|
' assignedToday: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
@ -608,11 +663,11 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
key: ValueKey(_insideGeofence),
|
key: ValueKey(_insideGeofence),
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_insideGeofence
|
_insideGeofence || hasGeofenceOverride
|
||||||
? Icons.check_circle
|
? Icons.check_circle
|
||||||
: Icons.cancel,
|
: Icons.cancel,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: _insideGeofence
|
color: _insideGeofence || hasGeofenceOverride
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: colors.error,
|
: colors.error,
|
||||||
),
|
),
|
||||||
|
|
@ -620,15 +675,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
Text(
|
Text(
|
||||||
_insideGeofence
|
_insideGeofence
|
||||||
? 'Within geofence'
|
? 'Within geofence'
|
||||||
|
: hasGeofenceOverride
|
||||||
|
? 'Outside geofence (allowed by IT request)'
|
||||||
: 'Outside geofence',
|
: 'Outside geofence',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: _insideGeofence
|
color: _insideGeofence || hasGeofenceOverride
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: colors.error,
|
: colors.error,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_insideGeofence) ...[
|
if (!_insideGeofence && !hasGeofenceOverride) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -658,13 +715,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
|
|
||||||
if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null)
|
if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null)
|
||||||
_buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog)
|
_buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog)
|
||||||
else if (_overtimeLogId == null &&
|
else if (showOvertimeCard)
|
||||||
(canRenderOvertime ||
|
|
||||||
(todaySchedule.isEmpty && activeLog.isEmpty)))
|
|
||||||
_buildOvertimeCard(context, theme, colors)
|
_buildOvertimeCard(context, theme, colors)
|
||||||
else if (todaySchedule.isEmpty &&
|
|
||||||
(activeLog.isNotEmpty || _overtimeLogId != null))
|
|
||||||
_buildActiveOvertimeCard(context, theme, colors, activeLog)
|
|
||||||
else
|
else
|
||||||
...todaySchedule.map((schedule) {
|
...todaySchedule.map((schedule) {
|
||||||
// All logs for this schedule.
|
// All logs for this schedule.
|
||||||
|
|
@ -705,7 +757,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
final canCheckIn =
|
final canCheckIn =
|
||||||
!showCheckOut &&
|
!showCheckOut &&
|
||||||
!isShiftOver &&
|
!isShiftOver &&
|
||||||
_insideGeofence &&
|
(_insideGeofence || hasGeofenceOverride) &&
|
||||||
!_checkingGeofence;
|
!_checkingGeofence;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
|
@ -807,6 +859,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
else if (!showCheckOut &&
|
else if (!showCheckOut &&
|
||||||
!isShiftOver &&
|
!isShiftOver &&
|
||||||
!_insideGeofence &&
|
!_insideGeofence &&
|
||||||
|
!hasGeofenceOverride &&
|
||||||
!_checkingGeofence)
|
!_checkingGeofence)
|
||||||
Center(
|
Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|
@ -912,10 +965,33 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
accuracy: LocationAccuracy.high,
|
accuracy: LocationAccuracy.high,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Client-side geofence check (can be bypassed in debug mode)
|
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
|
||||||
final debugBypass =
|
final debugBypass =
|
||||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||||
if (geoCfg != null && !debugBypass) {
|
|
||||||
|
// Allow outside check-in if the user has an approved IT Service Request
|
||||||
|
// with outsidePremiseAllowed = true.
|
||||||
|
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||||
|
final hasItOverride = () {
|
||||||
|
if (profile == null) return false;
|
||||||
|
final itRequests =
|
||||||
|
ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
|
||||||
|
final itAssignments =
|
||||||
|
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
|
||||||
|
final assignedRequestIds = itAssignments
|
||||||
|
.where((a) => a.userId == profile.id)
|
||||||
|
.map((a) => a.requestId)
|
||||||
|
.toSet();
|
||||||
|
return itRequests.any((r) {
|
||||||
|
return assignedRequestIds.contains(r.id) &&
|
||||||
|
r.outsidePremiseAllowed &&
|
||||||
|
(r.status == ItServiceRequestStatus.scheduled ||
|
||||||
|
r.status == ItServiceRequestStatus.inProgressDryRun ||
|
||||||
|
r.status == ItServiceRequestStatus.inProgress);
|
||||||
|
});
|
||||||
|
}();
|
||||||
|
|
||||||
|
if (geoCfg != null && !debugBypass && !hasItOverride) {
|
||||||
bool inside = false;
|
bool inside = false;
|
||||||
if (geoCfg.hasPolygon) {
|
if (geoCfg.hasPolygon) {
|
||||||
inside = geoCfg.containsPolygon(
|
inside = geoCfg.containsPolygon(
|
||||||
|
|
@ -935,8 +1011,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
showWarningSnackBar(context, 'You are outside the geofence area.');
|
showWarningSnackBar(context, 'You are outside the geofence area.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (debugBypass && mounted) {
|
} else if ((debugBypass || hasItOverride) && mounted) {
|
||||||
showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed');
|
showInfoSnackBar(
|
||||||
|
context,
|
||||||
|
hasItOverride
|
||||||
|
? 'Allowed by approved IT Service Request.'
|
||||||
|
: '⚠️ DEBUG: Geofence check bypassed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final logId = await ref
|
final logId = await ref
|
||||||
.read(attendanceControllerProvider)
|
.read(attendanceControllerProvider)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
-- Allow assigned IT staff to check in/out outside the geofence when an approved
|
||||||
|
-- IT Service Request has "outside_premise_allowed" enabled.
|
||||||
|
--
|
||||||
|
-- This enforces the same behavior server-side so clients cannot bypass geofence
|
||||||
|
-- checks by sending lat/lng values directly.
|
||||||
|
|
||||||
|
-- Recreate attendance_check_in to validate geofence and allow override.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_in(
|
||||||
|
p_duty_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision
|
||||||
|
) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_schedule duty_schedules%ROWTYPE;
|
||||||
|
v_log_id uuid;
|
||||||
|
v_now timestamptz := now();
|
||||||
|
v_status text;
|
||||||
|
v_geofence jsonb;
|
||||||
|
v_in_premise boolean := true;
|
||||||
|
v_polygon jsonb;
|
||||||
|
v_point_count int;
|
||||||
|
v_i int;
|
||||||
|
v_j int;
|
||||||
|
v_xi double precision;
|
||||||
|
v_yi double precision;
|
||||||
|
v_xj double precision;
|
||||||
|
v_yj double precision;
|
||||||
|
v_inside boolean := false;
|
||||||
|
v_override boolean := false;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Duty schedule not found';
|
||||||
|
END IF;
|
||||||
|
IF v_schedule.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your duty schedule';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate geofence (unless overridden by a signed IT service request)
|
||||||
|
SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence';
|
||||||
|
IF v_geofence IS NOT NULL THEN
|
||||||
|
v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points');
|
||||||
|
IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN
|
||||||
|
-- Ray-casting point-in-polygon
|
||||||
|
v_point_count := jsonb_array_length(v_polygon);
|
||||||
|
v_j := v_point_count - 1;
|
||||||
|
FOR v_i IN 0..(v_point_count - 1) LOOP
|
||||||
|
v_xi := (v_polygon->v_i->>'lng')::double precision;
|
||||||
|
v_yi := (v_polygon->v_i->>'lat')::double precision;
|
||||||
|
v_xj := (v_polygon->v_j->>'lng')::double precision;
|
||||||
|
v_yj := (v_polygon->v_j->>'lat')::double precision;
|
||||||
|
|
||||||
|
IF ((v_yi > p_lat) != (v_yj > p_lat)) AND
|
||||||
|
(p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN
|
||||||
|
v_inside := NOT v_inside;
|
||||||
|
END IF;
|
||||||
|
v_j := v_i;
|
||||||
|
END LOOP;
|
||||||
|
v_in_premise := v_inside;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT v_in_premise THEN
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM it_service_request_assignments a
|
||||||
|
JOIN it_service_requests r ON r.id = a.request_id
|
||||||
|
WHERE a.user_id = auth.uid()
|
||||||
|
AND r.outside_premise_allowed
|
||||||
|
AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress')
|
||||||
|
) INTO v_override;
|
||||||
|
|
||||||
|
IF NOT v_override THEN
|
||||||
|
RAISE EXCEPTION 'Outside geofence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check 2-hour window
|
||||||
|
IF v_now < (v_schedule.start_time - interval '2 hours') THEN
|
||||||
|
RAISE EXCEPTION 'Too early to check in (2-hour window)';
|
||||||
|
END IF;
|
||||||
|
IF v_now > v_schedule.end_time THEN
|
||||||
|
RAISE EXCEPTION 'Duty has already ended';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determine status (used only when first check-in)
|
||||||
|
IF v_now <= v_schedule.start_time THEN
|
||||||
|
v_status := 'arrival';
|
||||||
|
ELSE
|
||||||
|
v_status := 'late';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Insert a new attendance log row for each check-in
|
||||||
|
INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng)
|
||||||
|
VALUES (auth.uid(), p_duty_id, v_now, p_lat, p_lng)
|
||||||
|
RETURNING id INTO v_log_id;
|
||||||
|
|
||||||
|
-- Only update duty schedule on first check-in (preserve original arrival status)
|
||||||
|
UPDATE duty_schedules
|
||||||
|
SET check_in_at = COALESCE(check_in_at, v_now),
|
||||||
|
check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||||
|
status = CASE
|
||||||
|
WHEN check_in_at IS NULL THEN v_status::duty_status
|
||||||
|
ELSE status
|
||||||
|
END
|
||||||
|
WHERE id = p_duty_id;
|
||||||
|
|
||||||
|
RETURN v_log_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Recreate attendance_check_out to validate geofence and allow override.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||||
|
p_attendance_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision
|
||||||
|
) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log attendance_logs%ROWTYPE;
|
||||||
|
v_geofence jsonb;
|
||||||
|
v_in_premise boolean := true;
|
||||||
|
v_polygon jsonb;
|
||||||
|
v_point_count int;
|
||||||
|
v_i int;
|
||||||
|
v_j int;
|
||||||
|
v_xi double precision;
|
||||||
|
v_yi double precision;
|
||||||
|
v_xj double precision;
|
||||||
|
v_yj double precision;
|
||||||
|
v_inside boolean := false;
|
||||||
|
v_override boolean := false;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Attendance log not found';
|
||||||
|
END IF;
|
||||||
|
IF v_log.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your attendance log';
|
||||||
|
END IF;
|
||||||
|
IF v_log.check_out_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Already checked out';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Validate geofence (unless overridden by a signed IT service request)
|
||||||
|
SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence';
|
||||||
|
IF v_geofence IS NOT NULL THEN
|
||||||
|
v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points');
|
||||||
|
IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN
|
||||||
|
-- Ray-casting point-in-polygon
|
||||||
|
v_point_count := jsonb_array_length(v_polygon);
|
||||||
|
v_j := v_point_count - 1;
|
||||||
|
FOR v_i IN 0..(v_point_count - 1) LOOP
|
||||||
|
v_xi := (v_polygon->v_i->>'lng')::double precision;
|
||||||
|
v_yi := (v_polygon->v_i->>'lat')::double precision;
|
||||||
|
v_xj := (v_polygon->v_j->>'lng')::double precision;
|
||||||
|
v_yj := (v_polygon->v_j->>'lat')::double precision;
|
||||||
|
|
||||||
|
IF ((v_yi > p_lat) != (v_yj > p_lat)) AND
|
||||||
|
(p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN
|
||||||
|
v_inside := NOT v_inside;
|
||||||
|
END IF;
|
||||||
|
v_j := v_i;
|
||||||
|
END LOOP;
|
||||||
|
v_in_premise := v_inside;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT v_in_premise THEN
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM it_service_request_assignments a
|
||||||
|
JOIN it_service_requests r ON r.id = a.request_id
|
||||||
|
WHERE a.user_id = auth.uid()
|
||||||
|
AND r.outside_premise_allowed
|
||||||
|
AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress')
|
||||||
|
) INTO v_override;
|
||||||
|
|
||||||
|
IF NOT v_override THEN
|
||||||
|
RAISE EXCEPTION 'Outside geofence';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE attendance_logs
|
||||||
|
SET check_out_at = now(),
|
||||||
|
check_out_lat = p_lat,
|
||||||
|
check_out_lng = p_lng
|
||||||
|
WHERE id = p_attendance_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
-- Revert server-side geofence validation in attendance_check_in/check_out.
|
||||||
|
--
|
||||||
|
-- This migration restores the original behavior where the client is responsible
|
||||||
|
-- for geofence enforcement (and where check-in/check-out doesn't validate
|
||||||
|
-- whether the provided location is inside the geofence).
|
||||||
|
|
||||||
|
-- Recreate attendance_check_in without geofence validation.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_in(
|
||||||
|
p_duty_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision
|
||||||
|
) RETURNS uuid
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_schedule duty_schedules%ROWTYPE;
|
||||||
|
v_log_id uuid;
|
||||||
|
v_now timestamptz := now();
|
||||||
|
v_status text;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Duty schedule not found';
|
||||||
|
END IF;
|
||||||
|
IF v_schedule.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your duty schedule';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_now < (v_schedule.start_time - interval '2 hours') THEN
|
||||||
|
RAISE EXCEPTION 'Too early to check in (2-hour window)';
|
||||||
|
END IF;
|
||||||
|
IF v_now > v_schedule.end_time THEN
|
||||||
|
RAISE EXCEPTION 'Duty has already ended';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determine status (used only when first check-in)
|
||||||
|
IF v_now <= v_schedule.start_time THEN
|
||||||
|
v_status := 'arrival';
|
||||||
|
ELSE
|
||||||
|
v_status := 'late';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Insert a new attendance log row for each check-in
|
||||||
|
INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng)
|
||||||
|
VALUES (auth.uid(), p_duty_id, v_now, p_lat, p_lng)
|
||||||
|
RETURNING id INTO v_log_id;
|
||||||
|
|
||||||
|
-- Only update duty schedule on first check-in (preserve original arrival status)
|
||||||
|
UPDATE duty_schedules
|
||||||
|
SET check_in_at = COALESCE(check_in_at, v_now),
|
||||||
|
check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography,
|
||||||
|
status = CASE
|
||||||
|
WHEN check_in_at IS NULL THEN v_status::duty_status
|
||||||
|
ELSE status
|
||||||
|
END
|
||||||
|
WHERE id = p_duty_id;
|
||||||
|
|
||||||
|
RETURN v_log_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Recreate attendance_check_out without geofence validation.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||||
|
p_attendance_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision,
|
||||||
|
p_justification text DEFAULT NULL
|
||||||
|
) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log attendance_logs%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Attendance log not found';
|
||||||
|
END IF;
|
||||||
|
IF v_log.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your attendance log';
|
||||||
|
END IF;
|
||||||
|
IF v_log.check_out_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Already checked out';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE attendance_logs
|
||||||
|
SET check_out_at = now(),
|
||||||
|
check_out_lat = p_lat,
|
||||||
|
check_out_lng = p_lng,
|
||||||
|
justification = COALESCE(p_justification, justification)
|
||||||
|
WHERE id = p_attendance_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- Ensure there is only one overload of attendance_check_out.
|
||||||
|
--
|
||||||
|
-- Having both the 3-parameter and 4-parameter overloads causes PostgREST to
|
||||||
|
-- return "could not choose best candidate function" when calling the RPC with
|
||||||
|
-- only the required parameters.
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.attendance_check_out(uuid, double precision, double precision);
|
||||||
|
|
||||||
|
-- Ensure the 4-parameter version (with optional justification) exists.
|
||||||
|
CREATE OR REPLACE FUNCTION public.attendance_check_out(
|
||||||
|
p_attendance_id uuid,
|
||||||
|
p_lat double precision,
|
||||||
|
p_lng double precision,
|
||||||
|
p_justification text DEFAULT NULL
|
||||||
|
) RETURNS void
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log attendance_logs%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Attendance log not found';
|
||||||
|
END IF;
|
||||||
|
IF v_log.user_id != auth.uid() THEN
|
||||||
|
RAISE EXCEPTION 'Not your attendance log';
|
||||||
|
END IF;
|
||||||
|
IF v_log.check_out_at IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION 'Already checked out';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE attendance_logs
|
||||||
|
SET check_out_at = now(),
|
||||||
|
check_out_lat = p_lat,
|
||||||
|
check_out_lng = p_lng,
|
||||||
|
check_out_justification = p_justification
|
||||||
|
WHERE id = p_attendance_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
Loading…
Reference in New Issue
Block a user