Add premium My Case MVP

This commit is contained in:
2026-05-23 10:17:34 +02:00
parent e0aeefc73e
commit 83fc71414f
33 changed files with 1275 additions and 148 deletions
+28 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php';
require_once __DIR__ . '/../includes/CaseResults.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -54,7 +56,7 @@ try {
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
$engine = (string)($input['engine'] ?? 'azure_mini');
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
$sliceInput = $input['slices'] ?? [];
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
$additionalNotes = mb_substr(trim((string)($input['additional_notes'] ?? '')), 0, 2000, 'UTF-8');
@@ -112,6 +114,17 @@ try {
'file_count' => count($uploadedFiles),
]);
// Optional: append user's case-context chunks to the last uploaded document text,
// so the agent reads them as supplementary background.
$useMyCase = !empty($input['use_my_case']);
if ($useMyCase && !empty($uploadedFiles)) {
$retrievalQuery = mb_substr((string)$uploadedFiles[0]['text'], 0, 2000, 'UTF-8');
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
if ($caseBlock !== '') {
$uploadedFiles[0]['text'] .= "\n\n" . $caseBlock;
}
}
$result = (new DbnBvjAnalyzerAgent())->run(
$uploadedFiles,
$advocateRole,
@@ -138,6 +151,20 @@ try {
'bvj_doc_type' => $result['doc_meta']['doc_type'] ?? null,
]);
if ($ftUid > 0) {
$ownerId = CaseStore::caseResolveClientId($ftUid);
$resultId = CaseResults::save($ftUid, $ownerId, 'barnevernet', $input, $result, [
'used_case_context' => $useMyCase ? 1 : 0,
'case_doc_ids' => dbnToolsLastCaseDocIds(),
'model' => $result['trace_metadata']['deployment'] ?? $engine,
'latency_ms' => $result['latency_ms'],
'credits_charged' => FreeTier::cost('barnevernet'),
]);
if ($resultId > 0) {
$result['result_id'] = $resultId;
}
}
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/CaseResults.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
if (!dbnToolsIsFreeTier()) {
dbnToolsError('Saved analyses are SSO-only.', 403, 'sso_only');
}
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
if ($userId <= 0) {
dbnToolsError('Missing user id.', 401, 'no_user');
}
$input = dbnToolsJsonInput(4000);
$action = (string)($input['action'] ?? '');
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
dbnToolsError('Missing result id.', 422, 'missing_id');
}
switch ($action) {
case 'pin':
$pinned = CaseResults::togglePin($userId, $id);
if ($pinned === null) {
dbnToolsError('Result not found.', 404, 'not_found');
}
dbnToolsRespond(['ok' => true, 'pinned' => $pinned]);
break;
case 'delete':
if (!CaseResults::softDelete($userId, $id)) {
dbnToolsError('Result not found or already deleted.', 404, 'not_found');
}
dbnToolsRespond(['ok' => true]);
break;
case 'rename':
$title = (string)($input['title'] ?? '');
if (!CaseResults::updateTitle($userId, $id, $title)) {
dbnToolsError('Could not rename — empty title or result not found.', 422, 'rename_failed');
}
dbnToolsRespond(['ok' => true]);
break;
default:
dbnToolsError('Unknown action.', 422, 'unknown_action');
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../includes/bootstrap.php';
require_once __DIR__ . '/../../includes/CaseResults.php';
dbnToolsRequireMethod('GET');
dbnToolsRequireAuth();
if (!dbnToolsIsFreeTier()) {
http_response_code(403);
exit('Saved analyses are SSO-only.');
}
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
$id = (int)($_GET['id'] ?? 0);
if ($userId <= 0 || $id <= 0) {
http_response_code(404);
exit('Not found.');
}
$result = CaseResults::get($userId, $id);
if (!$result) {
http_response_code(404);
exit('Not found.');
}
$filename = sprintf(
'dbn-%s-%d-%s.json',
$result['tool'],
$id,
date('Ymd-His', strtotime((string)$result['created_at']))
);
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-store');
echo json_encode([
'id' => (int)$result['id'],
'tool' => $result['tool'],
'title' => $result['title'],
'created_at' => $result['created_at'],
'model' => $result['model'],
'latency_ms' => $result['latency_ms'],
'used_case_context' => (bool)$result['used_case_context'],
'case_doc_ids' => $result['case_doc_ids'],
'input' => $result['input_payload'],
'output' => $result['output_payload'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+28 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/DeepResearchAgent.php';
require_once __DIR__ . '/../includes/CaseResults.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -59,7 +61,7 @@ try {
$seedQuery = trim((string)($input['query'] ?? ''));
$pastedText = trim((string)($input['paste_text'] ?? ''));
$sliceInput = $input['slices'] ?? [];
$engine = (string)($input['engine'] ?? 'azure_mini');
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
@@ -115,6 +117,16 @@ try {
'upload_count' => count($uploadedFiles),
]);
// Optional: append user's case-context chunks to pasted_text so the agent sees them.
$useMyCase = !empty($input['use_my_case']);
if ($useMyCase) {
$retrievalQuery = $seedQuery !== '' ? $seedQuery : mb_substr($pastedText, 0, 2000, 'UTF-8');
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
if ($caseBlock !== '') {
$pastedText = ($pastedText === '') ? $caseBlock : ($pastedText . "\n\n" . $caseBlock);
}
}
$result = (new DbnDeepResearchAgent())->run(
$seedQuery,
$pastedText,
@@ -144,6 +156,21 @@ try {
'advocate_role' => $advocateRole !== '' ? $advocateRole : null,
]);
if ($ftUid > 0) {
$toolSlug = $advocateRole !== '' ? 'advocate' : 'deep-research';
$ownerId = CaseStore::caseResolveClientId($ftUid);
$resultId = CaseResults::save($ftUid, $ownerId, $toolSlug, $input, $result, [
'used_case_context' => $useMyCase ? 1 : 0,
'case_doc_ids' => dbnToolsLastCaseDocIds(),
'model' => $result['trace_metadata']['deployment'] ?? $engine,
'latency_ms' => $result['latency_ms'],
'credits_charged' => FreeTier::cost($toolSlug),
]);
if ($resultId > 0) {
$result['result_id'] = $resultId;
}
}
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
+27 -1
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/DiscrepancyAgent.php';
require_once __DIR__ . '/../includes/CaseResults.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -42,7 +44,7 @@ try {
}
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
$engine = (string)($input['engine'] ?? 'azure_mini');
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
$sliceInput = $input['slices'] ?? [];
// Extract file A
@@ -111,6 +113,16 @@ try {
'file_b' => $fileB['filename'],
]);
// Optional: append the user's case-context as supplementary background to Doc A.
$useMyCase = !empty($input['use_my_case']);
if ($useMyCase) {
$retrievalQuery = mb_substr((string)$fileA['text'], 0, 2000, 'UTF-8');
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
if ($caseBlock !== '') {
$fileA['text'] .= "\n\n" . $caseBlock;
}
}
$result = (new DbnDiscrepancyAgent())->run(
$fileA,
$fileB,
@@ -135,6 +147,20 @@ try {
'deployment' => $result['trace_metadata']['deployment'] ?? null,
]);
if ($ftUid > 0) {
$ownerId = CaseStore::caseResolveClientId($ftUid);
$resultId = CaseResults::save($ftUid, $ownerId, 'discrepancy', $input, $result, [
'used_case_context' => $useMyCase ? 1 : 0,
'case_doc_ids' => dbnToolsLastCaseDocIds(),
'model' => $result['trace_metadata']['deployment'] ?? $engine,
'latency_ms' => $result['latency_ms'],
'credits_charged' => FreeTier::cost('discrepancy'),
]);
if ($resultId > 0) {
$result['result_id'] = $resultId;
}
}
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
+16
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/KorrespondAgent.php';
require_once __DIR__ . '/../includes/CaseResults.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -184,6 +185,21 @@ try {
'deployment' => 'gpt-4o',
]);
// Premium: persist the run for paid (Plus/Pro) users so it shows up in Min Sak → Saved analyses.
if ($ftUid > 0) {
$ownerId = CaseStore::caseResolveClientId($ftUid);
$resultId = CaseResults::save($ftUid, $ownerId, 'korrespond', $input, $result, [
'used_case_context' => !empty($intake['use_my_case']) ? 1 : 0,
'case_doc_ids' => dbnToolsLastCaseDocIds(),
'model' => 'gpt-4o',
'latency_ms' => $result['latency_ms'],
'credits_charged' => FreeTier::cost('korrespond'),
]);
if ($resultId > 0) {
$result['result_id'] = $resultId;
}
}
$emit('final', ['result' => $result]);
} catch (DbnToolsHttpException $e) {
+10 -1
View File
@@ -18,7 +18,7 @@ if ($userId <= 0 || $email === '') {
$input = dbnToolsJsonInput(2000);
$sku = (string)($input['sku'] ?? '');
$validSubscriptions = ['light', 'pro', 'pro_plus'];
$validSubscriptions = ['plus', 'pro'];
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
@@ -55,9 +55,18 @@ try {
];
if ($isSub) {
FreeTier::ensureRow($userId);
$detail = FreeTier::balanceDetail($userId);
$params['subscription_data'] = [
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
];
if ($sku === 'plus' && empty($detail['trial_started_at'])) {
$params['subscription_data']['trial_period_days'] = 14;
$params['subscription_data']['trial_settings'] = [
'end_behavior' => ['missing_payment_method' => 'cancel'],
];
}
$params['payment_method_collection'] = 'always';
} else {
$params['payment_intent_data'] = [
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
+7 -1
View File
@@ -76,6 +76,10 @@ try {
handleSubscriptionDeleted($db, $object);
break;
case 'customer.subscription.trial_will_end':
// Stripe sends the customer reminder email. We mirror state on subscription.updated/deleted.
break;
case 'invoice.paid':
handleInvoicePaid($db, $object);
break;
@@ -154,8 +158,10 @@ function handleSubscriptionChange(PDO $db, array $sub): void
$periodEndTs = (int)($sub['current_period_end'] ?? 0);
$periodStartTs = (int)($sub['current_period_start'] ?? 0);
$trialEndTs = (int)($sub['trial_end'] ?? 0);
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
$periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : null;
$trialEndIso = $trialEndTs > 0 ? gmdate('Y-m-d H:i:s', $trialEndTs) : null;
// Upsert subscription ledger
$db->prepare(
@@ -172,7 +178,7 @@ function handleSubscriptionChange(PDO $db, array $sub): void
// Only flip the live tier flag if subscription is active/trialing.
if (in_array($status, ['active', 'trialing'], true)) {
FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso);
FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso, $status === 'trialing' ? $trialEndIso : null);
} elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) {
FreeTier::clearTier($userId);
}
+31 -2
View File
@@ -2,6 +2,8 @@
declare(strict_types=1);
require_once __DIR__ . '/../includes/LegalTools.php';
require_once __DIR__ . '/../includes/CaseResults.php';
require_once __DIR__ . '/../includes/ToolModels.php';
dbnToolsRequireMethod('POST');
dbnToolsRequireAuth();
@@ -11,12 +13,13 @@ if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
$input = dbnToolsJsonInput(400000);
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language): array {
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array {
$text = dbnToolsString($input, 'text', 128000);
$validEngines = ['azure_mini', 'azure_full', 'gpu'];
$engine = in_array((string)($input['engine'] ?? ''), $validEngines, true)
? (string)$input['engine'] : 'azure_mini';
$engine = ToolModels::engineForUser($ftUid, $engine);
$validFocus = ['all', 'deadlines', 'hearings', 'cps'];
$focus = in_array((string)($input['focus'] ?? ''), $validFocus, true)
@@ -29,5 +32,31 @@ dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language)
$includeBackground = ($input['include_background'] ?? true) !== false;
$userNotes = dbnToolsString($input, 'user_notes', 2000, false);
return (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes);
// Optional: prepend the user's case-context chunks so the timeline includes events
// referenced in their uploaded case documents.
$useMyCase = !empty($input['use_my_case']);
if ($useMyCase) {
$caseBlock = dbnToolsCaseContext(true, $text, 5);
if ($caseBlock !== '') {
$text = $text . "\n\n" . $caseBlock;
}
}
$result = (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes);
// Persist for paid users (silently no-op for free)
if ($ftUid > 0) {
$ownerId = CaseStore::caseResolveClientId($ftUid);
$resultId = CaseResults::save($ftUid, $ownerId, 'timeline', $input, $result, [
'used_case_context' => $useMyCase ? 1 : 0,
'case_doc_ids' => dbnToolsLastCaseDocIds(),
'model' => $engine,
'latency_ms' => (int)($result['latency_ms'] ?? 0),
'credits_charged' => FreeTier::cost('timeline'),
]);
if ($resultId > 0) {
$result['result_id'] = $resultId;
}
}
return $result;
});