From cc8d8ab8bfba14dd8157e6dd9433b6f07228bbf1 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 28 Sep 2026 14:00:29 +0800 Subject: [PATCH] feat(programmer): day sheet providers + controller Co-Authored-By: claude-flow --- lib/models/programmer_day_sheet.model.dart | 3 + .../programmer_day_sheet_event.model.dart | 32 ++ .../programmer_day_sheets_provider.dart | 388 ++++++++++++++++++ ...programmer_day_sheets_controller_test.dart | 318 ++++++++++++++ 4 files changed, 741 insertions(+) create mode 100644 lib/models/programmer_day_sheet_event.model.dart create mode 100644 lib/providers/programmer_day_sheets_provider.dart create mode 100644 test/programmer_day_sheets_controller_test.dart diff --git a/lib/models/programmer_day_sheet.model.dart b/lib/models/programmer_day_sheet.model.dart index 21009461..530ecce4 100644 --- a/lib/models/programmer_day_sheet.model.dart +++ b/lib/models/programmer_day_sheet.model.dart @@ -37,6 +37,9 @@ class DaySheetSnapshot { 'rows': rows.map((r) => r.toJson()).toList(), }; + /// Alias for [toJson]; used by the approval RPC payload. + Map toMap() => toJson(); + factory DaySheetSnapshot.fromJson(Map j) { final rowsList = (j['rows'] as List? ?? []) .map((r) => DaySheetRow.fromJson(r as Map)) diff --git a/lib/models/programmer_day_sheet_event.model.dart b/lib/models/programmer_day_sheet_event.model.dart new file mode 100644 index 00000000..2f4a81b6 --- /dev/null +++ b/lib/models/programmer_day_sheet_event.model.dart @@ -0,0 +1,32 @@ +/// A single audit-trail event on a programmer day sheet. +class ProgrammerDaySheetEvent { + final String id; + final String sheetId; + final String kind; + final String? actorId; + final String? body; + final List flaggedTaskIds; + final DateTime createdAt; + + const ProgrammerDaySheetEvent({ + required this.id, + required this.sheetId, + required this.kind, + this.actorId, + this.body, + required this.flaggedTaskIds, + required this.createdAt, + }); + + factory ProgrammerDaySheetEvent.fromMap(Map m) { + return ProgrammerDaySheetEvent( + id: m['id'] as String, + sheetId: m['sheet_id'] as String, + kind: m['kind'] as String, + actorId: m['actor_id'] as String?, + body: m['body'] as String?, + flaggedTaskIds: List.from(m['flagged_task_ids'] as List? ?? []), + createdAt: DateTime.parse(m['created_at'] as String), + ); + } +} diff --git a/lib/providers/programmer_day_sheets_provider.dart b/lib/providers/programmer_day_sheets_provider.dart new file mode 100644 index 00000000..9118df95 --- /dev/null +++ b/lib/providers/programmer_day_sheets_provider.dart @@ -0,0 +1,388 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/programmer_day_sheet.model.dart'; +import '../models/programmer_day_sheet_event.model.dart'; +import '../utils/app_time.dart'; +import '../utils/programmer_daily_time.dart'; +import 'notifications_provider.dart'; +import 'profile_provider.dart'; +import 'supabase_provider.dart'; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +/// Callback type for sending push notifications. +typedef PushFn = Future Function({ + required List userIds, + required String title, + required String body, + Map? data, +}); + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +enum DaySheetError { + alreadyReviewed, + alreadySubmitted, + notAuthorized, + remarksRequired, + bodyRequired, + workDateLocked, + invalidSnapshot, + unknown, +} + +class DaySheetActionException implements Exception { + const DaySheetActionException(this.code); + + final DaySheetError code; + + String get message { + switch (code) { + case DaySheetError.alreadyReviewed: + return 'This sheet was already reviewed.'; + case DaySheetError.alreadySubmitted: + return 'This day sheet was already submitted.'; + case DaySheetError.notAuthorized: + return 'You are not allowed to do that.'; + case DaySheetError.remarksRequired: + return 'Remarks are required to disapprove.'; + case DaySheetError.bodyRequired: + return 'Please write a justification.'; + case DaySheetError.workDateLocked: + return 'That day is locked — you can only add entries to pending or disapproved days.'; + case DaySheetError.invalidSnapshot: + return 'Could not approve: the sheet data was invalid.'; + case DaySheetError.unknown: + return 'Something went wrong. Please try again.'; + } + } + + @override + String toString() => 'DaySheetActionException(${code.name}): $message'; +} + +/// Maps an RPC error to a [DaySheetError] by substring-matching the lowercased +/// error string against the known database error codes. +DaySheetError mapDaySheetError(Object error) { + final s = error.toString().toLowerCase(); + if (s.contains('not_authorized')) return DaySheetError.notAuthorized; + if (s.contains('already_reviewed')) return DaySheetError.alreadyReviewed; + if (s.contains('already_submitted')) return DaySheetError.alreadySubmitted; + if (s.contains('remarks_required')) return DaySheetError.remarksRequired; + if (s.contains('body_required')) return DaySheetError.bodyRequired; + if (s.contains('work_date_locked')) return DaySheetError.workDateLocked; + if (s.contains('invalid_snapshot')) return DaySheetError.invalidSnapshot; + return DaySheetError.unknown; +} + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +class BulkApproveResult { + const BulkApproveResult({ + required this.approvedIds, + required this.skippedIds, + }); + + final List approvedIds; + final List skippedIds; +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +class DaySheetsController { + DaySheetsController(this._client, {required PushFn push}) : _push = push; + + final dynamic _client; + final PushFn _push; + + // ------------------------------------------------------------------------- + // Submit + // ------------------------------------------------------------------------- + + Future submit(DateTime dayKey) async { + try { + final dateStr = formatDayKey(dayKey); + final result = await _client.rpc( + 'submit_day_sheet', + params: {'p_date': dateStr}, + ) as Map; + + final sheetId = result['sheet_id'] as String; + final workDate = parseDayKey(result['work_date'] as String); + final programmerName = result['programmer_name'] as String; + final notifyUserIds = + List.from(result['notify_user_ids'] as List? ?? []); + + if (notifyUserIds.isNotEmpty) { + try { + await _push( + userIds: notifyUserIds, + title: 'Day sheet submitted', + body: + '$programmerName submitted their day sheet for ${AppTime.formatDate(workDate)}', + data: { + 'type': 'day_sheet_submitted', + 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', + }, + ); + } catch (e) { + debugPrint('DaySheetsController.submit push error: $e'); + } + } + + return sheetId; + } catch (e) { + if (e is DaySheetActionException) rethrow; + throw DaySheetActionException(mapDaySheetError(e)); + } + } + + // ------------------------------------------------------------------------- + // Disapprove + // ------------------------------------------------------------------------- + + Future disapprove({ + required String sheetId, + required String remarks, + required List flaggedTaskIds, + }) async { + if (remarks.trim().isEmpty) { + throw DaySheetActionException(DaySheetError.remarksRequired); + } + try { + final result = await _client.rpc( + 'disapprove_day_sheet', + params: { + 'p_sheet': sheetId, + 'p_remarks': remarks.trim(), + 'p_flagged_task_ids': flaggedTaskIds, + }, + ); + + if (result is Map) { + final programmerId = result['programmer_id'] as String?; + final rawDate = result['work_date'] as String?; + if (programmerId != null && rawDate != null) { + final workDate = parseDayKey(rawDate); + final truncated = remarks.trim().length > 80 + ? '${remarks.trim().substring(0, 80)}…' + : remarks.trim(); + try { + await _push( + userIds: [programmerId], + title: 'Day sheet disapproved', + body: + 'Your day sheet for ${AppTime.formatDate(workDate)} needs changes: $truncated', + data: { + 'type': 'day_sheet_disapproved', + 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', + }, + ); + } catch (e) { + debugPrint('DaySheetsController.disapprove push error: $e'); + } + } + } + } catch (e) { + if (e is DaySheetActionException) rethrow; + throw DaySheetActionException(mapDaySheetError(e)); + } + } + + // ------------------------------------------------------------------------- + // Justify + // ------------------------------------------------------------------------- + + Future justify({ + required String sheetId, + required String body, + }) async { + if (body.trim().isEmpty) { + throw DaySheetActionException(DaySheetError.bodyRequired); + } + try { + final result = await _client.rpc( + 'justify_day_sheet', + params: { + 'p_sheet': sheetId, + 'p_body': body.trim(), + }, + ); + + if (result is Map) { + final notifyUserIds = + List.from(result['notify_user_ids'] as List? ?? []); + final programmerName = result['programmer_name'] as String?; + final rawDate = result['work_date'] as String?; + if (notifyUserIds.isNotEmpty && + programmerName != null && + rawDate != null) { + final workDate = parseDayKey(rawDate); + try { + await _push( + userIds: notifyUserIds, + title: 'Day sheet resubmitted', + body: + '$programmerName justified their day sheet for ${AppTime.formatDate(workDate)}', + data: { + 'type': 'day_sheet_resubmitted', + 'navigate_to': '/programmer-tasks/day-sheets/$sheetId', + }, + ); + } catch (e) { + debugPrint('DaySheetsController.justify push error: $e'); + } + } + } + } catch (e) { + if (e is DaySheetActionException) rethrow; + throw DaySheetActionException(mapDaySheetError(e)); + } + } + + // ------------------------------------------------------------------------- + // Approve (bulk) + // ------------------------------------------------------------------------- + + Future approve( + List<({String sheetId, DaySheetSnapshot snapshot})> items, + ) async { + try { + final params = items + .map((e) => { + 'sheet_id': e.sheetId, + 'snapshot': e.snapshot.toMap(), + }) + .toList(); + + final result = await _client.rpc( + 'approve_day_sheets', + params: {'p_items': params}, + ) as Map; + + final approved = List>.from( + (result['approved'] as List? ?? []).cast>(), + ); + final skippedIds = + List.from(result['skipped_ids'] as List? ?? []); + + for (final item in approved) { + final approvedSheetId = item['sheet_id'] as String; + final programmerId = item['programmer_id'] as String?; + final rawDate = item['work_date'] as String?; + if (programmerId != null && rawDate != null) { + final workDate = parseDayKey(rawDate); + try { + await _push( + userIds: [programmerId], + title: 'Day sheet approved', + body: + 'Your day sheet for ${AppTime.formatDate(workDate)} was approved', + data: { + 'type': 'day_sheet_approved', + 'navigate_to': + '/programmer-tasks/day-sheets/$approvedSheetId', + }, + ); + } catch (e) { + debugPrint('DaySheetsController.approve push error: $e'); + } + } + } + + return BulkApproveResult( + approvedIds: approved.map((e) => e['sheet_id'] as String).toList(), + skippedIds: skippedIds, + ); + } catch (e) { + if (e is DaySheetActionException) rethrow; + throw DaySheetActionException(mapDaySheetError(e)); + } + } +} + +// --------------------------------------------------------------------------- +// Riverpod providers +// --------------------------------------------------------------------------- + +final daySheetsControllerProvider = Provider((ref) { + final client = ref.watch(supabaseClientProvider); + final notifications = ref.read(notificationsControllerProvider); + return DaySheetsController( + client, + push: notifications.sendPush, + ); +}); + +// --------------------------------------------------------------------------- +// Stream providers +// --------------------------------------------------------------------------- + +/// Day sheets for the currently authenticated programmer, newest first. +final myDaySheetsProvider = + StreamProvider>((ref) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return const Stream.empty(); + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_day_sheets') + .stream(primaryKey: ['id']) + .eq('programmer_id', userId) + .order('work_date', ascending: false) + .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); +}); + +/// Day sheets for a specific user (admin view), newest first. +final daySheetsForUserProvider = + StreamProvider.family, String>((ref, userId) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_day_sheets') + .stream(primaryKey: ['id']) + .eq('programmer_id', userId) + .order('work_date', ascending: false) + .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); +}); + +/// All day sheets visible to the current user (RLS-scoped), newest first. +final allDaySheetsProvider = + StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_day_sheets') + .stream(primaryKey: ['id']) + .order('work_date', ascending: false) + .map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList()); +}); + +/// A single day sheet by its ID, or null if not found. +final daySheetByIdProvider = + StreamProvider.family((ref, id) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_day_sheets') + .stream(primaryKey: ['id']) + .eq('id', id) + .map((rows) => rows.isEmpty ? null : ProgrammerDaySheet.fromMap(rows.first)); +}); + +/// Events for a day sheet, ordered by creation time ascending. +final daySheetEventsProvider = + StreamProvider.family, String>((ref, sheetId) { + final client = ref.watch(supabaseClientProvider); + return client + .from('programmer_day_sheet_events') + .stream(primaryKey: ['id']) + .eq('sheet_id', sheetId) + .order('created_at', ascending: true) + .map((rows) => rows.map(ProgrammerDaySheetEvent.fromMap).toList()); +}); diff --git a/test/programmer_day_sheets_controller_test.dart b/test/programmer_day_sheets_controller_test.dart new file mode 100644 index 00000000..8c00a00a --- /dev/null +++ b/test/programmer_day_sheets_controller_test.dart @@ -0,0 +1,318 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_day_sheet.model.dart'; +import 'package:tasq/models/programmer_day_sheet_event.model.dart'; +import 'package:tasq/providers/programmer_day_sheets_provider.dart'; + +// --------------------------------------------------------------------------- +// Fake push recorder +// --------------------------------------------------------------------------- + +class _PushCall { + final List userIds; + final String title; + final String body; + final Map? data; + + _PushCall({ + required this.userIds, + required this.title, + required this.body, + this.data, + }); +} + +class _RecordingPush { + final calls = <_PushCall>[]; + + Future call({ + required List userIds, + required String title, + required String body, + Map? data, + }) async { + calls.add(_PushCall(userIds: userIds, title: title, body: body, data: data)); + } +} + +// --------------------------------------------------------------------------- +// Fake Supabase-like client — only exposes rpc() +// --------------------------------------------------------------------------- + +class _RpcCall { + final String name; + final Map? params; + _RpcCall(this.name, this.params); +} + +class _FakeClient { + final calls = <_RpcCall>[]; + Map? _nextResult; + Object? _nextError; + + void setResult(Map result) => _nextResult = result; + void setError(Object error) => _nextError = error; + + Future rpc(String name, {Map? params}) async { + calls.add(_RpcCall(name, params)); + if (_nextError != null) { + final err = _nextError!; + _nextError = null; + throw err; + } + final result = _nextResult; + _nextResult = null; + return result; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('DaySheetsController', () { + late _FakeClient fake; + late _RecordingPush push; + late DaySheetsController controller; + + setUp(() { + fake = _FakeClient(); + push = _RecordingPush(); + controller = DaySheetsController(fake, push: push.call); + }); + + test('submit calls submit_day_sheet with p_date and pushes admins', () async { + fake.setResult({ + 'sheet_id': 's1', + 'work_date': '2026-09-28', + 'programmer_name': 'Paola Ross', + 'notify_user_ids': ['adm'], + }); + + final sheetId = await controller.submit(DateTime.utc(2026, 9, 28)); + + expect(sheetId, 's1'); + expect(fake.calls.single.name, 'submit_day_sheet'); + expect(fake.calls.single.params, {'p_date': '2026-09-28'}); + expect(push.calls.single.title, 'Day sheet submitted'); + expect( + push.calls.single.body, + 'Paola Ross submitted their day sheet for Sep 28, 2026', + ); + }); + + test('blank remarks fail fast without an rpc call', () async { + await expectLater( + controller.disapprove( + sheetId: 'sh1', + remarks: ' ', + flaggedTaskIds: [], + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + DaySheetError.remarksRequired, + ), + ), + ); + expect(fake.calls, isEmpty); + }); + + test('already_reviewed maps to a friendly message', () async { + fake.setError(Exception('already_reviewed')); + + DaySheetActionException? caught; + try { + await controller.submit(DateTime.utc(2026, 9, 28)); + } on DaySheetActionException catch (e) { + caught = e; + } + + expect(caught, isNotNull); + expect(caught!.code, DaySheetError.alreadyReviewed); + expect(caught.message, 'This sheet was already reviewed.'); + }); + + test('approve reports approved and skipped ids and pushes per approved sheet', + () async { + fake.setResult({ + 'approved': [ + { + 'sheet_id': 'sh1', + 'work_date': '2026-09-28', + 'programmer_id': 'prog1', + }, + { + 'sheet_id': 'sh2', + 'work_date': '2026-09-27', + 'programmer_id': 'prog2', + }, + ], + 'skipped_ids': ['sh3'], + }); + + final snapshot = DaySheetSnapshot(totalSeconds: 3600, rows: []); + final items = [ + (sheetId: 'sh1', snapshot: snapshot), + (sheetId: 'sh2', snapshot: snapshot), + (sheetId: 'sh3', snapshot: snapshot), + ]; + + final result = await controller.approve(items); + + expect(result.approvedIds, ['sh1', 'sh2']); + expect(result.skippedIds, ['sh3']); + expect(push.calls.length, 2); + expect(push.calls[0].title, 'Day sheet approved'); + expect( + push.calls[0].body, + 'Your day sheet for Sep 28, 2026 was approved', + ); + expect( + push.calls[1].body, + 'Your day sheet for Sep 27, 2026 was approved', + ); + }); + + test('a failing push does not fail the action', () async { + fake.setResult({ + 'sheet_id': 's1', + 'work_date': '2026-09-28', + 'programmer_name': 'Paola Ross', + 'notify_user_ids': ['adm'], + }); + + final throwingController = DaySheetsController( + fake, + push: ({ + required List userIds, + required String title, + required String body, + Map? data, + }) async { + throw Exception('push failed'); + }, + ); + + // Should succeed even though push throws. + final sheetId = await throwingController.submit(DateTime.utc(2026, 9, 28)); + expect(sheetId, 's1'); + }); + }); + + // ------------------------------------------------------------------------- + + group('mapDaySheetError', () { + test('maps known substrings to the correct error code', () { + expect( + mapDaySheetError(Exception('not_authorized')), + DaySheetError.notAuthorized, + ); + expect( + mapDaySheetError(Exception('already_reviewed')), + DaySheetError.alreadyReviewed, + ); + expect( + mapDaySheetError(Exception('already_submitted')), + DaySheetError.alreadySubmitted, + ); + expect( + mapDaySheetError(Exception('remarks_required')), + DaySheetError.remarksRequired, + ); + expect( + mapDaySheetError(Exception('body_required')), + DaySheetError.bodyRequired, + ); + expect( + mapDaySheetError(Exception('work_date_locked')), + DaySheetError.workDateLocked, + ); + expect( + mapDaySheetError(Exception('invalid_snapshot')), + DaySheetError.invalidSnapshot, + ); + expect( + mapDaySheetError(Exception('something else entirely')), + DaySheetError.unknown, + ); + }); + }); + + // ------------------------------------------------------------------------- + + group('DaySheetActionException.message', () { + test('returns exact strings for every error code', () { + expect( + DaySheetActionException(DaySheetError.alreadyReviewed).message, + 'This sheet was already reviewed.', + ); + expect( + DaySheetActionException(DaySheetError.alreadySubmitted).message, + 'This day sheet was already submitted.', + ); + expect( + DaySheetActionException(DaySheetError.notAuthorized).message, + 'You are not allowed to do that.', + ); + expect( + DaySheetActionException(DaySheetError.remarksRequired).message, + 'Remarks are required to disapprove.', + ); + expect( + DaySheetActionException(DaySheetError.bodyRequired).message, + 'Please write a justification.', + ); + expect( + DaySheetActionException(DaySheetError.workDateLocked).message, + 'That day is locked — you can only add entries to pending or disapproved days.', + ); + expect( + DaySheetActionException(DaySheetError.invalidSnapshot).message, + 'Could not approve: the sheet data was invalid.', + ); + expect( + DaySheetActionException(DaySheetError.unknown).message, + 'Something went wrong. Please try again.', + ); + }); + }); + + // ------------------------------------------------------------------------- + + group('ProgrammerDaySheetEvent', () { + test('fromMap parses all fields correctly', () { + final event = ProgrammerDaySheetEvent.fromMap({ + 'id': 'ev1', + 'sheet_id': 'sh1', + 'kind': 'submitted', + 'actor_id': 'u1', + 'body': 'some text', + 'flagged_task_ids': ['t1', 't2'], + 'created_at': '2026-09-28T10:00:00.000Z', + }); + + expect(event.id, 'ev1'); + expect(event.sheetId, 'sh1'); + expect(event.kind, 'submitted'); + expect(event.actorId, 'u1'); + expect(event.body, 'some text'); + expect(event.flaggedTaskIds, ['t1', 't2']); + expect(event.createdAt, DateTime.utc(2026, 9, 28, 10, 0, 0)); + }); + + test('fromMap handles null optional fields', () { + final event = ProgrammerDaySheetEvent.fromMap({ + 'id': 'ev2', + 'sheet_id': 'sh2', + 'kind': 'approved', + 'created_at': '2026-09-28T10:00:00.000Z', + }); + + expect(event.actorId, isNull); + expect(event.body, isNull); + expect(event.flaggedTaskIds, isEmpty); + }); + }); +}