Initial handling of activity logs

This commit is contained in:
Marc Rejohn Castillano 2026-02-22 14:14:16 +08:00
parent b581bdf7be
commit 56504b9e8a
2 changed files with 124 additions and 15 deletions

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
class TaskActivityLog { class TaskActivityLog {
@ -18,13 +20,87 @@ class TaskActivityLog {
final DateTime createdAt; final DateTime createdAt;
factory TaskActivityLog.fromMap(Map<String, dynamic> map) { factory TaskActivityLog.fromMap(Map<String, dynamic> map) {
// id and task_id may be returned as int or String depending on DB
final rawId = map['id'];
final rawTaskId = map['task_id'];
String id = rawId == null ? '' : rawId.toString();
String taskId = rawTaskId == null ? '' : rawTaskId.toString();
// actor_id is nullable
final actorId = map['actor_id']?.toString();
// action_type fallback
final actionType = (map['action_type'] as String?) ?? 'unknown';
// meta may be a Map, Map<dynamic,dynamic>, JSON-encoded string, List, or null
Map<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
meta = rawMeta;
} else if (rawMeta is Map) {
// convert dynamic-key map to Map<String, dynamic>
try {
meta = rawMeta.map((k, v) => MapEntry(k.toString(), v));
} catch (_) {
meta = null;
}
} else if (rawMeta is String && rawMeta.isNotEmpty) {
try {
final decoded = jsonDecode(rawMeta);
if (decoded is Map<String, dynamic>) {
meta = decoded;
} else if (decoded is Map) {
meta = decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
meta = null;
}
} else {
meta = null;
}
// created_at may be ISO string, DateTime, or numeric (seconds/millis since epoch)
final rawCreated = map['created_at'];
DateTime createdAt;
if (rawCreated is DateTime) {
createdAt = AppTime.toAppTime(rawCreated);
} else if (rawCreated is String) {
try {
createdAt = AppTime.parse(rawCreated);
} catch (_) {
createdAt = AppTime.now();
}
} else if (rawCreated is int) {
// assume seconds or milliseconds
if (rawCreated > 1e12) {
// likely microseconds or nanoseconds - treat as milliseconds
createdAt = AppTime.toAppTime(
DateTime.fromMillisecondsSinceEpoch(rawCreated),
);
} else if (rawCreated > 1e10) {
createdAt = AppTime.toAppTime(
DateTime.fromMillisecondsSinceEpoch(rawCreated),
);
} else {
createdAt = AppTime.toAppTime(
DateTime.fromMillisecondsSinceEpoch(rawCreated * 1000),
);
}
} else if (rawCreated is double) {
final asInt = rawCreated.toInt();
createdAt = AppTime.toAppTime(DateTime.fromMillisecondsSinceEpoch(asInt));
} else {
createdAt = AppTime.now();
}
return TaskActivityLog( return TaskActivityLog(
id: map['id'] as String, id: id,
taskId: map['task_id'] as String, taskId: taskId,
actorId: map['actor_id'] as String?, actorId: actorId,
actionType: map['action_type'] as String? ?? 'unknown', actionType: actionType,
meta: map['meta'] as Map<String, dynamic>?, meta: meta,
createdAt: AppTime.parse(map['created_at'] as String), createdAt: createdAt,
); );
} }
} }

View File

@ -16,6 +16,39 @@ import 'tickets_provider.dart';
import 'user_offices_provider.dart'; import 'user_offices_provider.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
// Helper to insert activity log rows while sanitizing nulls and
// avoiding exceptions from malformed payloads. Accepts either a Map
// or a List<Map>.
Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
try {
if (rows == null) return;
if (rows is List) {
final sanitized = rows
.map((r) {
if (r is Map) {
final m = Map<String, dynamic>.from(r);
m.removeWhere((k, v) => v == null);
return m;
}
return null;
})
.whereType<Map<String, dynamic>>()
.toList();
if (sanitized.isEmpty) return;
await client.from('task_activity_logs').insert(sanitized);
} else if (rows is Map) {
final m = Map<String, dynamic>.from(rows);
m.removeWhere((k, v) => v == null);
await client.from('task_activity_logs').insert(m);
}
} catch (e) {
// Log for debugging but don't rethrow to avoid breaking caller flows
try {
debugPrint('[insertActivityRows] insert failed: $e');
} catch (_) {}
}
}
/// Task query parameters for server-side pagination and filtering. /// Task query parameters for server-side pagination and filtering.
class TaskQuery { class TaskQuery {
/// Creates task query parameters. /// Creates task query parameters.
@ -337,7 +370,7 @@ class TasksController {
if (taskId == null) return; if (taskId == null) return;
try { try {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': actorId, 'actor_id': actorId,
'action_type': 'created', 'action_type': 'created',
@ -549,13 +582,13 @@ class TasksController {
try { try {
final actorId = _client.auth.currentUser?.id; final actorId = _client.auth.currentUser?.id;
if (status == 'in_progress') { if (status == 'in_progress') {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': actorId, 'actor_id': actorId,
'action_type': 'started', 'action_type': 'started',
}); });
} else if (status == 'completed') { } else if (status == 'completed') {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': actorId, 'actor_id': actorId,
'action_type': 'completed', 'action_type': 'completed',
@ -678,7 +711,7 @@ class TasksController {
if (onDuty.isEmpty) { if (onDuty.isEmpty) {
// record a failed auto-assign attempt for observability // record a failed auto-assign attempt for observability
try { try {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': null, 'actor_id': null,
'action_type': 'auto_assign_failed', 'action_type': 'auto_assign_failed',
@ -726,7 +759,7 @@ class TasksController {
if (candidates.isEmpty) { if (candidates.isEmpty) {
try { try {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': null, 'actor_id': null,
'action_type': 'auto_assign_failed', 'action_type': 'auto_assign_failed',
@ -752,7 +785,7 @@ class TasksController {
}); });
try { try {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': null, 'actor_id': null,
'action_type': 'assigned', 'action_type': 'assigned',
@ -773,7 +806,7 @@ class TasksController {
// ignore: avoid_print // ignore: avoid_print
print('autoAssignTask error for task=$taskId: $e\n$st'); print('autoAssignTask error for task=$taskId: $e\n$st');
try { try {
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': null, 'actor_id': null,
'action_type': 'auto_assign_failed', 'action_type': 'auto_assign_failed',
@ -859,7 +892,7 @@ class TaskAssignmentsController {
}, },
) )
.toList(); .toList();
await _client.from('task_activity_logs').insert(logRows); await _insertActivityRows(_client, logRows);
} catch (_) { } catch (_) {
// non-fatal // non-fatal
} }
@ -876,7 +909,7 @@ class TaskAssignmentsController {
// Record a reassignment event (who removed -> who added) // Record a reassignment event (who removed -> who added)
try { try {
final actorId = _client.auth.currentUser?.id; final actorId = _client.auth.currentUser?.id;
await _client.from('task_activity_logs').insert({ await _insertActivityRows(_client, {
'task_id': taskId, 'task_id': taskId,
'actor_id': actorId, 'actor_id': actorId,
'action_type': 'reassigned', 'action_type': 'reassigned',