diff --git a/lib/models/task.dart b/lib/models/task.dart index 2d5e8a01..c505ab78 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -6,6 +6,7 @@ class Task { Task({ required this.id, required this.ticketId, + required this.taskNumber, required this.title, required this.description, required this.officeId, @@ -28,6 +29,7 @@ class Task { final String id; final String? ticketId; + final String? taskNumber; final String title; final String description; final String? officeId; @@ -55,6 +57,7 @@ class Task { return Task( id: map['id'] as String, ticketId: map['ticket_id'] as String?, + taskNumber: map['task_number'] as String?, title: map['title'] as String? ?? 'Task', description: map['description'] as String? ?? '', officeId: map['office_id'] as String?, diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 4554a48f..7b6731c9 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -153,7 +153,8 @@ final tasksProvider = StreamProvider>((ref) { .where( (t) => t.title.toLowerCase().contains(q) || - t.description.toLowerCase().contains(q), + t.description.toLowerCase().contains(q) || + (t.taskNumber?.toLowerCase().contains(q) ?? false), ) .toList(); } @@ -253,15 +254,88 @@ class TasksController { payload['request_category'] = requestCategory; } - final data = await _client - .from('tasks') - .insert(payload) - .select('id') - .single(); - final taskId = data['id'] as String?; + // Prefer server RPC that atomically generates `task_number` and inserts + // the task; fallback to client-side insert with retry on duplicate-key. + String? taskId; + String? assignedNumber; + + try { + final rpcParams = { + 'p_title': title, + 'p_description': description, + 'p_office_id': officeId, + 'p_ticket_id': ticketId, + 'p_request_type': requestType, + 'p_request_type_other': requestTypeOther, + 'p_request_category': requestCategory, + 'p_creator_id': actorId, + }; + // Retry RPC on duplicate-key (23505) errors which may occur + // transiently due to concurrent inserts; prefer RPC always. + const int rpcMaxAttempts = 3; + Map? rpcRow; + for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) { + try { + final rpcRes = await _client + .rpc('insert_task_with_number', rpcParams) + .single(); + if (rpcRes is Map) { + rpcRow = Map.from(rpcRes); + } else if (rpcRes is List && + rpcRes.isNotEmpty && + rpcRes.first is Map) { + rpcRow = Map.from(rpcRes.first as Map); + } + break; + } catch (err) { + final msg = err.toString(); + final isDuplicateKey = + msg.contains('duplicate key value') || msg.contains('23505'); + if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) { + rethrow; + } + await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); + // retry + } + } + if (rpcRow != null) { + taskId = rpcRow['id'] as String?; + assignedNumber = rpcRow['task_number'] as String?; + } + // ignore: avoid_print + print('createTask via RPC assigned number=$assignedNumber id=$taskId'); + } catch (e) { + // RPC not available or failed; fallback to client insert with retry + const int maxAttempts = 3; + Map? insertData; + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + insertData = await _client + .from('tasks') + .insert(payload) + .select('id, task_number') + .single(); + break; + } catch (err) { + final msg = err.toString(); + final isDuplicateKey = + msg.contains('duplicate key value') || msg.contains('23505'); + if (!isDuplicateKey || attempt == maxAttempts - 1) { + rethrow; + } + await Future.delayed(Duration(milliseconds: 150 * (attempt + 1))); + } + } + taskId = insertData == null ? null : insertData['id'] as String?; + assignedNumber = insertData == null + ? null + : insertData['task_number'] as String?; + // ignore: avoid_print + print('createTask fallback assigned number=$assignedNumber id=$taskId'); + } + if (taskId == null) return; - // Activity log: created try { await _client.from('task_activity_logs').insert({ 'task_id': taskId, diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 26c2eba8..adba60f9 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -315,7 +315,9 @@ class _TaskDetailScreenState extends ConsumerState Align( alignment: Alignment.center, child: Text( - task.title.isNotEmpty ? task.title : 'Task ${task.id}', + task.title.isNotEmpty + ? task.title + : 'Task ${task.taskNumber ?? task.id}', textAlign: TextAlign.center, style: Theme.of( context, @@ -339,7 +341,11 @@ class _TaskDetailScreenState extends ConsumerState children: [ _buildStatusChip(context, task, canUpdateStatus), _MetaBadge(label: 'Office', value: officeName), - _MetaBadge(label: 'Task ID', value: task.id, isMono: true), + _MetaBadge( + label: 'Task #', + value: task.taskNumber ?? task.id, + isMono: true, + ), ], ), if (description.isNotEmpty) ...[ diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 421dc5b9..f18cea16 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -250,9 +250,10 @@ class _TasksListScreenState extends ConsumerState { isLoading: false, columns: [ TasQColumn( - header: 'Task ID', + header: 'Task #', technical: true, - cellBuilder: (context, task) => Text(task.id), + cellBuilder: (context, task) => + Text(task.taskNumber ?? task.id), ), TasQColumn( header: 'Subject', @@ -326,7 +327,8 @@ class _TasksListScreenState extends ConsumerState { title: Text( task.title.isNotEmpty ? task.title - : (ticket?.subject ?? 'Task ${task.id}'), + : (ticket?.subject ?? + 'Task ${task.taskNumber ?? task.id}'), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -335,7 +337,7 @@ class _TasksListScreenState extends ConsumerState { const SizedBox(height: 2), Text('Assigned: $assigned'), const SizedBox(height: 4), - MonoText('ID ${task.id}'), + MonoText('ID ${task.taskNumber ?? task.id}'), const SizedBox(height: 2), Text(_formatTimestamp(task.createdAt)), ], @@ -663,6 +665,7 @@ List _applyTaskFilters( : (ticket?.subject ?? 'Task ${task.id}'); if (query.isNotEmpty && !subject.toLowerCase().contains(query) && + !(task.taskNumber?.toLowerCase().contains(query) ?? false) && !task.id.toLowerCase().contains(query)) { return false; } diff --git a/supabase/migrations/20260221120000_add_task_number.sql b/supabase/migrations/20260221120000_add_task_number.sql new file mode 100644 index 00000000..60c66d4d --- /dev/null +++ b/supabase/migrations/20260221120000_add_task_number.sql @@ -0,0 +1,66 @@ +-- Add a human‐readable, sequential task number that is shown in the UI +-- Format: YYYY-MM-##### (resetting each month). + +-- 1. Add the column (nullable for now so we can backfill) +ALTER TABLE tasks +ADD COLUMN task_number text; + +-- 2. Backfill existing rows using created_at as the timestamp basis. +DO $$ +DECLARE + r RECORD; + prefix text; + cnt int; +BEGIN + FOR r IN + SELECT id, created_at + FROM tasks + WHERE task_number IS NULL + ORDER BY created_at + LOOP + prefix := to_char(r.created_at::timestamp, 'YYYY-MM-'); + cnt := (SELECT count(*) + FROM tasks t + WHERE to_char(t.created_at::timestamp, 'YYYY-MM-') = prefix + AND t.created_at <= r.created_at); + UPDATE tasks + SET task_number = prefix || lpad(cnt::text, 5, '0') + WHERE id = r.id; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 3. Create trigger function to generate numbers on new inserts +CREATE OR REPLACE FUNCTION tasks_set_task_number() +RETURNS trigger AS $$ +DECLARE + prefix text; + seq int; +BEGIN + -- if caller already provided one, do not overwrite + IF NEW.task_number IS NOT NULL THEN + RETURN NEW; + END IF; + + prefix := to_char(now(), 'YYYY-MM-'); + SELECT COALESCE(MAX((substring(task_number FROM 8))::int), 0) + 1 + INTO seq + FROM tasks + WHERE task_number LIKE prefix || '%'; + + NEW.task_number := prefix || lpad(seq::text, 5, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tasks_set_task_number_before_insert +BEFORE INSERT ON tasks +FOR EACH ROW +EXECUTE FUNCTION tasks_set_task_number(); + +-- 4. Enforce not-null and uniqueness now that every row has a value +ALTER TABLE tasks + ALTER COLUMN task_number SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_number + ON tasks (task_number); diff --git a/supabase/migrations/20260221123000_improve_task_number_generation.sql b/supabase/migrations/20260221123000_improve_task_number_generation.sql new file mode 100644 index 00000000..1a475f57 --- /dev/null +++ b/supabase/migrations/20260221123000_improve_task_number_generation.sql @@ -0,0 +1,51 @@ +-- Improve task_number generation to eliminate race conditions. +-- Maintain a separate counter table so concurrent inserts can safely bump the +-- sequence using an atomic upsert. This migration retains the existing +-- trigger name but replaces its body, and creates the helper table. + +-- 1. Create counter table +CREATE TABLE IF NOT EXISTS task_number_counters ( + year_month text PRIMARY KEY, + counter bigint NOT NULL +); + +-- 2. Initialize counters from existing tasks +INSERT INTO task_number_counters(year_month, counter) +SELECT s.prefix, s.cnt +FROM ( + SELECT to_char(created_at::timestamp, 'YYYY-MM-') AS prefix, + max((substring(task_number FROM 8))::int) AS cnt + FROM tasks + GROUP BY prefix +) s +ON CONFLICT (year_month) DO UPDATE SET counter = EXCLUDED.counter; + +-- 3. Replace trigger function with atomic counter logic +CREATE OR REPLACE FUNCTION tasks_set_task_number() +RETURNS trigger AS $$ +DECLARE + prefix text; + seq bigint; +BEGIN + IF NEW.task_number IS NOT NULL THEN + RETURN NEW; + END IF; + + prefix := to_char(now(), 'YYYY-MM-'); + INSERT INTO task_number_counters(year_month, counter) + VALUES (prefix, 1) + ON CONFLICT (year_month) + DO UPDATE SET counter = task_number_counters.counter + 1 + RETURNING counter INTO seq; + + NEW.task_number := prefix || lpad(seq::text, 5, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 4. Recreate trigger (drop/recreate to ensure correct function is bound) +DROP TRIGGER IF EXISTS tasks_set_task_number_before_insert ON tasks; +CREATE TRIGGER tasks_set_task_number_before_insert +BEFORE INSERT ON tasks +FOR EACH ROW +EXECUTE FUNCTION tasks_set_task_number(); diff --git a/supabase/migrations/20260221131000_insert_task_rpc.sql b/supabase/migrations/20260221131000_insert_task_rpc.sql new file mode 100644 index 00000000..fda9be6e --- /dev/null +++ b/supabase/migrations/20260221131000_insert_task_rpc.sql @@ -0,0 +1,55 @@ +-- Atomic insert that generates a unique task_number and inserts a task in one transaction +-- Returns: id (uuid), task_number (text) + +CREATE OR REPLACE FUNCTION insert_task_with_number( + p_title text, + p_description text, + p_office_id text, + p_ticket_id text, + p_request_type text, + p_request_type_other text, + p_request_category text, + p_creator_id uuid +) +RETURNS TABLE(id uuid, task_number text) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + -- The numbering trigger on `tasks` will set `task_number` before insert. + -- This RPC inserts the task row and returns the resulting id and task_number. +BEGIN + -- atomically increment (or create) the month counter and use it as the task_number + INSERT INTO task_number_counters(year_month, counter) + VALUES (to_char(now(), 'YYYY-MM-'), 1) + ON CONFLICT (year_month) DO UPDATE + SET counter = task_number_counters.counter + 1 + RETURNING counter INTO seq; + + -- build the formatted task number + PERFORM seq; -- ensure seq is set + new_task_number := to_char(now(), 'YYYY-MM-') || lpad(seq::text, 5, '0'); + + INSERT INTO tasks( + title, description, office_id, ticket_id, + request_type, request_type_other, request_category, + creator_id, created_at, task_number + ) VALUES ( + p_title, + p_description, + CASE WHEN p_office_id IS NULL OR p_office_id = '' THEN NULL ELSE p_office_id::uuid END, + CASE WHEN p_ticket_id IS NULL OR p_ticket_id = '' THEN NULL ELSE p_ticket_id::uuid END, + CASE WHEN p_request_type IS NULL OR p_request_type = '' THEN NULL ELSE p_request_type::request_type END, + p_request_type_other, + CASE WHEN p_request_category IS NULL OR p_request_category = '' THEN NULL ELSE p_request_category::request_category END, + p_creator_id, + now(), + new_task_number + ) RETURNING tasks.id, tasks.task_number INTO id, task_number; + + RETURN NEXT; +END; +$$; + +-- Grant execute to authenticated so client RPC can call it +GRANT EXECUTE ON FUNCTION insert_task_with_number(text,text,text,text,text,text,text,uuid) TO authenticated;