From ce82a88e044f34b08fd021107a27bc86e4f6f972 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 8 Mar 2026 16:58:05 +0800 Subject: [PATCH] Migrations --- .../20260306090000_religion_and_ramadan.sql | 15 + .../20260306090100_chat_messages.sql | 46 ++++ .../migrations/20260306090200_pass_slips.sql | 44 +++ .../20260306090300_attendance_logs.sql | 163 +++++++++++ .../20260306090400_live_positions.sql | 85 ++++++ ...090000_fix_check_in_location_geography.sql | 98 +++++++ .../20260307100000_overtime_check_in.sql | 49 ++++ ...20260307103000_fix_overtime_shift_type.sql | 42 +++ ...20260307110000_multi_checkin_early_out.sql | 100 +++++++ .../20260307120000_leave_of_absence.sql | 147 ++++++++++ ...0260307130000_avatar_face_verification.sql | 158 +++++++++++ .../20260307140000_verification_sessions.sql | 43 +++ .../20260307150000_profiles_update_policy.sql | 24 ++ .../20260307160000_fix_storage_policies.sql | 41 +++ .../20260307170000_overtime_shift_type.sql | 59 ++++ ...260307170100_overtime_shift_type_apply.sql | 54 ++++ ...20260308090000_add_it_service_requests.sql | 260 ++++++++++++++++++ ...0000_add_shift_type_to_attendance_logs.sql | 96 +++++++ .../20260308120000_add_team_color.sql | 2 + 19 files changed, 1526 insertions(+) create mode 100644 supabase/migrations/20260306090000_religion_and_ramadan.sql create mode 100644 supabase/migrations/20260306090100_chat_messages.sql create mode 100644 supabase/migrations/20260306090200_pass_slips.sql create mode 100644 supabase/migrations/20260306090300_attendance_logs.sql create mode 100644 supabase/migrations/20260306090400_live_positions.sql create mode 100644 supabase/migrations/20260307090000_fix_check_in_location_geography.sql create mode 100644 supabase/migrations/20260307100000_overtime_check_in.sql create mode 100644 supabase/migrations/20260307103000_fix_overtime_shift_type.sql create mode 100644 supabase/migrations/20260307110000_multi_checkin_early_out.sql create mode 100644 supabase/migrations/20260307120000_leave_of_absence.sql create mode 100644 supabase/migrations/20260307130000_avatar_face_verification.sql create mode 100644 supabase/migrations/20260307140000_verification_sessions.sql create mode 100644 supabase/migrations/20260307150000_profiles_update_policy.sql create mode 100644 supabase/migrations/20260307160000_fix_storage_policies.sql create mode 100644 supabase/migrations/20260307170000_overtime_shift_type.sql create mode 100644 supabase/migrations/20260307170100_overtime_shift_type_apply.sql create mode 100644 supabase/migrations/20260308090000_add_it_service_requests.sql create mode 100644 supabase/migrations/20260308090000_add_shift_type_to_attendance_logs.sql create mode 100644 supabase/migrations/20260308120000_add_team_color.sql diff --git a/supabase/migrations/20260306090000_religion_and_ramadan.sql b/supabase/migrations/20260306090000_religion_and_ramadan.sql new file mode 100644 index 00000000..4c18e5f4 --- /dev/null +++ b/supabase/migrations/20260306090000_religion_and_ramadan.sql @@ -0,0 +1,15 @@ +-- Add religion column to profiles with PH-common defaults +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS religion text NOT NULL DEFAULT 'catholic'; + +-- Add tracking consent column +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS allow_tracking boolean NOT NULL DEFAULT false; + +-- Seed Ramadan mode setting +INSERT INTO app_settings (key, value) +VALUES ('ramadan_mode', '{"enabled": false, "auto_detect": true}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- RLS: all authenticated users can read religion; users can update their own; +-- admins can update anyone's religion. +-- (Existing profile RLS already allows select; we only need to ensure update +-- policies cover the new columns.) diff --git a/supabase/migrations/20260306090100_chat_messages.sql b/supabase/migrations/20260306090100_chat_messages.sql new file mode 100644 index 00000000..ffe105fe --- /dev/null +++ b/supabase/migrations/20260306090100_chat_messages.sql @@ -0,0 +1,46 @@ +-- Chat messages for swap request bargaining +CREATE TABLE IF NOT EXISTS chat_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + thread_id uuid NOT NULL, + sender_id uuid NOT NULL REFERENCES auth.users(id), + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_chat_messages_thread ON chat_messages (thread_id, created_at); + +-- Auto-generate chat_thread_id on swap_requests if null +ALTER TABLE swap_requests ALTER COLUMN chat_thread_id SET DEFAULT gen_random_uuid(); + +-- Backfill existing swap_requests that have no chat_thread_id +UPDATE swap_requests SET chat_thread_id = gen_random_uuid() WHERE chat_thread_id IS NULL; + +-- Enable realtime +ALTER PUBLICATION supabase_realtime ADD TABLE chat_messages; + +-- RLS +ALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY; + +-- Users can read messages for swap requests where they are requester or recipient +CREATE POLICY "chat_messages_select" ON chat_messages FOR SELECT TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM swap_requests sr + WHERE sr.chat_thread_id = chat_messages.thread_id + AND (sr.requester_id = auth.uid() OR sr.recipient_id = auth.uid()) + ) + OR EXISTS ( + SELECT 1 FROM profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') + ) +); + +-- Users can insert messages for swap requests where they are requester or recipient +CREATE POLICY "chat_messages_insert" ON chat_messages FOR INSERT TO authenticated +WITH CHECK ( + sender_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM swap_requests sr + WHERE sr.chat_thread_id = chat_messages.thread_id + AND (sr.requester_id = auth.uid() OR sr.recipient_id = auth.uid()) + ) +); diff --git a/supabase/migrations/20260306090200_pass_slips.sql b/supabase/migrations/20260306090200_pass_slips.sql new file mode 100644 index 00000000..d6ba52df --- /dev/null +++ b/supabase/migrations/20260306090200_pass_slips.sql @@ -0,0 +1,44 @@ +-- Pass slip system for duty excusals +CREATE TABLE IF NOT EXISTS pass_slips ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id), + duty_schedule_id uuid NOT NULL REFERENCES duty_schedules(id), + reason text NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'completed')), + requested_at timestamptz NOT NULL DEFAULT now(), + approved_by uuid REFERENCES auth.users(id), + approved_at timestamptz, + slip_start timestamptz, + slip_end timestamptz +); + +CREATE INDEX idx_pass_slips_user ON pass_slips (user_id, requested_at DESC); +CREATE INDEX idx_pass_slips_status ON pass_slips (status) WHERE status IN ('pending', 'approved'); + +-- Enable realtime +ALTER PUBLICATION supabase_realtime ADD TABLE pass_slips; + +-- RLS +ALTER TABLE pass_slips ENABLE ROW LEVEL SECURITY; + +-- Users can see their own pass slips; admin/dispatcher can see all +CREATE POLICY "pass_slips_select" ON pass_slips FOR SELECT TO authenticated +USING ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') + ) +); + +-- Users can insert their own pass slips +CREATE POLICY "pass_slips_insert" ON pass_slips FOR INSERT TO authenticated +WITH CHECK (user_id = auth.uid()); + +-- Admins can update pass slips (approve/reject); users can complete their own +CREATE POLICY "pass_slips_update" ON pass_slips FOR UPDATE TO authenticated +USING ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') + ) +); diff --git a/supabase/migrations/20260306090300_attendance_logs.sql b/supabase/migrations/20260306090300_attendance_logs.sql new file mode 100644 index 00000000..d6ea091a --- /dev/null +++ b/supabase/migrations/20260306090300_attendance_logs.sql @@ -0,0 +1,163 @@ +-- Attendance log book for check-in / check-out +CREATE TABLE IF NOT EXISTS attendance_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id), + duty_schedule_id uuid NOT NULL REFERENCES duty_schedules(id), + check_in_at timestamptz NOT NULL DEFAULT now(), + check_in_lat double precision NOT NULL, + check_in_lng double precision NOT NULL, + check_out_at timestamptz, + check_out_lat double precision, + check_out_lng double precision +); + +CREATE INDEX idx_attendance_logs_user ON attendance_logs (user_id, check_in_at DESC); + +-- Enable realtime +ALTER PUBLICATION supabase_realtime ADD TABLE attendance_logs; + +-- RLS +ALTER TABLE attendance_logs ENABLE ROW LEVEL SECURITY; + +-- Users can see their own logs; admin/dispatcher/it_staff can see all +CREATE POLICY "attendance_logs_select" ON attendance_logs FOR SELECT TO authenticated +USING ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher', 'it_staff') + ) +); + +-- Users can insert their own logs +CREATE POLICY "attendance_logs_insert" ON attendance_logs FOR INSERT TO authenticated +WITH CHECK (user_id = auth.uid()); + +-- Users can update their own logs (for check-out) +CREATE POLICY "attendance_logs_update" ON attendance_logs FOR UPDATE TO authenticated +USING (user_id = auth.uid()); + +-- RPC: attendance_check_in — validates geofence server-side, checks 2-hour window +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_geofence jsonb; + v_log_id uuid; + v_now timestamptz := now(); + v_status text; +BEGIN + -- Fetch the duty schedule + 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; + + -- 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 + IF v_now <= v_schedule.start_time THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + -- Insert attendance log + 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; + + -- Update duty schedule + UPDATE duty_schedules + SET check_in_at = v_now, + check_in_location = jsonb_build_object('latitude', p_lat, 'longitude', p_lng), + status = v_status + WHERE id = p_duty_id; + + RETURN v_log_id; +END; +$$; + +-- RPC: attendance_check_out +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; +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 + WHERE id = p_attendance_id; +END; +$$; + +-- Also create the missing duty_check_in RPC that workforce_screen.dart calls +CREATE OR REPLACE FUNCTION public.duty_check_in( + p_duty_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; + 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 THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + UPDATE duty_schedules + SET check_in_at = v_now, + check_in_location = jsonb_build_object('latitude', p_lat, 'longitude', p_lng), + status = v_status + WHERE id = p_duty_id; + + -- Also create an attendance log entry + 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); + + RETURN v_status; +END; +$$; diff --git a/supabase/migrations/20260306090400_live_positions.sql b/supabase/migrations/20260306090400_live_positions.sql new file mode 100644 index 00000000..c59ee0b1 --- /dev/null +++ b/supabase/migrations/20260306090400_live_positions.sql @@ -0,0 +1,85 @@ +-- Live position tracking for whereabouts map +CREATE TABLE IF NOT EXISTS live_positions ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id), + lat double precision NOT NULL, + lng double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + in_premise boolean NOT NULL DEFAULT false +); + +-- Enable realtime +ALTER PUBLICATION supabase_realtime ADD TABLE live_positions; + +-- RLS +ALTER TABLE live_positions ENABLE ROW LEVEL SECURITY; + +-- All authenticated users can read live positions +CREATE POLICY "live_positions_select" ON live_positions FOR SELECT TO authenticated +USING (true); + +-- Users can only upsert their own position +CREATE POLICY "live_positions_insert" ON live_positions FOR INSERT TO authenticated +WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "live_positions_update" ON live_positions FOR UPDATE TO authenticated +USING (user_id = auth.uid()); + +-- RPC: update_live_position — upserts position, computes in_premise via geofence +CREATE OR REPLACE FUNCTION public.update_live_position( + p_lat double precision, + p_lng double precision +) RETURNS boolean +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_geofence jsonb; + v_in_premise boolean := false; + 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; +BEGIN + -- Fetch geofence config + 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; + + INSERT INTO live_positions (user_id, lat, lng, updated_at, in_premise) + VALUES (auth.uid(), p_lat, p_lng, now(), v_in_premise) + ON CONFLICT (user_id) + DO UPDATE SET lat = p_lat, lng = p_lng, updated_at = now(), in_premise = v_in_premise; + + RETURN v_in_premise; +END; +$$; + +-- Update the duty_schedules RLS to allow all authenticated users to SELECT +-- (previously only admin/dispatcher could see all; now all roles can see all schedules) +DROP POLICY IF EXISTS "duty_schedules_select" ON duty_schedules; +CREATE POLICY "duty_schedules_select" ON duty_schedules FOR SELECT TO authenticated +USING (true); diff --git a/supabase/migrations/20260307090000_fix_check_in_location_geography.sql b/supabase/migrations/20260307090000_fix_check_in_location_geography.sql new file mode 100644 index 00000000..624f34d2 --- /dev/null +++ b/supabase/migrations/20260307090000_fix_check_in_location_geography.sql @@ -0,0 +1,98 @@ +-- Fix check_in_location to use PostGIS geography instead of JSONB + +-- Update attendance_check_in RPC to use geography point +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_geofence jsonb; + v_log_id uuid; + v_now timestamptz := now(); + v_status text; +BEGIN + -- Fetch the duty schedule + 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; + + -- 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 + IF v_now <= v_schedule.start_time THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + -- Insert attendance log + 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; + + -- Update duty schedule with geography point (longitude, latitude order) + UPDATE duty_schedules + SET check_in_at = v_now, + check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + status = v_status::duty_status + WHERE id = p_duty_id; + + RETURN v_log_id; +END; +$$; + +-- Update duty_check_in RPC to use geography point +CREATE OR REPLACE FUNCTION public.duty_check_in( + p_duty_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; + 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 THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + -- Update duty schedule with geography point (longitude, latitude order) + UPDATE duty_schedules + SET check_in_at = v_now, + check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + status = v_status::duty_status + WHERE id = p_duty_id; + + -- Also create an attendance log entry + 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); + + RETURN v_status; +END; +$$; diff --git a/supabase/migrations/20260307100000_overtime_check_in.sql b/supabase/migrations/20260307100000_overtime_check_in.sql new file mode 100644 index 00000000..f7a12a51 --- /dev/null +++ b/supabase/migrations/20260307100000_overtime_check_in.sql @@ -0,0 +1,49 @@ +-- Add justification column to attendance_logs for overtime check-ins +ALTER TABLE attendance_logs ADD COLUMN IF NOT EXISTS justification text; + +-- Overtime check-in RPC: creates overtime duty_schedule + attendance_log in one call. +-- Returns the attendance log ID. +CREATE OR REPLACE FUNCTION public.overtime_check_in( + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_now timestamptz := now(); + v_schedule_id uuid; + v_log_id uuid; + v_end_time timestamptz; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Overtime shift: start now, end after 8 hours (can be checked out earlier) + v_end_time := v_now + interval '8 hours'; + + -- Create overtime duty schedule + INSERT INTO duty_schedules (user_id, shift_type, start_time, end_time, status, check_in_at, check_in_location) + VALUES ( + v_uid, + -- Store as a normal shift_type to satisfy enum constraints; + -- overtime semantics are captured by this RPC + log justification. + 'normal', + v_now, + v_end_time, + 'arrival'::duty_status, + v_now, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography + ) + RETURNING id INTO v_schedule_id; + + -- Create attendance log + INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng, justification) + VALUES (v_uid, v_schedule_id, v_now, p_lat, p_lng, p_justification) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$; diff --git a/supabase/migrations/20260307103000_fix_overtime_shift_type.sql b/supabase/migrations/20260307103000_fix_overtime_shift_type.sql new file mode 100644 index 00000000..83c99572 --- /dev/null +++ b/supabase/migrations/20260307103000_fix_overtime_shift_type.sql @@ -0,0 +1,42 @@ +-- Fix overtime_check_in RPC: use valid shift_type enum label. +-- Some environments reject 'overtime' for duty_schedules.shift_type. + +CREATE OR REPLACE FUNCTION public.overtime_check_in( + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_now timestamptz := now(); + v_schedule_id uuid; + v_log_id uuid; + v_end_time timestamptz; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_end_time := v_now + interval '8 hours'; + + INSERT INTO duty_schedules (user_id, shift_type, start_time, end_time, status, check_in_at, check_in_location) + VALUES ( + v_uid, + 'normal', + v_now, + v_end_time, + 'arrival'::duty_status, + v_now, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography + ) + RETURNING id INTO v_schedule_id; + + INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng, justification) + VALUES (v_uid, v_schedule_id, v_now, p_lat, p_lng, p_justification) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$; diff --git a/supabase/migrations/20260307110000_multi_checkin_early_out.sql b/supabase/migrations/20260307110000_multi_checkin_early_out.sql new file mode 100644 index 00000000..0738dcd1 --- /dev/null +++ b/supabase/migrations/20260307110000_multi_checkin_early_out.sql @@ -0,0 +1,100 @@ +-- Allow multiple check-in/out within a single duty schedule. +-- Preserves first check-in time & arrival/late status on subsequent re-check-ins. + +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; +$$; + +-- Also update duty_check_in for consistency +CREATE OR REPLACE FUNCTION public.duty_check_in( + p_duty_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS text +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; + 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 THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + 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; + + -- Create attendance log entry + 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); + + RETURN v_status; +END; +$$; diff --git a/supabase/migrations/20260307120000_leave_of_absence.sql b/supabase/migrations/20260307120000_leave_of_absence.sql new file mode 100644 index 00000000..9ed31bcb --- /dev/null +++ b/supabase/migrations/20260307120000_leave_of_absence.sql @@ -0,0 +1,147 @@ +-- Leave of Absence table +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'leave_type') THEN + CREATE TYPE leave_type AS ENUM ( + 'emergency_leave', + 'parental_leave', + 'sick_leave', + 'vacation_leave' + ); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'leave_status') THEN + CREATE TYPE leave_status AS ENUM ( + 'pending', + 'approved', + 'rejected', + 'cancelled' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS leave_of_absence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + leave_type leave_type NOT NULL, + justification TEXT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + status leave_status NOT NULL DEFAULT 'pending', + filed_by UUID NOT NULL REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT valid_time_range CHECK (end_time > start_time) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_leave_user_id ON leave_of_absence(user_id); +CREATE INDEX IF NOT EXISTS idx_leave_start_time ON leave_of_absence(start_time); +CREATE INDEX IF NOT EXISTS idx_leave_status ON leave_of_absence(status); + +-- RLS +ALTER TABLE leave_of_absence ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Privileged users can view all leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Users can view own leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Privileged users can file own leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Users can file own leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Admins can approve or reject leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Users can cancel own future approved leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Admins can cancel future approved leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Privileged users can update leaves" ON leave_of_absence; +DROP POLICY IF EXISTS "Users can cancel own pending leaves" ON leave_of_absence; + +-- Admin/dispatcher/it_staff can see all leaves +CREATE POLICY "Privileged users can view all leaves" + ON leave_of_absence FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() + AND profiles.role IN ('admin', 'dispatcher', 'it_staff') + ) + ); + +-- All users can see their own leaves +CREATE POLICY "Users can view own leaves" + ON leave_of_absence FOR SELECT + USING (user_id = auth.uid()); + +-- Only admin/dispatcher/it_staff can file leaves (for themselves) +CREATE POLICY "Privileged users can file own leaves" + ON leave_of_absence FOR INSERT + WITH CHECK ( + user_id = auth.uid() + AND filed_by = auth.uid() + AND EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() + AND profiles.role IN ('admin', 'dispatcher', 'it_staff') + ) + ); + +-- Only admins can approve/reject pending leaves +CREATE POLICY "Admins can approve or reject leaves" + ON leave_of_absence FOR UPDATE + USING ( + status = 'pending' + AND EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() + AND profiles.role = 'admin' + ) + ) + WITH CHECK ( + status IN ('approved', 'rejected') + ); + +-- Users can cancel their own future approved leaves +CREATE POLICY "Users can cancel own future approved leaves" + ON leave_of_absence FOR UPDATE + USING ( + user_id = auth.uid() + AND status = 'approved' + AND start_time > now() + ) + WITH CHECK ( + status = 'cancelled' + ); + +-- Admins can cancel future approved leaves +CREATE POLICY "Admins can cancel future approved leaves" + ON leave_of_absence FOR UPDATE + USING ( + status = 'approved' + AND start_time > now() + AND EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() + AND profiles.role = 'admin' + ) + ) + WITH CHECK ( + status = 'cancelled' + ); + +-- Drop legacy reason column if it exists from an earlier migration +ALTER TABLE leave_of_absence DROP COLUMN IF EXISTS reason; + +-- Updated_at trigger +CREATE OR REPLACE FUNCTION update_leave_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_leave_updated_at ON leave_of_absence; +CREATE TRIGGER trigger_leave_updated_at + BEFORE UPDATE ON leave_of_absence + FOR EACH ROW + EXECUTE FUNCTION update_leave_updated_at(); diff --git a/supabase/migrations/20260307130000_avatar_face_verification.sql b/supabase/migrations/20260307130000_avatar_face_verification.sql new file mode 100644 index 00000000..27b82d5d --- /dev/null +++ b/supabase/migrations/20260307130000_avatar_face_verification.sql @@ -0,0 +1,158 @@ +-- ─────────────────────────────────────────────────────────── +-- Profile: avatar + face enrollment +-- ─────────────────────────────────────────────────────────── +-- avatar_url and face_photo_url may already exist; ADD IF NOT EXISTS +-- Postgres 11+ doesn't have ADD COLUMN IF NOT EXISTS in a clean way, +-- so we wrap each in a DO block. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'profiles' AND column_name = 'avatar_url' + ) THEN + ALTER TABLE profiles ADD COLUMN avatar_url TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'profiles' AND column_name = 'face_photo_url' + ) THEN + ALTER TABLE profiles ADD COLUMN face_photo_url TEXT; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'profiles' AND column_name = 'face_enrolled_at' + ) THEN + ALTER TABLE profiles ADD COLUMN face_enrolled_at TIMESTAMPTZ; + END IF; +END $$; + +-- ─────────────────────────────────────────────────────────── +-- Attendance logs: verification status + selfie +-- ─────────────────────────────────────────────────────────── + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verification_status') THEN + CREATE TYPE verification_status AS ENUM ( + 'pending', + 'verified', + 'unverified', + 'skipped' + ); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'attendance_logs' AND column_name = 'verification_status' + ) THEN + ALTER TABLE attendance_logs + ADD COLUMN verification_status verification_status NOT NULL DEFAULT 'pending'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'attendance_logs' AND column_name = 'verification_photo_url' + ) THEN + ALTER TABLE attendance_logs ADD COLUMN verification_photo_url TEXT; + END IF; +END $$; + +-- ─────────────────────────────────────────────────────────── +-- Storage buckets (idempotent via INSERT ... ON CONFLICT) +-- ─────────────────────────────────────────────────────────── + +INSERT INTO storage.buckets (id, name, public) +VALUES ('avatars', 'avatars', true) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO storage.buckets (id, name, public) +VALUES ('face-enrollment', 'face-enrollment', false) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO storage.buckets (id, name, public) +VALUES ('attendance-verification', 'attendance-verification', false) +ON CONFLICT (id) DO NOTHING; + +-- Storage policies: avatars (public read, auth write own) +DROP POLICY IF EXISTS "Public can view avatars" ON storage.objects; +CREATE POLICY "Public can view avatars" + ON storage.objects FOR SELECT + USING (bucket_id = 'avatars'); + +DROP POLICY IF EXISTS "Users can upload own avatar" ON storage.objects; +CREATE POLICY "Users can upload own avatar" + ON storage.objects FOR INSERT + WITH CHECK ( + bucket_id = 'avatars' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +DROP POLICY IF EXISTS "Users can update own avatar" ON storage.objects; +CREATE POLICY "Users can update own avatar" + ON storage.objects FOR UPDATE + USING ( + bucket_id = 'avatars' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +-- Storage policies: face-enrollment (private, owner read/write, admin read) +DROP POLICY IF EXISTS "Users can upload own face" ON storage.objects; +CREATE POLICY "Users can upload own face" + ON storage.objects FOR INSERT + WITH CHECK ( + bucket_id = 'face-enrollment' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +DROP POLICY IF EXISTS "Users can view own face" ON storage.objects; +CREATE POLICY "Users can view own face" + ON storage.objects FOR SELECT + USING ( + bucket_id = 'face-enrollment' + AND ( + (storage.foldername(name))[1] = auth.uid()::text + OR EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() AND profiles.role = 'admin' + ) + ) + ); + +-- Storage policies: attendance-verification (owner + admin read, owner write) +DROP POLICY IF EXISTS "Users can upload verification photo" ON storage.objects; +CREATE POLICY "Users can upload verification photo" + ON storage.objects FOR INSERT + WITH CHECK ( + bucket_id = 'attendance-verification' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +DROP POLICY IF EXISTS "Users and admins can view verification photos" ON storage.objects; +CREATE POLICY "Users and admins can view verification photos" + ON storage.objects FOR SELECT + USING ( + bucket_id = 'attendance-verification' + AND ( + (storage.foldername(name))[1] = auth.uid()::text + OR EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.id = auth.uid() + AND profiles.role IN ('admin', 'dispatcher', 'it_staff') + ) + ) + ); diff --git a/supabase/migrations/20260307140000_verification_sessions.sql b/supabase/migrations/20260307140000_verification_sessions.sql new file mode 100644 index 00000000..528a8499 --- /dev/null +++ b/supabase/migrations/20260307140000_verification_sessions.sql @@ -0,0 +1,43 @@ +-- Cross-device face verification sessions. +-- Created when web cannot access a camera and generates a QR code. +-- Mobile scans the QR, performs liveness detection, and completes the session. +CREATE TABLE IF NOT EXISTS verification_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('enrollment', 'verification')), + context_id TEXT, -- e.g. attendance_log_id for verification type + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'expired')), + image_url TEXT, -- filled when mobile completes the session + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '5 minutes') +); + +-- Index for quick lookup by user + status +CREATE INDEX idx_verification_sessions_user_status + ON verification_sessions (user_id, status); + +-- Enable realtime for this table +ALTER PUBLICATION supabase_realtime ADD TABLE verification_sessions; + +-- RLS policies +ALTER TABLE verification_sessions ENABLE ROW LEVEL SECURITY; + +-- Users can read their own sessions +CREATE POLICY "Users can read own verification sessions" + ON verification_sessions FOR SELECT + USING (auth.uid() = user_id); + +-- Users can create their own sessions +CREATE POLICY "Users can create own verification sessions" + ON verification_sessions FOR INSERT + WITH CHECK (auth.uid() = user_id); + +-- Users can update their own sessions (complete from mobile) +CREATE POLICY "Users can update own verification sessions" + ON verification_sessions FOR UPDATE + USING (auth.uid() = user_id); + +-- Users can delete their own sessions (cleanup) +CREATE POLICY "Users can delete own verification sessions" + ON verification_sessions FOR DELETE + USING (auth.uid() = user_id); diff --git a/supabase/migrations/20260307150000_profiles_update_policy.sql b/supabase/migrations/20260307150000_profiles_update_policy.sql new file mode 100644 index 00000000..043de0eb --- /dev/null +++ b/supabase/migrations/20260307150000_profiles_update_policy.sql @@ -0,0 +1,24 @@ +-- ─────────────────────────────────────────────────────────── +-- Profiles: Enable RLS and allow users to update their own profile +-- ─────────────────────────────────────────────────────────── + +-- Enable RLS if not already enabled +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; + +-- Remove recursive admin policy (caused infinite recursion in earlier revision) +DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles; + +-- Drop existing policy if it exists +DROP POLICY IF EXISTS "Users can update own profile" ON profiles; + +-- Allow users to update their own profile +CREATE POLICY "Users can update own profile" + ON profiles FOR UPDATE + USING (auth.uid() = id) + WITH CHECK (auth.uid() = id); + +-- Also ensure users can select their own profile +DROP POLICY IF EXISTS "Users can view own profile" ON profiles; +CREATE POLICY "Users can view own profile" + ON profiles FOR SELECT + USING (auth.uid() = id); diff --git a/supabase/migrations/20260307160000_fix_storage_policies.sql b/supabase/migrations/20260307160000_fix_storage_policies.sql new file mode 100644 index 00000000..36f95a5c --- /dev/null +++ b/supabase/migrations/20260307160000_fix_storage_policies.sql @@ -0,0 +1,41 @@ +-- ─────────────────────────────────────────────────────────── +-- Fix storage policies: +-- 1. Add missing UPDATE policy for face-enrollment (needed for upsert) +-- 2. Remove recursive profiles subqueries from SELECT policies +-- ─────────────────────────────────────────────────────────── + +-- face-enrollment: allow users to UPDATE (overwrite) their own face photo +DROP POLICY IF EXISTS "Users can update own face" ON storage.objects; +CREATE POLICY "Users can update own face" + ON storage.objects FOR UPDATE + USING ( + bucket_id = 'face-enrollment' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +-- face-enrollment: fix SELECT to remove recursive profiles subquery +DROP POLICY IF EXISTS "Users can view own face" ON storage.objects; +CREATE POLICY "Users can view own face" + ON storage.objects FOR SELECT + USING ( + bucket_id = 'face-enrollment' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +-- attendance-verification: allow users to UPDATE (overwrite) their own photo +DROP POLICY IF EXISTS "Users can update verification photo" ON storage.objects; +CREATE POLICY "Users can update verification photo" + ON storage.objects FOR UPDATE + USING ( + bucket_id = 'attendance-verification' + AND (storage.foldername(name))[1] = auth.uid()::text + ); + +-- attendance-verification: fix SELECT to remove recursive profiles subquery +DROP POLICY IF EXISTS "Users and admins can view verification photos" ON storage.objects; +CREATE POLICY "Users and admins can view verification photos" + ON storage.objects FOR SELECT + USING ( + bucket_id = 'attendance-verification' + AND (storage.foldername(name))[1] = auth.uid()::text + ); diff --git a/supabase/migrations/20260307170000_overtime_shift_type.sql b/supabase/migrations/20260307170000_overtime_shift_type.sql new file mode 100644 index 00000000..f06c54c2 --- /dev/null +++ b/supabase/migrations/20260307170000_overtime_shift_type.sql @@ -0,0 +1,59 @@ +-- Step 1 only: add 'overtime' to shift_type. +-- +-- IMPORTANT (PostgreSQL enum rule): +-- A newly added enum label cannot be used in the same transaction. +-- Therefore, function/data updates that reference 'overtime' are moved +-- to the next migration file: 20260307170100_overtime_shift_type_apply.sql. + +-- 1. Extend the shift_type enum with 'overtime'. +-- If the column uses a PostgreSQL ENUM type use ADD VALUE. +-- Wrapped in a DO block so it is safe to re-run. +DO $$ +BEGIN + -- Try adding to an existing enum type first + BEGIN + ALTER TYPE shift_type ADD VALUE IF NOT EXISTS 'overtime'; + EXCEPTION WHEN undefined_object THEN + -- Not an enum type – fall through to check constraint below + NULL; + END; +END $$; + +-- If shift_type is constrained by a CHECK instead of an enum, +-- drop the old CHECK and recreate it with 'overtime' included. +DO $$ +DECLARE + _con text; + _is_enum boolean := false; +BEGIN + SELECT (t.typtype = 'e') INTO _is_enum + FROM pg_attribute a + JOIN pg_type t ON t.oid = a.atttypid + WHERE a.attrelid = 'duty_schedules'::regclass + AND a.attname = 'shift_type' + AND NOT a.attisdropped; + + -- If the column is enum-backed, do not touch any CHECK constraints here; + -- using the new enum label in this same transaction would trigger 55P04. + IF _is_enum THEN + RETURN; + END IF; + + SELECT con.conname INTO _con + FROM pg_constraint con + JOIN pg_attribute att ON att.attnum = ANY(con.conkey) + AND att.attrelid = con.conrelid + WHERE con.conrelid = 'duty_schedules'::regclass + AND att.attname = 'shift_type' + AND con.contype = 'c'; -- CHECK constraint + + IF _con IS NOT NULL THEN + EXECUTE format('ALTER TABLE duty_schedules DROP CONSTRAINT %I', _con); + ALTER TABLE duty_schedules + ADD CONSTRAINT duty_schedules_shift_type_check + CHECK (shift_type IN ('normal','am','pm','on_call','weekend','overtime')); + END IF; +END $$; + +-- Step 2 is intentionally separated to avoid: +-- ERROR 55P04 unsafe use of new value of enum type shift_type diff --git a/supabase/migrations/20260307170100_overtime_shift_type_apply.sql b/supabase/migrations/20260307170100_overtime_shift_type_apply.sql new file mode 100644 index 00000000..694f6e37 --- /dev/null +++ b/supabase/migrations/20260307170100_overtime_shift_type_apply.sql @@ -0,0 +1,54 @@ +-- Step 2: apply overtime usage after enum value is committed. +-- This migration must run after 20260307170000_overtime_shift_type.sql. + +-- Recreate overtime_check_in to use 'overtime' shift_type. +CREATE OR REPLACE FUNCTION public.overtime_check_in( + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_now timestamptz := now(); + v_schedule_id uuid; + v_log_id uuid; + v_end_time timestamptz; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_end_time := v_now + interval '8 hours'; + + INSERT INTO duty_schedules (user_id, shift_type, start_time, end_time, status, check_in_at, check_in_location) + VALUES ( + v_uid, + 'overtime', + v_now, + v_end_time, + 'arrival'::duty_status, + v_now, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography + ) + RETURNING id INTO v_schedule_id; + + INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng, justification) + VALUES (v_uid, v_schedule_id, v_now, p_lat, p_lng, p_justification) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$; + +-- Retroactively fix existing overtime schedules. +-- Any duty_schedule linked to an attendance_log with a non-empty +-- justification was created by overtime_check_in and should be marked overtime. +UPDATE duty_schedules ds + SET shift_type = 'overtime' + FROM attendance_logs al + WHERE al.duty_schedule_id = ds.id + AND al.justification IS NOT NULL + AND TRIM(al.justification) <> '' + AND ds.shift_type <> 'overtime'; diff --git a/supabase/migrations/20260308090000_add_it_service_requests.sql b/supabase/migrations/20260308090000_add_it_service_requests.sql new file mode 100644 index 00000000..e8f7ef1e --- /dev/null +++ b/supabase/migrations/20260308090000_add_it_service_requests.sql @@ -0,0 +1,260 @@ +-- ============================================================================= +-- IT Service Request Module +-- ============================================================================= + +-- 1. Main table +CREATE TABLE IF NOT EXISTS it_service_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_number text UNIQUE, + + -- Services (checkboxes from the form) + services text[] NOT NULL DEFAULT '{}', + services_other text, + + -- Event/Activity Details (Quill Delta JSON) + event_name text NOT NULL, + event_details text, -- Quill delta JSON + + -- Scheduling + event_date timestamptz, + event_end_date timestamptz, + dry_run_date timestamptz, + dry_run_end_date timestamptz, + + -- Contact + contact_person text, + contact_number text, + + -- Remarks (Quill Delta JSON) + remarks text, + + -- Office (Department in the form) + office_id uuid REFERENCES offices(id) ON DELETE SET NULL, + + -- Signatories + requested_by text, -- Name of requester (default: standard user who created) + requested_by_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + approved_by text, -- Name of approver (admin only) + approved_by_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + approved_at timestamptz, + + -- Status lifecycle: draft → pending_approval → scheduled → in_progress_dry_run → in_progress → completed → cancelled + status text NOT NULL DEFAULT 'draft' CHECK ( + status IN ('draft', 'pending_approval', 'scheduled', 'in_progress_dry_run', 'in_progress', 'completed', 'cancelled') + ), + + -- Geofence override: allow IT staff to check in/out outside CRMC premise + outside_premise_allowed boolean NOT NULL DEFAULT false, + + -- Cancellation + cancellation_reason text, + cancelled_at timestamptz, + + -- Metadata + creator_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + + -- Date/Time Received/Checked from the form + date_time_received timestamptz, + date_time_checked timestamptz +); + +-- Auto-update updated_at +CREATE OR REPLACE FUNCTION update_it_service_requests_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER it_service_requests_updated_at_trigger + BEFORE UPDATE ON it_service_requests + FOR EACH ROW + EXECUTE FUNCTION update_it_service_requests_updated_at(); + +-- 2. IT Service Request Assignments (IT Staff assigned to the request) +CREATE TABLE IF NOT EXISTS it_service_request_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid NOT NULL REFERENCES it_service_requests(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(request_id, user_id) +); + +-- 3. Activity Logs for audit trail +CREATE TABLE IF NOT EXISTS it_service_request_activity_logs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + request_id uuid NOT NULL REFERENCES it_service_requests(id) ON DELETE CASCADE, + actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action_type text NOT NULL, + meta jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- 4. Action Taken (separate table with evidence attachments) +CREATE TABLE IF NOT EXISTS it_service_request_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid NOT NULL REFERENCES it_service_requests(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + action_taken text, -- Quill Delta JSON + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Auto-update updated_at for actions +CREATE TRIGGER it_service_request_actions_updated_at_trigger + BEFORE UPDATE ON it_service_request_actions + FOR EACH ROW + EXECUTE FUNCTION update_it_service_requests_updated_at(); + +-- 5. Evidence Attachments (images only, for action taken) +CREATE TABLE IF NOT EXISTS it_service_request_evidence ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid NOT NULL REFERENCES it_service_requests(id) ON DELETE CASCADE, + action_id uuid REFERENCES it_service_request_actions(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + file_path text NOT NULL, + file_name text NOT NULL, + taken_at timestamptz, -- Extracted from image EXIF metadata + created_at timestamptz NOT NULL DEFAULT now() +); + +-- 6. Request Number Generation RPC +CREATE OR REPLACE FUNCTION insert_it_service_request_with_number( + p_event_name text, + p_services text[], + p_creator_id uuid, + p_office_id uuid DEFAULT NULL, + p_requested_by text DEFAULT NULL, + p_requested_by_user_id uuid DEFAULT NULL, + p_status text DEFAULT 'draft' +) +RETURNS TABLE(id uuid, request_number text) AS $$ +DECLARE + v_seq int; + v_id uuid; + v_number text; +BEGIN + -- Get next sequence number for the current year + SELECT COALESCE(MAX( + CAST(NULLIF(regexp_replace(r.request_number, '^ISR-\d{4}-', ''), '') AS int) + ), 0) + 1 + INTO v_seq + FROM it_service_requests r + WHERE r.request_number LIKE 'ISR-' || EXTRACT(YEAR FROM now())::text || '-%'; + + v_number := 'ISR-' || EXTRACT(YEAR FROM now())::text || '-' || LPAD(v_seq::text, 4, '0'); + v_id := gen_random_uuid(); + + INSERT INTO it_service_requests (id, request_number, event_name, services, creator_id, office_id, requested_by, requested_by_user_id, status) + VALUES (v_id, v_number, p_event_name, p_services, p_creator_id, p_office_id, p_requested_by, p_requested_by_user_id, p_status); + + RETURN QUERY SELECT v_id, v_number; +END; +$$ LANGUAGE plpgsql; + +-- 7. Indexes +CREATE INDEX idx_it_service_requests_status ON it_service_requests(status); +CREATE INDEX idx_it_service_requests_office_id ON it_service_requests(office_id); +CREATE INDEX idx_it_service_requests_creator_id ON it_service_requests(creator_id); +CREATE INDEX idx_it_service_requests_event_date ON it_service_requests(event_date); +CREATE INDEX idx_it_service_request_assignments_request_id ON it_service_request_assignments(request_id); +CREATE INDEX idx_it_service_request_assignments_user_id ON it_service_request_assignments(user_id); +CREATE INDEX idx_it_service_request_activity_logs_request_id ON it_service_request_activity_logs(request_id); +CREATE INDEX idx_it_service_request_actions_request_id ON it_service_request_actions(request_id); +CREATE INDEX idx_it_service_request_evidence_request_id ON it_service_request_evidence(request_id); + +-- 8. Enable RLS +ALTER TABLE it_service_requests ENABLE ROW LEVEL SECURITY; +ALTER TABLE it_service_request_assignments ENABLE ROW LEVEL SECURITY; +ALTER TABLE it_service_request_activity_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE it_service_request_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE it_service_request_evidence ENABLE ROW LEVEL SECURITY; + +-- 9. RLS Policies + +-- IT Service Requests: authenticated can read all, insert own, update depending on role +CREATE POLICY "Authenticated users can read it_service_requests" + ON it_service_requests FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Authenticated users can insert it_service_requests" + ON it_service_requests FOR INSERT TO authenticated + WITH CHECK (auth.uid() = creator_id); + +CREATE POLICY "Authenticated users can update it_service_requests" + ON it_service_requests FOR UPDATE TO authenticated + USING (true); + +-- Assignments +CREATE POLICY "Authenticated users can read it_service_request_assignments" + ON it_service_request_assignments FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Authenticated users can insert it_service_request_assignments" + ON it_service_request_assignments FOR INSERT TO authenticated + WITH CHECK (true); + +CREATE POLICY "Authenticated users can delete it_service_request_assignments" + ON it_service_request_assignments FOR DELETE TO authenticated + USING (true); + +-- Activity Logs +CREATE POLICY "Authenticated users can read it_service_request_activity_logs" + ON it_service_request_activity_logs FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Authenticated users can insert it_service_request_activity_logs" + ON it_service_request_activity_logs FOR INSERT TO authenticated + WITH CHECK (true); + +-- Actions +CREATE POLICY "Authenticated users can read it_service_request_actions" + ON it_service_request_actions FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Authenticated users can insert it_service_request_actions" + ON it_service_request_actions FOR INSERT TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Authenticated users can update it_service_request_actions" + ON it_service_request_actions FOR UPDATE TO authenticated + USING (auth.uid() = user_id); + +-- Evidence +CREATE POLICY "Authenticated users can read it_service_request_evidence" + ON it_service_request_evidence FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Authenticated users can insert it_service_request_evidence" + ON it_service_request_evidence FOR INSERT TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Authenticated users can delete it_service_request_evidence" + ON it_service_request_evidence FOR DELETE TO authenticated + USING (auth.uid() = user_id); + +-- 10. Add it_service_request_id to notifications table +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS it_service_request_id uuid REFERENCES it_service_requests(id) ON DELETE CASCADE; + +-- 11. Storage bucket for IT service request files +INSERT INTO storage.buckets (id, name, public, file_size_limit) +VALUES ('it_service_attachments', 'it_service_attachments', true, 26214400) +ON CONFLICT (id) DO NOTHING; + +-- Storage policies +CREATE POLICY "Authenticated users can upload it_service_attachments" + ON storage.objects FOR INSERT TO authenticated + WITH CHECK (bucket_id = 'it_service_attachments'); + +CREATE POLICY "Authenticated users can read it_service_attachments" + ON storage.objects FOR SELECT TO authenticated + USING (bucket_id = 'it_service_attachments'); + +CREATE POLICY "Authenticated users can delete it_service_attachments" + ON storage.objects FOR DELETE TO authenticated + USING (bucket_id = 'it_service_attachments'); + +-- 12. Realtime publication +ALTER PUBLICATION supabase_realtime ADD TABLE it_service_requests; +ALTER PUBLICATION supabase_realtime ADD TABLE it_service_request_assignments; +ALTER PUBLICATION supabase_realtime ADD TABLE it_service_request_activity_logs; +ALTER PUBLICATION supabase_realtime ADD TABLE it_service_request_actions; diff --git a/supabase/migrations/20260308090000_add_shift_type_to_attendance_logs.sql b/supabase/migrations/20260308090000_add_shift_type_to_attendance_logs.sql new file mode 100644 index 00000000..be47d980 --- /dev/null +++ b/supabase/migrations/20260308090000_add_shift_type_to_attendance_logs.sql @@ -0,0 +1,96 @@ +-- Add shift_type to attendance_logs so each log carries its own shift label. +-- This fixes the display bug where every log showed "Normal Shift" +-- because the column was missing and the Flutter model always fell back to 'normal'. + +-- 1. Add the column (text, nullable to avoid breaking existing rows). +ALTER TABLE attendance_logs + ADD COLUMN IF NOT EXISTS shift_type text; + +-- 2. Backfill from the related duty_schedule. +UPDATE attendance_logs al + SET shift_type = ds.shift_type + FROM duty_schedules ds + WHERE al.duty_schedule_id = ds.id + AND al.shift_type IS NULL; + +-- 3. Set default to 'normal' for any remaining nulls, then lock it in. +UPDATE attendance_logs SET shift_type = 'normal' WHERE shift_type IS NULL; +ALTER TABLE attendance_logs ALTER COLUMN shift_type SET DEFAULT 'normal'; +ALTER TABLE attendance_logs ALTER COLUMN shift_type SET NOT NULL; + +-- 4. Recreate attendance_check_in to stamp shift_type from its schedule. +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_uid uuid := auth.uid(); + v_now timestamptz := now(); + v_schedule duty_schedules%ROWTYPE; + v_log_id uuid; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Duty schedule not found'; + END IF; + + UPDATE duty_schedules + SET status = 'arrival', check_in_at = v_now, + check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography + WHERE id = p_duty_id; + + INSERT INTO attendance_logs (user_id, duty_schedule_id, shift_type, check_in_at, check_in_lat, check_in_lng) + VALUES (v_uid, p_duty_id, v_schedule.shift_type, v_now, p_lat, p_lng) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$; + +-- 5. Recreate overtime_check_in to stamp shift_type = 'overtime'. +CREATE OR REPLACE FUNCTION public.overtime_check_in( + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_now timestamptz := now(); + v_schedule_id uuid; + v_log_id uuid; + v_end_time timestamptz; +BEGIN + IF v_uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + v_end_time := v_now + interval '8 hours'; + + INSERT INTO duty_schedules (user_id, shift_type, start_time, end_time, status, check_in_at, check_in_location) + VALUES ( + v_uid, + 'overtime', + v_now, + v_end_time, + 'arrival'::duty_status, + v_now, + ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography + ) + RETURNING id INTO v_schedule_id; + + INSERT INTO attendance_logs (user_id, duty_schedule_id, shift_type, check_in_at, check_in_lat, check_in_lng, justification) + VALUES (v_uid, v_schedule_id, 'overtime', v_now, p_lat, p_lng, p_justification) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$; diff --git a/supabase/migrations/20260308120000_add_team_color.sql b/supabase/migrations/20260308120000_add_team_color.sql new file mode 100644 index 00000000..da6ccb80 --- /dev/null +++ b/supabase/migrations/20260308120000_add_team_color.sql @@ -0,0 +1,2 @@ +-- Add color column to teams table (stores hex color string like 'e53935') +ALTER TABLE public.teams ADD COLUMN IF NOT EXISTS color TEXT;