feat(programmer): day sheet providers + controller

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-28 14:00:29 +08:00
parent 20e95ffe37
commit cc8d8ab8bf
4 changed files with 741 additions and 0 deletions
@@ -37,6 +37,9 @@ class DaySheetSnapshot {
'rows': rows.map((r) => r.toJson()).toList(),
};
/// Alias for [toJson]; used by the approval RPC payload.
Map<String, dynamic> toMap() => toJson();
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j) {
final rowsList = (j['rows'] as List? ?? [])
.map((r) => DaySheetRow.fromJson(r as Map<String, dynamic>))
@@ -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<String> 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<String, dynamic> 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<String>.from(m['flagged_task_ids'] as List? ?? []),
createdAt: DateTime.parse(m['created_at'] as String),
);
}
}
@@ -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<void> Function({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? 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<String> approvedIds;
final List<String> skippedIds;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class DaySheetsController {
DaySheetsController(this._client, {required PushFn push}) : _push = push;
final dynamic _client;
final PushFn _push;
// -------------------------------------------------------------------------
// Submit
// -------------------------------------------------------------------------
Future<String> submit(DateTime dayKey) async {
try {
final dateStr = formatDayKey(dayKey);
final result = await _client.rpc(
'submit_day_sheet',
params: {'p_date': dateStr},
) as Map<String, dynamic>;
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<String>.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<void> disapprove({
required String sheetId,
required String remarks,
required List<String> 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<String, dynamic>) {
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<void> 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<String, dynamic>) {
final notifyUserIds =
List<String>.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<BulkApproveResult> 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<String, dynamic>;
final approved = List<Map<String, dynamic>>.from(
(result['approved'] as List? ?? []).cast<Map<String, dynamic>>(),
);
final skippedIds =
List<String>.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<DaySheetsController>((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<List<ProgrammerDaySheet>>((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<List<ProgrammerDaySheet>, 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<List<ProgrammerDaySheet>>((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<ProgrammerDaySheet?, String>((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<List<ProgrammerDaySheetEvent>, 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());
});
@@ -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<String> userIds;
final String title;
final String body;
final Map<String, dynamic>? data;
_PushCall({
required this.userIds,
required this.title,
required this.body,
this.data,
});
}
class _RecordingPush {
final calls = <_PushCall>[];
Future<void> call({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? 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<String, dynamic>? params;
_RpcCall(this.name, this.params);
}
class _FakeClient {
final calls = <_RpcCall>[];
Map<String, dynamic>? _nextResult;
Object? _nextError;
void setResult(Map<String, dynamic> result) => _nextResult = result;
void setError(Object error) => _nextError = error;
Future<dynamic> rpc(String name, {Map<String, dynamic>? 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<DaySheetActionException>().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<String> userIds,
required String title,
required String body,
Map<String, dynamic>? 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);
});
});
}