syncAssignmentCycleData((int) $existing['id'], $cycleStartAt, $cycleEndAt, $dueAt); return 0; } if ($cycleEndAt !== null && trim($cycleEndAt) !== '') { $cycleEndMatch = TaskCertDb::fetchOne( 'SELECT id FROM task_assignment WHERE task_id = ? AND main_contact_id = ? AND cycle_end_at = ? ORDER BY id DESC LIMIT 1', 'iis', [$taskId, $contactId, $cycleEndAt] ); if ($cycleEndMatch !== null) { $this->rekeyAndSyncAssignment( (int) $cycleEndMatch['id'], $cycleKey, $cycleStartAt, $cycleEndAt, $dueAt ); return 0; } } return TaskCertDb::insert( 'INSERT INTO task_assignment (task_id, main_contact_id, cycle_key, status, assigned_source, cycle_start_at, cycle_end_at, due_at, grace_until) VALUES (?, ?, ?, "open", ?, ?, ?, ?, ?)', 'iissssss', [$taskId, $contactId, $cycleKey, $assignedSource, $cycleStartAt, $cycleEndAt, $dueAt, $graceUntil] ); } private function syncAssignmentCycleData( int $assignmentId, ?string $cycleStartAt, ?string $cycleEndAt, ?string $dueAt ): void { TaskCertDb::execute( 'UPDATE task_assignment SET cycle_start_at = ?, cycle_end_at = ?, due_at = ?, modified_at = NOW() WHERE id = ?', 'sssi', [$cycleStartAt, $cycleEndAt, $dueAt, $assignmentId] ); } private function rekeyAndSyncAssignment( int $assignmentId, string $cycleKey, ?string $cycleStartAt, ?string $cycleEndAt, ?string $dueAt ): void { TaskCertDb::execute( 'UPDATE task_assignment SET cycle_key = ?, cycle_start_at = ?, cycle_end_at = ?, due_at = ?, modified_at = NOW() WHERE id = ?', 'ssssi', [$cycleKey, $cycleStartAt, $cycleEndAt, $dueAt, $assignmentId] ); } public function findById(int $assignmentId): ?array { return TaskCertDb::fetchOne('SELECT * FROM task_assignment WHERE id = ?', 'i', [$assignmentId]); } public function listForUser(int $contactId): array { return TaskCertDb::fetchAll( 'SELECT a.*, d.title, d.description, d.task_category_id, cat.name AS task_category_name, cat.slug AS task_category_slug, d.fulfillment_mode, d.show_cycle_period, d.certificate_enabled, d.certificate_title, cert.valid_until AS certificate_valid_until, cert.status AS certificate_status FROM task_assignment a INNER JOIN task_definition d ON d.id = a.task_id LEFT JOIN task_category cat ON cat.id = d.task_category_id LEFT JOIN task_certificate cert ON cert.assignment_id = a.id WHERE a.main_contact_id = ? ORDER BY CASE a.status WHEN "overdue" THEN 1 WHEN "rejected" THEN 2 WHEN "open" THEN 3 WHEN "in_progress" THEN 4 WHEN "approval_pending" THEN 5 WHEN "completed" THEN 6 WHEN "expired" THEN 7 ELSE 7 END, a.due_at IS NULL, a.due_at ASC, a.id DESC', 'i', [$contactId] ); } public function listForAdmin(int $taskId = 0, string $status = '', string $searchQuery = ''): array { $searchQuery = trim($searchQuery); $sql = 'SELECT a.*, d.title AS task_title, c.name AS contact_name, c.email AS contact_email FROM task_assignment a INNER JOIN task_definition d ON d.id = a.task_id LEFT JOIN main_contact c ON c.id = a.main_contact_id WHERE 1=1'; $types = ''; $params = []; if ($taskId > 0) { $sql .= ' AND a.task_id = ?'; $types .= 'i'; $params[] = $taskId; } if ($status !== '') { $sql .= ' AND a.status = ?'; $types .= 's'; $params[] = $status; } if ($searchQuery !== '') { $searchLike = '%' . $searchQuery . '%'; $sql .= ' AND ( CAST(a.id AS CHAR) LIKE ? OR d.title LIKE ? OR COALESCE(c.name, "") LIKE ? OR COALESCE(c.email, "") LIKE ? OR a.status LIKE ? )'; $types .= 'sssss'; $params[] = $searchLike; $params[] = $searchLike; $params[] = $searchLike; $params[] = $searchLike; $params[] = $searchLike; } $sql .= ' ORDER BY a.escalation_level DESC, a.due_at ASC, a.id DESC'; return TaskCertDb::fetchAll($sql, $types, $params); } public function listByTaskAndCycleKey(int $taskId, string $cycleKey): array { return TaskCertDb::fetchAll( 'SELECT * FROM task_assignment WHERE task_id = ? AND cycle_key = ? ORDER BY id ASC', 'is', [$taskId, $cycleKey] ); } public function listByTask(int $taskId): array { return TaskCertDb::fetchAll( 'SELECT * FROM task_assignment WHERE task_id = ? ORDER BY id ASC', 'i', [$taskId] ); } public function listByContactId(int $contactId): array { return TaskCertDb::fetchAll( 'SELECT a.id, a.task_id, a.status, a.due_at, a.grace_until, a.created_at, a.cycle_key, a.escalation_level, d.title AS task_title, d.active AS task_active, tc.name AS task_category_name, ls.last_submitted_at FROM task_assignment a INNER JOIN task_definition d ON d.id = a.task_id LEFT JOIN task_category tc ON tc.id = d.task_category_id LEFT JOIN ( SELECT assignment_id, MAX(submitted_at) AS last_submitted_at FROM task_submission GROUP BY assignment_id ) ls ON ls.assignment_id = a.id WHERE a.main_contact_id = ? ORDER BY CASE a.status WHEN "overdue" THEN 1 WHEN "open" THEN 2 WHEN "in_progress" THEN 3 WHEN "approval_pending" THEN 4 WHEN "rejected" THEN 5 WHEN "completed" THEN 6 WHEN "expired" THEN 7 ELSE 8 END, a.due_at ASC', 'i', [$contactId] ); } public function findLatestByTaskAndContact(int $taskId, int $contactId): ?array { return TaskCertDb::fetchOne( 'SELECT * FROM task_assignment WHERE task_id = ? AND main_contact_id = ? ORDER BY id DESC LIMIT 1', 'ii', [$taskId, $contactId] ); } public function listLatestByTaskForContacts(int $taskId, array $contactIds): array { $contactIds = array_values(array_unique(array_map('intval', $contactIds))); $contactIds = array_values(array_filter($contactIds, static function (int $contactId): bool { return $contactId > 0; })); if ($taskId <= 0 || $contactIds === []) { return []; } $placeholders = implode(',', array_fill(0, count($contactIds), '?')); $types = 'i' . str_repeat('i', count($contactIds)); $params = array_merge([$taskId], $contactIds); return TaskCertDb::fetchAll( 'SELECT a.* FROM task_assignment a INNER JOIN ( SELECT main_contact_id, MAX(id) AS latest_id FROM task_assignment WHERE task_id = ? AND main_contact_id IN (' . $placeholders . ') GROUP BY main_contact_id ) latest ON latest.latest_id = a.id ORDER BY a.main_contact_id ASC', $types, $params ); } public function listRemovableOpenByTaskAndContacts(int $taskId, array $contactIds): array { $contactIds = array_values(array_unique(array_map('intval', $contactIds))); $contactIds = array_values(array_filter($contactIds, static function (int $contactId): bool { return $contactId > 0; })); if ($taskId <= 0 || $contactIds === []) { return []; } $placeholders = implode(',', array_fill(0, count($contactIds), '?')); $types = 'i' . str_repeat('i', count($contactIds)); $params = array_merge([$taskId], $contactIds); return TaskCertDb::fetchAll( 'SELECT * FROM task_assignment WHERE task_id = ? AND main_contact_id IN (' . $placeholders . ') AND status IN ("open", "in_progress", "approval_pending", "overdue", "rejected", "completed", "expired") ORDER BY id ASC', $types, $params ); } public function updateStatus(int $assignmentId, string $status, ?string $completedAt = null): void { TaskCertDb::execute( 'UPDATE task_assignment SET status = ?, completed_at = ?, modified_at = NOW() WHERE id = ?', 'ssi', [$status, $completedAt, $assignmentId] ); } public function markInProgressIfOpen(int $assignmentId): void { TaskCertDb::execute( 'UPDATE task_assignment SET status = "in_progress", modified_at = NOW() WHERE id = ? AND status = "open"', 'i', [$assignmentId] ); } public function setEscalationLevel(int $assignmentId, int $level): void { TaskCertDb::execute( 'UPDATE task_assignment SET escalation_level = ?, last_escalated_at = NOW(), modified_at = NOW() WHERE id = ?', 'ii', [$level, $assignmentId] ); } public function clearEscalation(int $assignmentId): void { TaskCertDb::execute( 'UPDATE task_assignment SET escalation_level = 0, last_escalated_at = NULL, modified_at = NOW() WHERE id = ?', 'i', [$assignmentId] ); } public function addEscalationEvent(int $assignmentId, int $level, string $eventType, array $snapshot): int { return TaskCertDb::insert( 'INSERT INTO task_escalation_event (assignment_id, escalation_level, event_type, snapshot_json) VALUES (?, ?, ?, ?)', 'iiss', [ $assignmentId, $level, $eventType, json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ] ); } public function findEscalationCandidates(?int $taskId = null): array { $sql = 'SELECT a.*, d.title AS task_title FROM task_assignment a INNER JOIN task_definition d ON d.id = a.task_id WHERE d.active = 1 AND a.status IN ("open", "in_progress", "approval_pending", "overdue", "rejected") AND a.due_at IS NOT NULL'; $types = ''; $params = []; if ($taskId !== null && $taskId > 0) { $sql .= ' AND a.task_id = ?'; $types = 'i'; $params[] = $taskId; } return TaskCertDb::fetchAll($sql, $types, $params); } public function markOverdue(int $assignmentId, string $overdueAt): void { TaskCertDb::execute( 'UPDATE task_assignment SET status = "overdue", overdue_at = COALESCE(overdue_at, ?), modified_at = NOW() WHERE id = ? AND status IN ("open", "in_progress", "approval_pending", "overdue", "rejected")', 'si', [$overdueAt, $assignmentId] ); } public function clearOverdue(int $assignmentId): void { TaskCertDb::execute( 'UPDATE task_assignment SET status = "in_progress", overdue_at = NULL, modified_at = NOW() WHERE id = ? AND status = "overdue"', 'i', [$assignmentId] ); } public function resetByIds(array $assignmentIds): int { $assignmentIds = array_values(array_unique(array_map('intval', $assignmentIds))); $assignmentIds = array_values(array_filter($assignmentIds, static function (int $assignmentId): bool { return $assignmentId > 0; })); if ($assignmentIds === []) { return 0; } $placeholders = implode(',', array_fill(0, count($assignmentIds), '?')); return TaskCertDb::execute( 'UPDATE task_assignment SET status = "open", completed_at = NULL, overdue_at = NULL, escalation_level = 0, last_escalated_at = NULL, modified_at = NOW() WHERE id IN (' . $placeholders . ')', str_repeat('i', count($assignmentIds)), $assignmentIds ); } public function deleteByIds(array $assignmentIds): int { $assignmentIds = array_values(array_unique(array_map('intval', $assignmentIds))); $assignmentIds = array_values(array_filter($assignmentIds, static function (int $assignmentId): bool { return $assignmentId > 0; })); if ($assignmentIds === []) { return 0; } $placeholders = implode(',', array_fill(0, count($assignmentIds), '?')); return TaskCertDb::execute( 'DELETE FROM task_assignment WHERE id IN (' . $placeholders . ')', str_repeat('i', count($assignmentIds)), $assignmentIds ); } public function listTaskAssignmentSummary(int $taskId): array { $rows = TaskCertDb::fetchAll( 'SELECT status, COUNT(*) AS cnt FROM task_assignment WHERE task_id = ? GROUP BY status', 'i', [$taskId] ); $summary = [ 'open' => 0, 'in_progress' => 0, 'approval_pending' => 0, 'overdue' => 0, 'completed' => 0, 'expired' => 0, 'rejected' => 0, ]; foreach ($rows as $row) { $status = (string) $row['status']; if (!array_key_exists($status, $summary)) { continue; } $summary[$status] = (int) $row['cnt']; } return $summary; } }