Add premium My Case MVP
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/FreeTier.php';
|
||||
require_once __DIR__ . '/CaseStore.php';
|
||||
|
||||
/**
|
||||
* Persistent tool-run results per case (premium "Saved analyses" feature).
|
||||
*
|
||||
* Every successful Korrespond / Advocate / BVJ / Deep Research / Discrepancy / Timeline run
|
||||
* for a paid (Plus or Pro) user — including active trial — is written to `case_tool_results`.
|
||||
* Free users do not persist results; save() silently no-ops for them.
|
||||
*
|
||||
* Storage layout:
|
||||
* user_id — session user (may be a family member)
|
||||
* owner_user_id — CaseStore::caseResolveClientId result; whose corpus the run touched
|
||||
* tool — tool slug
|
||||
* input_payload — the entire request body (used by Re-run)
|
||||
* output_payload — the entire tool response
|
||||
* used_case_context + case_doc_ids — true when the user toggled "Bruk min sak"
|
||||
*/
|
||||
final class CaseResults
|
||||
{
|
||||
/** Tools that participate in the saved-results system. Other tools (search, corpus, etc.) are not persisted. */
|
||||
public const ELIGIBLE_TOOLS = [
|
||||
'korrespond',
|
||||
'advocate',
|
||||
'barnevernet',
|
||||
'deep-research',
|
||||
'discrepancy',
|
||||
'timeline',
|
||||
];
|
||||
|
||||
/** True when the user is on a tier that gets saved results (Plus, Pro, or active Plus trial). */
|
||||
public static function isEnabled(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$tier = FreeTier::tier($userId);
|
||||
return FreeTier::isPaidTier($tier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a completed tool run. Returns the new row id, or 0 if the user isn't eligible
|
||||
* (so callers can wrap unconditionally without if-branches).
|
||||
*
|
||||
* @param array $meta {
|
||||
* used_case_context: 0|1,
|
||||
* case_doc_ids: int[],
|
||||
* model: string|null,
|
||||
* latency_ms: int|null,
|
||||
* credits_charged: int,
|
||||
* }
|
||||
*/
|
||||
public static function save(
|
||||
int $userId,
|
||||
int $ownerUserId,
|
||||
string $tool,
|
||||
array $input,
|
||||
array $output,
|
||||
array $meta = []
|
||||
): int {
|
||||
if (!self::isEnabled($userId)) {
|
||||
return 0;
|
||||
}
|
||||
if (!in_array($tool, self::ELIGIBLE_TOOLS, true)) {
|
||||
return 0;
|
||||
}
|
||||
if (!self::tableReady()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Title default: first 80 chars of the most descriptive input field.
|
||||
$title = self::deriveTitle($tool, $input);
|
||||
|
||||
$caseDocIds = $meta['case_doc_ids'] ?? [];
|
||||
if (!is_array($caseDocIds)) {
|
||||
$caseDocIds = [];
|
||||
}
|
||||
$caseDocIds = array_values(array_unique(array_map('intval', $caseDocIds)));
|
||||
|
||||
$db = dbnmDb();
|
||||
$db->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user