diff --git a/lib/app.dart b/lib/app.dart index 642a29f4..774cadf7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,7 @@ import 'models/profile.dart'; import 'providers/profile_provider.dart'; import 'services/background_location_service.dart'; import 'theme/app_theme.dart'; +import 'utils/snackbar.dart'; class TasqApp extends ConsumerWidget { const TasqApp({super.key}); @@ -36,6 +37,7 @@ class TasqApp extends ConsumerWidget { return MaterialApp.router( title: 'TasQ', routerConfig: router, + scaffoldMessengerKey: scaffoldMessengerKey, theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index fb083044..8e12f37e 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -31,6 +31,7 @@ final attendanceLogsProvider = StreamProvider>((ref) { final hasFullAccess = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; diff --git a/lib/providers/leave_provider.dart b/lib/providers/leave_provider.dart index 387c0a42..7752d80e 100644 --- a/lib/providers/leave_provider.dart +++ b/lib/providers/leave_provider.dart @@ -23,6 +23,7 @@ final leavesProvider = StreamProvider>((ref) { final hasFullAccess = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart index 2bc1ddd3..962cb495 100644 --- a/lib/providers/pass_slip_provider.dart +++ b/lib/providers/pass_slip_provider.dart @@ -16,9 +16,10 @@ final passSlipsProvider = StreamProvider>((ref) { if (profile == null) return Stream.value(const []); final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; + final hasFullAccess = isAdmin || profile.role == 'programmer'; final wrapper = StreamRecoveryWrapper( - stream: isAdmin + stream: hasFullAccess ? client .from('pass_slips') .stream(primaryKey: ['id']) @@ -30,7 +31,7 @@ final passSlipsProvider = StreamProvider>((ref) { .order('requested_at', ascending: false), onPollData: () async { final query = client.from('pass_slips').select(); - final data = isAdmin + final data = hasFullAccess ? await query.order('requested_at', ascending: false) : await query .eq('user_id', profile.id) diff --git a/lib/providers/profile_provider.dart b/lib/providers/profile_provider.dart index 7c6a5c5d..638af48d 100644 --- a/lib/providers/profile_provider.dart +++ b/lib/providers/profile_provider.dart @@ -156,7 +156,8 @@ class ProfileController { final isAdminProvider = Provider((ref) { final profileAsync = ref.watch(currentProfileProvider); return profileAsync.maybeWhen( - data: (profile) => profile?.role == 'admin', + data: (profile) => + profile?.role == 'admin' || profile?.role == 'programmer', orElse: () => false, ); }); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 9a6e9dc6..d92b5907 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -266,6 +266,7 @@ final tasksProvider = StreamProvider>((ref) { final isGlobal = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 38f2a655..e3db65d9 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -176,6 +176,7 @@ final ticketsProvider = StreamProvider>((ref) { final isGlobal = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index 5ff8d6e9..73eb31f1 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -101,7 +101,10 @@ final swapRequestsProvider = StreamProvider>((ref) { return Stream.value(const []); } - final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; + final isAdmin = + profile.role == 'admin' || + profile.role == 'programmer' || + profile.role == 'dispatcher'; final wrapper = StreamRecoveryWrapper( stream: isAdmin diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 1e49a994..978fa6f1 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -59,10 +59,13 @@ final appRouterProvider = Provider((ref) { final role = profileAsync is AsyncData ? (profileAsync.value)?.role : null; - final isAdmin = role == 'admin'; + final isAdmin = role == 'admin' || role == 'programmer'; final isReportsRoute = state.matchedLocation == '/reports'; final hasReportsAccess = - role == 'admin' || role == 'dispatcher' || role == 'it_staff'; + role == 'admin' || + role == 'programmer' || + role == 'dispatcher' || + role == 'it_staff'; if (!isSignedIn && !isAuthRoute) { return '/login'; diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index cf481592..e432f817 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -32,6 +32,7 @@ class _UserManagementScreenState extends ConsumerState { 'standard', 'dispatcher', 'it_staff', + 'programmer', 'admin', ]; diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 8f116971..344fa79e 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -132,9 +132,10 @@ class _AttendanceScreenState extends ConsumerState ColorScheme colors, Profile profile, ) { - final isAdmin = profile.role == 'admin'; + final isAdmin = profile.role == 'admin' || profile.role == 'programmer'; final canFileLeave = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; @@ -209,7 +210,7 @@ class _AttendanceScreenState extends ConsumerState } void _showPassSlipDialog(BuildContext context, Profile profile) { - final isAdmin = profile.role == 'admin'; + final isAdmin = profile.role == 'admin' || profile.role == 'programmer'; if (isAdmin) { showWarningSnackBar(context, 'Admins cannot file pass slips.'); return; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 531bb94c..6178ab14 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -177,6 +177,7 @@ final dashboardMetricsProvider = Provider>((ref) { .where( (profile) => profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff', ) diff --git a/lib/screens/it_service_requests/it_service_request_detail_screen.dart b/lib/screens/it_service_requests/it_service_request_detail_screen.dart index 0752b425..508fe855 100644 --- a/lib/screens/it_service_requests/it_service_request_detail_screen.dart +++ b/lib/screens/it_service_requests/it_service_request_detail_screen.dart @@ -241,9 +241,15 @@ class _ItServiceRequestDetailScreenState if (profile == null) return false; final request = ref.read(itServiceRequestByIdProvider(widget.requestId)); if (request == null) return false; - // Admin, dispatcher can always edit; IT Staff and creator can edit in certain statuses - if (profile.role == 'admin' || profile.role == 'dispatcher') return true; - if (profile.role == 'it_staff') return true; + // Admin, programmer, dispatcher can always edit; IT Staff and creator can edit in certain statuses + if (profile.role == 'admin' || + profile.role == 'programmer' || + profile.role == 'dispatcher') { + return true; + } + if (profile.role == 'it_staff') { + return true; + } if (request.creatorId == profile.id && (request.status == 'draft' || request.status == 'pending_approval')) { return true; @@ -259,7 +265,11 @@ class _ItServiceRequestDetailScreenState bool get _canChangeStatus { final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null) return false; - if (profile.role == 'admin' || profile.role == 'dispatcher') return true; + if (profile.role == 'admin' || + profile.role == 'programmer' || + profile.role == 'dispatcher') { + return true; + } // Assigned IT staff can change status final assignments = ref.read(itServiceRequestAssignmentsProvider).valueOrNull ?? []; diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart index 2abfdc87..16e1f775 100644 --- a/lib/screens/it_service_requests/it_service_requests_list_screen.dart +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -140,6 +140,7 @@ class _ItServiceRequestsListScreenState final isPrivileged = currentProfile != null && (currentProfile.role == 'admin' || + currentProfile.role == 'programmer' || currentProfile.role == 'dispatcher' || currentProfile.role == 'it_staff'); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index ccc0060e..978f9799 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -663,8 +663,7 @@ class _TaskDetailScreenState extends ConsumerState ), ); if (mounted) { - showSuccessSnackBar( - context, + showSuccessSnackBarGlobal( 'Task resumed', ); } @@ -682,16 +681,14 @@ class _TaskDetailScreenState extends ConsumerState ), ); if (mounted) { - showInfoSnackBar( - context, + showInfoSnackBarGlobal( 'Task paused', ); } } } catch (e) { if (mounted) { - showErrorSnackBar( - context, + showErrorSnackBarGlobal( e.toString(), ); } @@ -4310,8 +4307,7 @@ class _TaskDetailScreenState extends ConsumerState // Validate IT staff assignment before starting or completing if ((value == 'in_progress' || value == 'completed') && !hasAssignedItStaff) { - showWarningSnackBar( - context, + showWarningSnackBarGlobal( 'Please assign at least one IT Staff member before ${value == 'in_progress' ? 'starting' : 'completing'} this task.', ); return; @@ -4327,7 +4323,7 @@ class _TaskDetailScreenState extends ConsumerState } catch (e) { // surface validation or other errors to user if (mounted) { - showErrorSnackBar(context, e.toString()); + showErrorSnackBarGlobal(e.toString()); } } }, @@ -4362,6 +4358,7 @@ class _TaskDetailScreenState extends ConsumerState } final isGlobal = profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'; if (isGlobal) { @@ -4390,7 +4387,7 @@ class _TaskDetailScreenState extends ConsumerState if (bytes == null) { if (mounted) { - showErrorSnackBar(context, 'Failed to read file'); + showErrorSnackBarGlobal('Failed to read file'); } return; } @@ -4399,7 +4396,7 @@ class _TaskDetailScreenState extends ConsumerState const maxSizeBytes = 25 * 1024 * 1024; if (bytes.length > maxSizeBytes) { if (mounted) { - showErrorSnackBar(context, 'File size exceeds 25MB limit'); + showErrorSnackBarGlobal('File size exceeds 25MB limit'); } return; } @@ -4457,12 +4454,12 @@ class _TaskDetailScreenState extends ConsumerState if (uploadSuccess) { debugPrint('Showing success message and reloading attachments'); - showSuccessSnackBar(context, 'File uploaded successfully'); + showSuccessSnackBarGlobal('File uploaded successfully'); // Reload attachments list (non-blocking) _loadAttachments(taskId); debugPrint('Attachment reload triggered'); } else { - showErrorSnackBar(context, 'Upload failed: $errorMessage'); + showErrorSnackBarGlobal('Upload failed: $errorMessage'); } } catch (e) { if (mounted) { @@ -4536,15 +4533,15 @@ class _TaskDetailScreenState extends ConsumerState if (mounted) { if (savePath != null && savePath.isNotEmpty) { - showSuccessSnackBar(context, 'File saved to: $savePath'); + showSuccessSnackBarGlobal('File saved to: $savePath'); } else { - showInfoSnackBar(context, 'Download cancelled'); + showInfoSnackBarGlobal('Download cancelled'); } } } catch (e) { debugPrint('Download error: $e'); if (mounted) { - showErrorSnackBar(context, 'Download error: $e'); + showErrorSnackBarGlobal('Download error: $e'); } } } @@ -4576,7 +4573,7 @@ class _TaskDetailScreenState extends ConsumerState ]); if (mounted) { - showSuccessSnackBar(context, 'Attachment deleted'); + showSuccessSnackBarGlobal('Attachment deleted'); // Reload attachments list await _loadAttachments(taskId); } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 9711983e..2b0e7f4c 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -122,6 +122,7 @@ class _TasksListScreenState extends ConsumerState data: (profile) => profile != null && (profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff'), orElse: () => false, @@ -862,8 +863,7 @@ class _TasksListScreenState extends ConsumerState ); if (context.mounted) { Navigator.of(dialogContext).pop(); - showSuccessSnackBar( - context, + showSuccessSnackBarGlobal( 'Task "$title" has been created successfully.', ); } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 3533af18..39da1e75 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -120,6 +120,7 @@ class _TicketDetailScreenState extends ConsumerState { final canEdit = profile != null && (profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff' || profile.id == ticket.creatorId); diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index df485c9d..28840baf 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -26,7 +26,8 @@ class WorkforceScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final profileAsync = ref.watch(currentProfileProvider); final role = profileAsync.valueOrNull?.role ?? 'standard'; - final isAdmin = role == 'admin' || role == 'dispatcher'; + final isAdmin = + role == 'admin' || role == 'programmer' || role == 'dispatcher'; return ResponsiveBody( child: LayoutBuilder( @@ -1486,6 +1487,7 @@ class _ScheduleGeneratorPanelState (profile) => profile.role == 'it_staff' || profile.role == 'admin' || + profile.role == 'programmer' || profile.role == 'dispatcher', ) .toList(); diff --git a/lib/utils/snackbar.dart b/lib/utils/snackbar.dart index a7538014..cc0eb473 100644 --- a/lib/utils/snackbar.dart +++ b/lib/utils/snackbar.dart @@ -1,5 +1,11 @@ import 'package:flutter/material.dart'; import 'package:awesome_snackbar_content/awesome_snackbar_content.dart'; +import 'package:flutter/foundation.dart'; + +/// A global messenger key used to show snackbars from contexts without a +/// Scaffold (e.g. dialogs, background callbacks, or tests). +final GlobalKey scaffoldMessengerKey = + GlobalKey(); /// Helper wrappers around `awesome_snackbar_content` so that callers /// can show snackbars with a consistent look and only specify a message and @@ -28,6 +34,7 @@ void showAwesomeSnackBar( required String title, required String message, required SnackType snackType, + bool retry = true, }) { // Add margin and padding so even very short messages feel substantial. final snackBar = SnackBar( @@ -47,39 +54,147 @@ void showAwesomeSnackBar( ), ); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(snackBar); + // Prefer a local ScaffoldMessenger when available, but prefer the global + // `scaffoldMessengerKey` if it's already ready. This increases reliability + // when callers are inside dialogs or other overlays. + final globalMessenger = scaffoldMessengerKey.currentState; + final localMessenger = ScaffoldMessenger.maybeOf(context); + final messenger = localMessenger ?? globalMessenger; + + if (messenger == null) { + if (kDebugMode) { + debugPrint( + 'showAwesomeSnackBar: no messenger available; scheduling fallback', + ); + } + + // If the scaffold messenger is not available yet, schedule a post-frame + // callback that uses the global messenger (if it becomes available). + if (retry) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final gm = scaffoldMessengerKey.currentState; + if (gm != null) { + try { + gm + ..hideCurrentSnackBar() + ..showSnackBar(snackBar); + if (kDebugMode) { + debugPrint( + 'showAwesomeSnackBar: shown via global messenger (post-frame)', + ); + } + } catch (e) { + if (kDebugMode) { + debugPrint('showAwesomeSnackBar: post-frame show failed: $e'); + } + } + } else if (kDebugMode) { + debugPrint( + 'showAwesomeSnackBar: global messenger still null after frame', + ); + } + }); + } + + return; + } + + try { + messenger + ..hideCurrentSnackBar() + ..showSnackBar(snackBar); + if (kDebugMode) { + debugPrint( + 'showAwesomeSnackBar: shown via ${identical(messenger, globalMessenger) ? 'global' : 'local'} messenger', + ); + } + } catch (e) { + if (kDebugMode) debugPrint('showAwesomeSnackBar: show failed: $e'); + } } -void showSuccessSnackBar(BuildContext context, String message) => - showAwesomeSnackBar( - context, - title: 'Success', - message: message, - snackType: SnackType.success, - ); +void showSuccessSnackBar(BuildContext context, String message) { + showSuccessSnackBarGlobal(message); +} -void showErrorSnackBar(BuildContext context, String message) => - showAwesomeSnackBar( - context, - title: 'Error', - message: message, - snackType: SnackType.error, - ); +void showErrorSnackBar(BuildContext context, String message) { + showErrorSnackBarGlobal(message); +} -void showInfoSnackBar(BuildContext context, String message) => - showAwesomeSnackBar( - context, - title: 'Info', - message: message, - snackType: SnackType.info, - ); +void showInfoSnackBar(BuildContext context, String message) { + showInfoSnackBarGlobal(message); +} -void showWarningSnackBar(BuildContext context, String message) => - showAwesomeSnackBar( - context, - title: 'Warning', - message: message, - snackType: SnackType.warning, - ); +void showWarningSnackBar(BuildContext context, String message) { + showWarningSnackBarGlobal(message); +} + +/// Global helpers that use the app-level `scaffoldMessengerKey` directly. +/// Use these from places where a valid `BuildContext` with a Scaffold may +/// not be available (e.g. dialog builders or background callbacks). +void showAwesomeSnackBarGlobal({ + required String title, + required String message, + required SnackType snackType, +}) { + final gm = scaffoldMessengerKey.currentState; + if (gm == null) { + if (kDebugMode) { + debugPrint('showAwesomeSnackBarGlobal: global messenger null'); + } + return; + } + + final snackBar = SnackBar( + elevation: 0, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + content: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: AwesomeSnackbarContent( + title: title, + message: message, + contentType: _mapSnackType(snackType), + ), + ), + ); + + try { + gm + ..hideCurrentSnackBar() + ..showSnackBar(snackBar); + if (kDebugMode) { + debugPrint('showAwesomeSnackBarGlobal: shown'); + } + } catch (e) { + if (kDebugMode) { + debugPrint('showAwesomeSnackBarGlobal: show failed: $e'); + } + } +} + +void showSuccessSnackBarGlobal(String message) => showAwesomeSnackBarGlobal( + title: 'Success', + message: message, + snackType: SnackType.success, +); + +void showErrorSnackBarGlobal(String message) => showAwesomeSnackBarGlobal( + title: 'Error', + message: message, + snackType: SnackType.error, +); + +void showInfoSnackBarGlobal(String message) => showAwesomeSnackBarGlobal( + title: 'Info', + message: message, + snackType: SnackType.info, +); + +void showWarningSnackBarGlobal(String message) => showAwesomeSnackBarGlobal( + title: 'Warning', + message: message, + snackType: SnackType.warning, +); diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 062cf1a6..9bb7c7d0 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -404,7 +404,7 @@ List _buildSections(String role) { ), ]; - if (role == 'admin' || role == 'dispatcher') { + if (role == 'admin' || role == 'programmer' || role == 'dispatcher') { return [ NavSection(label: 'Operations', items: mainItems), NavSection( @@ -508,7 +508,7 @@ List _standardNavItems() { } List _primaryItemsForRole(String role) { - if (role == 'admin') { + if (role == 'admin' || role == 'programmer') { return [ NavItem( label: 'Dashboard', diff --git a/supabase/migrations/20260316080000_add_programmer_role_enum.sql b/supabase/migrations/20260316080000_add_programmer_role_enum.sql new file mode 100644 index 00000000..24e39d62 --- /dev/null +++ b/supabase/migrations/20260316080000_add_programmer_role_enum.sql @@ -0,0 +1,6 @@ +-- Add `programmer` to the `user_role` enum. +-- +-- This is used by the application for role-based access checks. It must be +-- committed before any other schema objects (e.g., RLS policies) reference it. + +ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'programmer'; diff --git a/supabase/migrations/20260316090000_add_programmer_role.sql b/supabase/migrations/20260316090000_add_programmer_role.sql new file mode 100644 index 00000000..6001f2ec --- /dev/null +++ b/supabase/migrations/20260316090000_add_programmer_role.sql @@ -0,0 +1,117 @@ +-- Add `programmer` role to admin-level access checks without granting approval privileges. +-- +-- NOTE: This role should have the same access as admins in the UI and for +-- non-approval data access. However, it must NOT be able to approve/reject +-- pass slips, leave applications, swap requests, etc. + +-- NOTE: The `programmer` enum value is added in a prior migration so +-- it can safely be used in RLS policies and other schema objects. + +-- Teams: allow programmers to manage teams like admins. +DROP POLICY IF EXISTS "Admins can manage teams (select)" ON public.teams; +DROP POLICY IF EXISTS "Admins can manage teams (write)" ON public.teams; + +CREATE POLICY "Admins can manage teams (select)" ON public.teams + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ); + +CREATE POLICY "Admins can manage teams (write)" ON public.teams + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ); + +-- Team members: allow programmers to view/insert like admins. +DROP POLICY IF EXISTS "Admins can manage team_members (select)" ON public.team_members; +DROP POLICY IF EXISTS "Admins can manage team_members (write)" ON public.team_members; + +CREATE POLICY "Admins can manage team_members (select)" ON public.team_members + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ); + +CREATE POLICY "Admins can manage team_members (write)" ON public.team_members + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'programmer') + ) + ); + +-- Pass slips: allow programmers to view all slips like admins/dispatchers. +DROP POLICY IF EXISTS "pass_slips_select" ON pass_slips; +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', 'programmer') + ) +); + +-- Leaves: allow programmers to view/file leaves like admins/dispatchers/it_staff. +DROP POLICY IF EXISTS "Privileged users can view all leaves" ON leave_of_absence; +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', 'programmer') + ) + ); + +DROP POLICY IF EXISTS "Privileged users can file own leaves" ON leave_of_absence; +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', 'programmer') + ) + ); + +-- Swap request participants: allow programmers to view/insert participant rows. +DROP POLICY IF EXISTS "Swap participants: select" ON public.swap_request_participants; +CREATE POLICY "Swap participants: select" ON public.swap_request_participants + FOR SELECT + USING ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher', 'programmer') + ) + OR EXISTS ( + SELECT 1 FROM public.swap_requests s WHERE s.id = swap_request_id AND (s.requester_id = auth.uid() OR s.recipient_id = auth.uid()) + ) + ); + +DROP POLICY IF EXISTS "Swap participants: insert" ON public.swap_request_participants; +CREATE POLICY "Swap participants: insert" ON public.swap_request_participants + FOR INSERT + WITH CHECK ( + user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher', 'programmer') + ) + ); diff --git a/supabase/migrations/20260316110000_offices_rls_programmer.sql b/supabase/migrations/20260316110000_offices_rls_programmer.sql new file mode 100644 index 00000000..052bfdb7 --- /dev/null +++ b/supabase/migrations/20260316110000_offices_rls_programmer.sql @@ -0,0 +1,31 @@ +-- Ensure office management works for the new `programmer` role. +-- +-- If RLS is enabled for offices, insert/update/delete operations can fail unless +-- there is an explicit policy allowing those roles. + +ALTER TABLE IF EXISTS offices ENABLE ROW LEVEL SECURITY; + +-- Allow any authenticated user to read offices (used for dropdowns/filters). +DROP POLICY IF EXISTS "Offices: select auth" ON offices; +CREATE POLICY "Offices: select auth" ON offices + FOR SELECT + USING (auth.role() IS NOT NULL); + +-- Allow admin/dispatcher/programmer to insert/update/delete offices. +DROP POLICY IF EXISTS "Offices: manage" ON offices; +CREATE POLICY "Offices: manage" ON offices + FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'dispatcher', 'programmer') + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'dispatcher', 'programmer') + ) + );