prepare( 'INSERT INTO case_tool_results (user_id, owner_user_id, tool, title, used_case_context, case_doc_ids, input_payload, output_payload, model, latency_ms, credits_charged, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())' )->execute([ $userId, $ownerUserId > 0 ? $ownerUserId : $userId, $tool, $title, !empty($meta['used_case_context']) ? 1 : 0, json_encode($caseDocIds, JSON_UNESCAPED_UNICODE), json_encode($input, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), json_encode($output, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $meta['model'] ?? null, isset($meta['latency_ms']) ? (int)$meta['latency_ms'] : null, (int)($meta['credits_charged'] ?? 0), ]); return (int)$db->lastInsertId(); } /** List a user's saved results (also visible across family seats). */ public static function listForUser(int $userId, int $limit = 50): array { if ($userId <= 0) { return []; } if (!self::tableReady()) { return []; } $ownerId = CaseStore::caseResolveClientId($userId); $db = dbnmDb(); $stmt = $db->prepare( 'SELECT id, user_id, owner_user_id, tool, title, used_case_context, model, latency_ms, credits_charged, pinned, created_at FROM case_tool_results WHERE owner_user_id = ? AND deleted_at IS NULL ORDER BY pinned DESC, created_at DESC LIMIT ' . max(1, min(200, $limit)) ); $stmt->execute([$ownerId]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } /** Single result row with full payloads, ownership-checked. */ public static function get(int $userId, int $resultId): ?array { if ($userId <= 0 || $resultId <= 0) { return null; } if (!self::tableReady()) { return null; } $ownerId = CaseStore::caseResolveClientId($userId); $db = dbnmDb(); $stmt = $db->prepare( 'SELECT * FROM case_tool_results WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL LIMIT 1' ); $stmt->execute([$resultId, $ownerId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $row['case_doc_ids'] = json_decode((string)$row['case_doc_ids'], true) ?: []; $row['input_payload'] = json_decode((string)$row['input_payload'], true) ?: []; $row['output_payload'] = json_decode((string)$row['output_payload'], true) ?: []; return $row; } /** Soft-delete a result. Returns true if the row was found and deleted. */ public static function softDelete(int $userId, int $resultId): bool { if ($userId <= 0 || $resultId <= 0) { return false; } if (!self::tableReady()) { return false; } $ownerId = CaseStore::caseResolveClientId($userId); $db = dbnmDb(); $stmt = $db->prepare( 'UPDATE case_tool_results SET deleted_at = NOW() WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL' ); $stmt->execute([$resultId, $ownerId]); return $stmt->rowCount() > 0; } /** Toggle the pinned flag. Returns the new pinned state, or null if not found. */ public static function togglePin(int $userId, int $resultId): ?bool { if ($userId <= 0 || $resultId <= 0) { return null; } if (!self::tableReady()) { return null; } $ownerId = CaseStore::caseResolveClientId($userId); $db = dbnmDb(); $stmt = $db->prepare( 'UPDATE case_tool_results SET pinned = 1 - pinned WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL' ); $stmt->execute([$resultId, $ownerId]); if ($stmt->rowCount() === 0) { return null; } $check = $db->prepare('SELECT pinned FROM case_tool_results WHERE id = ?'); $check->execute([$resultId]); return (bool)$check->fetchColumn(); } /** Update the user-editable title. Returns true on success. */ public static function updateTitle(int $userId, int $resultId, string $title): bool { $title = mb_substr(trim($title), 0, 200, 'UTF-8'); if ($title === '') { return false; } if (!self::tableReady()) { return false; } $ownerId = CaseStore::caseResolveClientId($userId); $db = dbnmDb(); $stmt = $db->prepare( 'UPDATE case_tool_results SET title = ? WHERE id = ? AND owner_user_id = ? AND deleted_at IS NULL' ); $stmt->execute([$title, $resultId, $ownerId]); return $stmt->rowCount() > 0; } /** Human-readable Norwegian title hint per tool. */ public static function toolLabel(string $tool): string { return [ 'korrespond' => 'Korrespondanse', 'advocate' => 'Advokatutkast', 'barnevernet' => 'BVJ-analyse', 'deep-research' => 'Dyp analyse', 'discrepancy' => 'Motstrid', 'timeline' => 'Tidslinje', ][$tool] ?? ucfirst($tool); } /** Tool icon (emoji for now, can swap to SVG later). */ public static function toolIcon(string $tool): string { return [ 'korrespond' => '✉️', 'advocate' => '⚖️', 'barnevernet' => '🛡️', 'deep-research' => '🔬', 'discrepancy' => '🔍', 'timeline' => '📅', ][$tool] ?? '📄'; } /** Derive a default title from the input payload (best-effort per tool). */ private static function deriveTitle(string $tool, array $input): string { $candidates = match ($tool) { 'korrespond' => [$input['goal'] ?? null, $input['narrative'] ?? null, $input['case_ref'] ?? null], 'advocate' => [$input['question'] ?? null, $input['facts'] ?? null, $input['topic'] ?? null], 'barnevernet' => [$input['document_type'] ?? null, $input['summary'] ?? null, $input['text'] ?? null], 'deep-research' => [$input['question'] ?? null, $input['query'] ?? null, $input['topic'] ?? null], 'discrepancy' => [$input['focus'] ?? null, $input['context'] ?? null], 'timeline' => [$input['context'] ?? null, $input['text'] ?? null], default => [$input['title'] ?? null, $input['query'] ?? null, $input['text'] ?? null], }; foreach ($candidates as $c) { $c = is_string($c) ? trim($c) : ''; if ($c !== '') { return mb_substr($c, 0, 80, 'UTF-8'); } } return self::toolLabel($tool) . ' — ' . date('j. M Y H:i'); } /** Guard against deployment order: code may arrive just before the DB migration. */ private static function tableReady(): bool { static $ready = null; if ($ready !== null) { return $ready; } try { $stmt = dbnmDb()->prepare( 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?' ); $stmt->execute(['case_tool_results']); $ready = ((int)$stmt->fetchColumn()) > 0; } catch (Throwable $e) { error_log('[CaseResults] table readiness check failed: ' . $e->getMessage()); $ready = false; } return $ready; } }