Add premium My Case MVP
This commit is contained in:
@@ -192,6 +192,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="advStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="advStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<div class="form-footer__btns">
|
<div class="form-footer__btns">
|
||||||
|
|||||||
+28
-1
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php';
|
require_once __DIR__ . '/../includes/BvjAnalyzerAgent.php';
|
||||||
|
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||||
|
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
@@ -54,7 +56,7 @@ try {
|
|||||||
|
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||||
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
||||||
$engine = (string)($input['engine'] ?? 'azure_mini');
|
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
||||||
$sliceInput = $input['slices'] ?? [];
|
$sliceInput = $input['slices'] ?? [];
|
||||||
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
||||||
$additionalNotes = mb_substr(trim((string)($input['additional_notes'] ?? '')), 0, 2000, 'UTF-8');
|
$additionalNotes = mb_substr(trim((string)($input['additional_notes'] ?? '')), 0, 2000, 'UTF-8');
|
||||||
@@ -112,6 +114,17 @@ try {
|
|||||||
'file_count' => count($uploadedFiles),
|
'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(
|
$result = (new DbnBvjAnalyzerAgent())->run(
|
||||||
$uploadedFiles,
|
$uploadedFiles,
|
||||||
$advocateRole,
|
$advocateRole,
|
||||||
@@ -138,6 +151,20 @@ try {
|
|||||||
'bvj_doc_type' => $result['doc_meta']['doc_type'] ?? null,
|
'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]);
|
$emit('final', ['result' => $result]);
|
||||||
|
|
||||||
} catch (DbnToolsHttpException $e) {
|
} catch (DbnToolsHttpException $e) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/../includes/DeepResearchAgent.php';
|
require_once __DIR__ . '/../includes/DeepResearchAgent.php';
|
||||||
|
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||||
|
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
@@ -59,7 +61,7 @@ try {
|
|||||||
$seedQuery = trim((string)($input['query'] ?? ''));
|
$seedQuery = trim((string)($input['query'] ?? ''));
|
||||||
$pastedText = trim((string)($input['paste_text'] ?? ''));
|
$pastedText = trim((string)($input['paste_text'] ?? ''));
|
||||||
$sliceInput = $input['slices'] ?? [];
|
$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'] : [];
|
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
||||||
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
||||||
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
|
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
|
||||||
@@ -115,6 +117,16 @@ try {
|
|||||||
'upload_count' => count($uploadedFiles),
|
'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(
|
$result = (new DbnDeepResearchAgent())->run(
|
||||||
$seedQuery,
|
$seedQuery,
|
||||||
$pastedText,
|
$pastedText,
|
||||||
@@ -144,6 +156,21 @@ try {
|
|||||||
'advocate_role' => $advocateRole !== '' ? $advocateRole : null,
|
'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]);
|
$emit('final', ['result' => $result]);
|
||||||
|
|
||||||
} catch (DbnToolsHttpException $e) {
|
} catch (DbnToolsHttpException $e) {
|
||||||
|
|||||||
+27
-1
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/../includes/DiscrepancyAgent.php';
|
require_once __DIR__ . '/../includes/DiscrepancyAgent.php';
|
||||||
|
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||||
|
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
@@ -42,7 +44,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||||
$engine = (string)($input['engine'] ?? 'azure_mini');
|
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
||||||
$sliceInput = $input['slices'] ?? [];
|
$sliceInput = $input['slices'] ?? [];
|
||||||
|
|
||||||
// Extract file A
|
// Extract file A
|
||||||
@@ -111,6 +113,16 @@ try {
|
|||||||
'file_b' => $fileB['filename'],
|
'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(
|
$result = (new DbnDiscrepancyAgent())->run(
|
||||||
$fileA,
|
$fileA,
|
||||||
$fileB,
|
$fileB,
|
||||||
@@ -135,6 +147,20 @@ try {
|
|||||||
'deployment' => $result['trace_metadata']['deployment'] ?? null,
|
'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]);
|
$emit('final', ['result' => $result]);
|
||||||
|
|
||||||
} catch (DbnToolsHttpException $e) {
|
} catch (DbnToolsHttpException $e) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/../includes/KorrespondAgent.php';
|
require_once __DIR__ . '/../includes/KorrespondAgent.php';
|
||||||
|
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
@@ -184,6 +185,21 @@ try {
|
|||||||
'deployment' => 'gpt-4o',
|
'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]);
|
$emit('final', ['result' => $result]);
|
||||||
|
|
||||||
} catch (DbnToolsHttpException $e) {
|
} catch (DbnToolsHttpException $e) {
|
||||||
|
|||||||
+10
-1
@@ -18,7 +18,7 @@ if ($userId <= 0 || $email === '') {
|
|||||||
$input = dbnToolsJsonInput(2000);
|
$input = dbnToolsJsonInput(2000);
|
||||||
$sku = (string)($input['sku'] ?? '');
|
$sku = (string)($input['sku'] ?? '');
|
||||||
|
|
||||||
$validSubscriptions = ['light', 'pro', 'pro_plus'];
|
$validSubscriptions = ['plus', 'pro'];
|
||||||
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
|
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
|
||||||
|
|
||||||
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
|
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
|
||||||
@@ -55,9 +55,18 @@ try {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($isSub) {
|
if ($isSub) {
|
||||||
|
FreeTier::ensureRow($userId);
|
||||||
|
$detail = FreeTier::balanceDetail($userId);
|
||||||
$params['subscription_data'] = [
|
$params['subscription_data'] = [
|
||||||
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
|
'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 {
|
} else {
|
||||||
$params['payment_intent_data'] = [
|
$params['payment_intent_data'] = [
|
||||||
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
|
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ try {
|
|||||||
handleSubscriptionDeleted($db, $object);
|
handleSubscriptionDeleted($db, $object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'customer.subscription.trial_will_end':
|
||||||
|
// Stripe sends the customer reminder email. We mirror state on subscription.updated/deleted.
|
||||||
|
break;
|
||||||
|
|
||||||
case 'invoice.paid':
|
case 'invoice.paid':
|
||||||
handleInvoicePaid($db, $object);
|
handleInvoicePaid($db, $object);
|
||||||
break;
|
break;
|
||||||
@@ -154,8 +158,10 @@ function handleSubscriptionChange(PDO $db, array $sub): void
|
|||||||
|
|
||||||
$periodEndTs = (int)($sub['current_period_end'] ?? 0);
|
$periodEndTs = (int)($sub['current_period_end'] ?? 0);
|
||||||
$periodStartTs = (int)($sub['current_period_start'] ?? 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;
|
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
|
||||||
$periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : 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
|
// Upsert subscription ledger
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
@@ -172,7 +178,7 @@ function handleSubscriptionChange(PDO $db, array $sub): void
|
|||||||
|
|
||||||
// Only flip the live tier flag if subscription is active/trialing.
|
// Only flip the live tier flag if subscription is active/trialing.
|
||||||
if (in_array($status, ['active', 'trialing'], true)) {
|
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)) {
|
} elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) {
|
||||||
FreeTier::clearTier($userId);
|
FreeTier::clearTier($userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -2,6 +2,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||||
|
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||||
|
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||||
|
|
||||||
dbnToolsRequireMethod('POST');
|
dbnToolsRequireMethod('POST');
|
||||||
dbnToolsRequireAuth();
|
dbnToolsRequireAuth();
|
||||||
@@ -11,12 +13,13 @@ if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
|||||||
$input = dbnToolsJsonInput(400000);
|
$input = dbnToolsJsonInput(400000);
|
||||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
$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);
|
$text = dbnToolsString($input, 'text', 128000);
|
||||||
|
|
||||||
$validEngines = ['azure_mini', 'azure_full', 'gpu'];
|
$validEngines = ['azure_mini', 'azure_full', 'gpu'];
|
||||||
$engine = in_array((string)($input['engine'] ?? ''), $validEngines, true)
|
$engine = in_array((string)($input['engine'] ?? ''), $validEngines, true)
|
||||||
? (string)$input['engine'] : 'azure_mini';
|
? (string)$input['engine'] : 'azure_mini';
|
||||||
|
$engine = ToolModels::engineForUser($ftUid, $engine);
|
||||||
|
|
||||||
$validFocus = ['all', 'deadlines', 'hearings', 'cps'];
|
$validFocus = ['all', 'deadlines', 'hearings', 'cps'];
|
||||||
$focus = in_array((string)($input['focus'] ?? ''), $validFocus, true)
|
$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;
|
$includeBackground = ($input['include_background'] ?? true) !== false;
|
||||||
$userNotes = dbnToolsString($input, 'user_notes', 2000, 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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -391,6 +391,7 @@
|
|||||||
language: lang,
|
language: lang,
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
advocate_role: advocateRole,
|
advocate_role: advocateRole,
|
||||||
|
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||||
};
|
};
|
||||||
if (branchContext) {
|
if (branchContext) {
|
||||||
payload.prior_context = branchContext;
|
payload.prior_context = branchContext;
|
||||||
|
|||||||
@@ -408,6 +408,7 @@
|
|||||||
slices,
|
slices,
|
||||||
controls: getControls(),
|
controls: getControls(),
|
||||||
additional_notes: additionalNotes,
|
additional_notes: additionalNotes,
|
||||||
|
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (branchContext) {
|
if (branchContext) {
|
||||||
|
|||||||
@@ -202,7 +202,10 @@
|
|||||||
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
|
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
|
||||||
renderTrace(stepState);
|
renderTrace(stepState);
|
||||||
|
|
||||||
const payload = { engine, language: lang, slices };
|
const payload = {
|
||||||
|
engine, language: lang, slices,
|
||||||
|
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||||
|
};
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('payload', JSON.stringify(payload));
|
form.append('payload', JSON.stringify(payload));
|
||||||
form.append('file_a', fileA);
|
form.append('file_a', fileA);
|
||||||
|
|||||||
@@ -274,6 +274,7 @@
|
|||||||
goal: els.goal.value.trim(),
|
goal: els.goal.value.trim(),
|
||||||
clarifications: pendingClarifications,
|
clarifications: pendingClarifications,
|
||||||
force_draft: !!forceDraft,
|
force_draft: !!forceDraft,
|
||||||
|
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1103,6 +1103,7 @@ async function runTool(event) {
|
|||||||
payload.include_relative = currentIncludeRelative();
|
payload.include_relative = currentIncludeRelative();
|
||||||
payload.include_background = currentIncludeBackground();
|
payload.include_background = currentIncludeBackground();
|
||||||
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
|
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
|
||||||
|
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label class="input-label" for="bvjNotes">Additional case context <span class="optional-hint">(optional)</span></label>
|
<label class="input-label" for="bvjNotes">Additional case context <span class="optional-hint">(optional)</span></label>
|
||||||
<textarea id="bvjNotes" name="bvjNotes" rows="4" placeholder="Add context about specific aspects of the case you want the agent to focus on, or clarify any information in the document. The document itself is the primary input."></textarea>
|
<textarea id="bvjNotes" name="bvjNotes" rows="4" placeholder="Add context about specific aspects of the case you want the agent to focus on, or clarify any information in the document. The document itself is the primary input."></textarea>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="bvjStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="bvjStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="bvjRunButton" type="submit">Analyse document</button>
|
<button id="bvjRunButton" type="submit">Analyse document</button>
|
||||||
|
|||||||
+10
-10
@@ -18,9 +18,11 @@ $detail = $userId > 0 ? FreeTier::balanceDetail($userId) : [
|
|||||||
'balance' => 0, 'bonus_balance' => 0, 'tier' => 'caveau',
|
'balance' => 0, 'bonus_balance' => 0, 'tier' => 'caveau',
|
||||||
'storage_used_bytes' => 0, 'storage_quota_bytes' => 0,
|
'storage_used_bytes' => 0, 'storage_quota_bytes' => 0,
|
||||||
'survey_completed_at' => null, 'subscription_period_end' => null,
|
'survey_completed_at' => null, 'subscription_period_end' => null,
|
||||||
|
'trial_active' => false, 'trial_days_remaining' => 0, 'trial_expires_at' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$tier = (string)$detail['tier'];
|
$tier = (string)$detail['tier'];
|
||||||
|
$isPaidTier = in_array($tier, ['plus', 'pro'], true);
|
||||||
$effective = (int)$detail['balance'] + (int)$detail['bonus_balance'];
|
$effective = (int)$detail['balance'] + (int)$detail['bonus_balance'];
|
||||||
$storageMb = round($detail['storage_used_bytes'] / 1048576, 1);
|
$storageMb = round($detail['storage_used_bytes'] / 1048576, 1);
|
||||||
$quotaMb = $detail['storage_quota_bytes'] > 0 ? round($detail['storage_quota_bytes'] / 1048576, 0) : 0;
|
$quotaMb = $detail['storage_quota_bytes'] > 0 ? round($detail['storage_quota_bytes'] / 1048576, 0) : 0;
|
||||||
@@ -28,9 +30,8 @@ $storagePct = $quotaMb > 0 ? min(100, round(($storageMb / $quotaMb) * 100)) : 0;
|
|||||||
|
|
||||||
$tierLabels = [
|
$tierLabels = [
|
||||||
'free' => 'Gratis',
|
'free' => 'Gratis',
|
||||||
'light' => 'Light',
|
'plus' => 'Plus',
|
||||||
'pro' => 'Pro',
|
'pro' => 'Pro Familie',
|
||||||
'pro_plus' => 'Pro+ Familie',
|
|
||||||
'caveau' => 'CaveauAI',
|
'caveau' => 'CaveauAI',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -66,9 +67,8 @@ $status = (string)($_GET['status'] ?? '');
|
|||||||
.billing-card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #374151; }
|
.billing-card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #374151; }
|
||||||
.tier-badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
.tier-badge { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
||||||
.tier-free { background: #f3f4f6; color: #374151; }
|
.tier-free { background: #f3f4f6; color: #374151; }
|
||||||
.tier-light { background: #ddd6fe; color: #5b21b6; }
|
.tier-plus { background: #ddd6fe; color: #5b21b6; }
|
||||||
.tier-pro { background: #bfdbfe; color: #1e40af; }
|
.tier-pro { background: #bfdbfe; color: #1e40af; }
|
||||||
.tier-pro_plus { background: #fde68a; color: #92400e; }
|
|
||||||
.tier-caveau { background: #d1fae5; color: #065f46; }
|
.tier-caveau { background: #d1fae5; color: #065f46; }
|
||||||
.balance-big { font-size: 2.6rem; font-weight: 700; color: #00205B; margin: 0.5rem 0; }
|
.balance-big { font-size: 2.6rem; font-weight: 700; color: #00205B; margin: 0.5rem 0; }
|
||||||
.balance-break { color: #6b7280; font-size: 0.9rem; }
|
.balance-break { color: #6b7280; font-size: 0.9rem; }
|
||||||
@@ -104,13 +104,16 @@ $status = (string)($_GET['status'] ?? '');
|
|||||||
<p style="margin:0.5rem 0 1rem;">
|
<p style="margin:0.5rem 0 1rem;">
|
||||||
<span class="tier-badge tier-<?= htmlspecialchars($tier) ?>"><?= htmlspecialchars($tierLabels[$tier] ?? $tier) ?></span>
|
<span class="tier-badge tier-<?= htmlspecialchars($tier) ?>"><?= htmlspecialchars($tierLabels[$tier] ?? $tier) ?></span>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if (!empty($detail['trial_active'])): ?>
|
||||||
|
<p class="balance-break" style="color:#92400e;">Plus prøveperiode: <?= (int)$detail['trial_days_remaining'] ?> dager igjen. Kortet belastes automatisk etter prøveperioden hvis du ikke kansellerer.</p>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($detail['subscription_period_end'])): ?>
|
<?php if (!empty($detail['subscription_period_end'])): ?>
|
||||||
<p class="balance-break">Fornyes <?= htmlspecialchars(date('j. F Y', strtotime((string)$detail['subscription_period_end']))) ?></p>
|
<p class="balance-break">Fornyes <?= htmlspecialchars(date('j. F Y', strtotime((string)$detail['subscription_period_end']))) ?></p>
|
||||||
<?php elseif ($tier === 'free'): ?>
|
<?php elseif ($tier === 'free'): ?>
|
||||||
<p class="balance-break">Ingen aktiv abonnement. <a href="/pricing.php">Se planer</a></p>
|
<p class="balance-break">Ingen aktiv abonnement. <a href="/pricing.php">Se planer</a></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="billing-actions" style="margin: 1.25rem 0 0;">
|
<div class="billing-actions" style="margin: 1.25rem 0 0;">
|
||||||
<?php if (in_array($tier, ['light','pro','pro_plus'], true)): ?>
|
<?php if ($isPaidTier): ?>
|
||||||
<button type="button" id="portalBtn" class="btn btn-secondary">Administrer abonnement</button>
|
<button type="button" id="portalBtn" class="btn btn-secondary">Administrer abonnement</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<a class="btn btn-primary" href="/pricing.php">Se alle planer</a>
|
<a class="btn btn-primary" href="/pricing.php">Se alle planer</a>
|
||||||
@@ -123,12 +126,9 @@ $status = (string)($_GET['status'] ?? '');
|
|||||||
<p class="balance-break">
|
<p class="balance-break">
|
||||||
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> bonus
|
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> bonus
|
||||||
</p>
|
</p>
|
||||||
<?php if ($tier === 'pro_plus'): ?>
|
|
||||||
<p style="margin-top:0.5rem; color:#059669; font-size:0.9rem;">✓ Pro+ har ubegrenset bruk (50 kall/time)</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (in_array($tier, ['light','pro','pro_plus'], true)): ?>
|
<?php if ($isPaidTier): ?>
|
||||||
<div class="billing-card">
|
<div class="billing-card">
|
||||||
<h2>Sak-lagring</h2>
|
<h2>Sak-lagring</h2>
|
||||||
<p class="balance-big" style="font-size:1.8rem;"><?= $storageMb ?> MB <span style="color:#6b7280; font-size:1rem;">/ <?= $quotaMb ?> MB</span></p>
|
<p class="balance-big" style="font-size:1.8rem;"><?= $storageMb ?> MB <span style="color:#6b7280; font-size:1rem;">/ <?= $quotaMb ?> MB</span></p>
|
||||||
|
|||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/includes/FreeTier.php';
|
||||||
|
require_once __DIR__ . '/includes/CaseStore.php';
|
||||||
|
require_once __DIR__ . '/includes/CaseResults.php';
|
||||||
|
|
||||||
|
if (!dbnToolsIsAuthenticated()) {
|
||||||
|
header('Location: /?return=' . urlencode($_SERVER['REQUEST_URI'] ?? '/'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uiLang = dbnToolsCurrentLanguage();
|
||||||
|
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
header('Location: /dashboard.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$result = $id > 0 ? CaseResults::get($userId, $id) : null;
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
http_response_code(404);
|
||||||
|
?><!doctype html><html lang="<?= htmlspecialchars($uiLang) ?>"><head>
|
||||||
|
<meta charset="utf-8"><title>Ikke funnet — Min Sak</title>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
||||||
|
<link rel="stylesheet" href="assets/css/tools.css">
|
||||||
|
</head><body><main style="max-width:720px;margin:4rem auto;padding:0 1.5rem;text-align:center;font-family:'IBM Plex Sans',sans-serif;">
|
||||||
|
<h1 style="font-family:'Crimson Pro',serif;color:#00205B;">Analysen finnes ikke</h1>
|
||||||
|
<p>Den lagrede analysen ble ikke funnet, eller du har ikke tilgang til den.</p>
|
||||||
|
<p><a href="/min-sak.php" style="color:#00205B;font-weight:600;">← Tilbake til Min Sak</a></p>
|
||||||
|
</main></body></html><?php
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$toolSlug = (string)$result['tool'];
|
||||||
|
$toolLabel = CaseResults::toolLabel($toolSlug);
|
||||||
|
$toolIcon = CaseResults::toolIcon($toolSlug);
|
||||||
|
$title = (string)($result['title'] ?? $toolLabel);
|
||||||
|
$createdAt = (string)$result['created_at'];
|
||||||
|
$usedCase = !empty($result['used_case_context']);
|
||||||
|
$caseDocIds = is_array($result['case_doc_ids'] ?? null) ? $result['case_doc_ids'] : [];
|
||||||
|
$input = is_array($result['input_payload'] ?? null) ? $result['input_payload'] : [];
|
||||||
|
$output = is_array($result['output_payload'] ?? null) ? $result['output_payload'] : [];
|
||||||
|
|
||||||
|
// Look up doc filenames for the chunks that contributed
|
||||||
|
$caseDocs = [];
|
||||||
|
if (!empty($caseDocIds)) {
|
||||||
|
try {
|
||||||
|
$ownerId = CaseStore::caseResolveClientId($userId);
|
||||||
|
$allDocs = CaseStore::listDocs($ownerId);
|
||||||
|
$byId = [];
|
||||||
|
foreach ($allDocs as $d) {
|
||||||
|
$byId[(int)$d['id']] = $d;
|
||||||
|
}
|
||||||
|
foreach ($caseDocIds as $docId) {
|
||||||
|
if (isset($byId[(int)$docId])) {
|
||||||
|
$caseDocs[] = $byId[(int)$docId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$caseDocs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort extraction of the primary human-readable output
|
||||||
|
$primaryOutput = '';
|
||||||
|
foreach (['draft', 'response', 'answer', 'text', 'summary', 'markdown'] as $k) {
|
||||||
|
if (!empty($output[$k]) && is_string($output[$k])) {
|
||||||
|
$primaryOutput = (string)$output[$k];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="<?= htmlspecialchars($uiLang) ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars($title) ?> — Min Sak</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||||
|
<link rel="stylesheet" href="assets/css/tools.css">
|
||||||
|
<style>
|
||||||
|
.cr-shell { max-width: 980px; margin: 0 auto; padding: 2rem 1.5rem 4rem; font-family: 'IBM Plex Sans', sans-serif; }
|
||||||
|
.cr-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.25rem; }
|
||||||
|
.cr-icon { font-size: 2.4rem; line-height: 1; }
|
||||||
|
.cr-title-row { flex: 1; min-width: 0; }
|
||||||
|
.cr-title { font-family: 'Crimson Pro', serif; font-size: 1.9rem; margin: 0; color: #00205B; cursor: pointer; }
|
||||||
|
.cr-title[contenteditable="true"]:focus { outline: 2px solid #00205B; outline-offset: 4px; }
|
||||||
|
.cr-meta { color: #6b7280; margin: 0.4rem 0 0; font-size: 0.9rem; }
|
||||||
|
.cr-tag { display: inline-block; background: #dbeafe; color: #1e3a8a; padding: 1px 8px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.cr-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1.25rem 0 2rem; }
|
||||||
|
.cr-btn { padding: 8px 14px; border-radius: 6px; font-size: 0.9rem; font-weight: 600; border: 1px solid #00205B; background: #fff; color: #00205B; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||||
|
.cr-btn-primary { background: #00205B; color: #fff; }
|
||||||
|
.cr-btn-danger { border-color: #b91c1c; color: #b91c1c; }
|
||||||
|
.cr-section { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem; }
|
||||||
|
.cr-section h2 { font-family: 'Crimson Pro', serif; margin: 0 0 0.75rem; font-size: 1.3rem; color: #00205B; }
|
||||||
|
.cr-output { white-space: pre-wrap; line-height: 1.6; color: #1f2937; font-size: 1rem; }
|
||||||
|
.cr-output-empty { color: #6b7280; font-style: italic; }
|
||||||
|
.cr-docs { display: grid; grid-template-columns: 1fr; gap: 0.5rem; }
|
||||||
|
.cr-doc { padding: 0.65rem 0.85rem; background: #f9fafb; border-radius: 6px; display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
.cr-doc-name { flex: 1; font-weight: 500; color: #1f2937; }
|
||||||
|
details.cr-collapse > summary { cursor: pointer; color: #00205B; font-weight: 600; padding: 0.5rem 0; }
|
||||||
|
details.cr-collapse pre { background: #f3f4f6; padding: 1rem; border-radius: 6px; overflow-x: auto; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; max-height: 400px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="cr-shell">
|
||||||
|
<p style="margin:0 0 1rem;color:#6b7280;font-size:0.85rem;text-transform:uppercase;letter-spacing:0.06em;">
|
||||||
|
<a href="/min-sak.php" style="color:inherit;">← Min Sak</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<header class="cr-header">
|
||||||
|
<div class="cr-icon"><?= htmlspecialchars($toolIcon) ?></div>
|
||||||
|
<div class="cr-title-row">
|
||||||
|
<h1 class="cr-title" id="crTitle" contenteditable="false" data-id="<?= (int)$result['id'] ?>">
|
||||||
|
<?= htmlspecialchars($title) ?>
|
||||||
|
</h1>
|
||||||
|
<p class="cr-meta">
|
||||||
|
<?= htmlspecialchars($toolLabel) ?>
|
||||||
|
· <?= htmlspecialchars(date('j. F Y, H:i', strtotime($createdAt))) ?>
|
||||||
|
<?php if ($usedCase): ?>
|
||||||
|
· <span class="cr-tag">Bruk min sak</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($result['model'])): ?>
|
||||||
|
· <span style="font-family:'JetBrains Mono',monospace;font-size:0.8rem;color:#6b7280;"><?= htmlspecialchars((string)$result['model']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cr-actions">
|
||||||
|
<button type="button" class="cr-btn cr-btn-primary" id="crRerun" data-tool="<?= htmlspecialchars($toolSlug) ?>" data-id="<?= (int)$result['id'] ?>">Kjør på nytt</button>
|
||||||
|
<button type="button" class="cr-btn" id="crRename">Endre navn</button>
|
||||||
|
<a class="cr-btn" href="/api/case/result-export.php?id=<?= (int)$result['id'] ?>" download>Eksporter JSON</a>
|
||||||
|
<button type="button" class="cr-btn cr-btn-danger" id="crDelete" data-id="<?= (int)$result['id'] ?>">Slett</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($caseDocs)): ?>
|
||||||
|
<section class="cr-section">
|
||||||
|
<h2>Saksdokumenter brukt</h2>
|
||||||
|
<div class="cr-docs">
|
||||||
|
<?php foreach ($caseDocs as $d): ?>
|
||||||
|
<div class="cr-doc">
|
||||||
|
<div>📄</div>
|
||||||
|
<div class="cr-doc-name"><?= htmlspecialchars((string)$d['filename']) ?></div>
|
||||||
|
<div style="color:#6b7280;font-size:0.85rem;">
|
||||||
|
<?php if (!empty($d['page_count'])): ?><?= (int)$d['page_count'] ?> sider<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="cr-section">
|
||||||
|
<h2>Resultat</h2>
|
||||||
|
<?php if ($primaryOutput !== ''): ?>
|
||||||
|
<div class="cr-output"><?= htmlspecialchars($primaryOutput) ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="cr-output-empty">Dette verktøyet returnerer strukturert output — se rådata under.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<details class="cr-collapse" style="margin-top:1.25rem;">
|
||||||
|
<summary>Vis rådata (JSON)</summary>
|
||||||
|
<pre><?= htmlspecialchars(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') ?></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cr-section">
|
||||||
|
<h2>Input</h2>
|
||||||
|
<details class="cr-collapse" open>
|
||||||
|
<summary>Vis input som ble sendt</summary>
|
||||||
|
<pre><?= htmlspecialchars(json_encode($input, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') ?></pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const titleEl = document.getElementById('crTitle');
|
||||||
|
const renameBtn = document.getElementById('crRename');
|
||||||
|
const deleteBtn = document.getElementById('crDelete');
|
||||||
|
const rerunBtn = document.getElementById('crRerun');
|
||||||
|
|
||||||
|
renameBtn.addEventListener('click', () => {
|
||||||
|
titleEl.contentEditable = 'true';
|
||||||
|
titleEl.focus();
|
||||||
|
document.execCommand('selectAll', false, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
titleEl.addEventListener('blur', async () => {
|
||||||
|
if (titleEl.contentEditable !== 'true') return;
|
||||||
|
titleEl.contentEditable = 'false';
|
||||||
|
const newTitle = (titleEl.textContent || '').trim();
|
||||||
|
if (newTitle === '') { return; }
|
||||||
|
try {
|
||||||
|
await fetch('/api/case/result-action.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'rename', id: <?= (int)$result['id'] ?>, title: newTitle }),
|
||||||
|
});
|
||||||
|
} catch (e) { /* silent */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
titleEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Slette denne analysen for godt?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/case/result-action.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete', id: <?= (int)$result['id'] ?> }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok) window.location.href = '/min-sak.php';
|
||||||
|
else alert(json.error?.message || 'Sletting feilet.');
|
||||||
|
} catch (e) { alert('Nettverksfeil: ' + e.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-run: just navigate to the tool page with a hash that the tool can pick up if it wants to.
|
||||||
|
// For MVP, we deep-link back to the tool — the user can re-fill from input shown below.
|
||||||
|
rerunBtn.addEventListener('click', () => {
|
||||||
|
const tool = rerunBtn.getAttribute('data-tool');
|
||||||
|
const path = {
|
||||||
|
'korrespond': '/korrespond.php',
|
||||||
|
'advocate': '/advocate.php',
|
||||||
|
'barnevernet': '/barnevernet.php',
|
||||||
|
'deep-research': '/deep-research.php',
|
||||||
|
'discrepancy': '/discrepancy.php',
|
||||||
|
'timeline': '/timeline.php',
|
||||||
|
}[tool] || '/dashboard.php';
|
||||||
|
window.location.href = path + '?rerun=' + <?= (int)$result['id'] ?>;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+5
-6
@@ -20,9 +20,8 @@ $dashTier = $dashIsSso ? FreeTier::tier((int)$_SESSION['dbn_tools_sso_uid']) : '
|
|||||||
$dashDetail = $dashIsSso ? FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']) : null;
|
$dashDetail = $dashIsSso ? FreeTier::balanceDetail((int)$_SESSION['dbn_tools_sso_uid']) : null;
|
||||||
$tierLabels = [
|
$tierLabels = [
|
||||||
'free' => ['Gratis', '#f3f4f6', '#374151'],
|
'free' => ['Gratis', '#f3f4f6', '#374151'],
|
||||||
'light' => ['Light', '#ddd6fe', '#5b21b6'],
|
'plus' => ['Plus', '#ddd6fe', '#5b21b6'],
|
||||||
'pro' => ['Pro', '#bfdbfe', '#1e40af'],
|
'pro' => ['Pro Familie', '#bfdbfe', '#1e40af'],
|
||||||
'pro_plus' => ['Pro+ Familie', '#fde68a', '#92400e'],
|
|
||||||
];
|
];
|
||||||
$tierLabel = $tierLabels[$dashTier] ?? ['CaveauAI', '#d1fae5', '#065f46'];
|
$tierLabel = $tierLabels[$dashTier] ?? ['CaveauAI', '#d1fae5', '#065f46'];
|
||||||
$showSurveyCta = $dashIsSso && empty($dashDetail['survey_completed_at']);
|
$showSurveyCta = $dashIsSso && empty($dashDetail['survey_completed_at']);
|
||||||
@@ -82,11 +81,11 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
|
|||||||
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Tilgjengelige kreditter</p>
|
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Tilgjengelige kreditter</p>
|
||||||
<p style="margin:0.35rem 0 0; font-size:1.8rem; font-weight:700; color:#00205B;">
|
<p style="margin:0.35rem 0 0; font-size:1.8rem; font-weight:700; color:#00205B;">
|
||||||
<?php $eff = (int)$dashDetail['balance'] + (int)$dashDetail['bonus_balance']; ?>
|
<?php $eff = (int)$dashDetail['balance'] + (int)$dashDetail['bonus_balance']; ?>
|
||||||
<?php if ($dashTier === 'pro_plus'): ?>∞<?php else: ?><?= number_format($eff, 0, ',', ' ') ?><?php endif; ?>
|
<?= number_format($eff, 0, ',', ' ') ?>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= (int)$dashDetail['balance'] ?> månedlige · <?= (int)$dashDetail['bonus_balance'] ?> bonus · <a href="/billing.php">Detaljer</a></p>
|
<p style="margin:0; color:#6b7280; font-size:0.85rem;"><?= (int)$dashDetail['balance'] ?> månedlige · <?= (int)$dashDetail['bonus_balance'] ?> bonus · <a href="/billing.php">Detaljer</a></p>
|
||||||
</div>
|
</div>
|
||||||
<?php if (in_array($dashTier, ['light','pro','pro_plus'], true)): ?>
|
<?php if (in_array($dashTier, ['plus','pro'], true)): ?>
|
||||||
<a class="status-card" href="/min-sak.php" style="background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none; color:inherit;">
|
<a class="status-card" href="/min-sak.php" style="background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none; color:inherit;">
|
||||||
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Min sak</p>
|
<p style="margin:0; color:#6b7280; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Min sak</p>
|
||||||
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700; color:#00205B;">Bygg din egen sak →</p>
|
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700; color:#00205B;">Bygg din egen sak →</p>
|
||||||
@@ -102,7 +101,7 @@ window.DBN_TOOLS_LANG = <?= json_encode($uiLang, JSON_UNESCAPED_UNICODE) ?>;
|
|||||||
<a class="status-card" href="/pricing.php" style="background:linear-gradient(135deg,#00205B,#003478); color:#fff; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none;">
|
<a class="status-card" href="/pricing.php" style="background:linear-gradient(135deg,#00205B,#003478); color:#fff; border-radius:10px; padding:1.1rem 1.25rem; text-decoration:none;">
|
||||||
<p style="margin:0; opacity:0.85; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Bygg din egen sak</p>
|
<p style="margin:0; opacity:0.85; font-size:0.85rem; text-transform:uppercase; letter-spacing:0.06em;">Bygg din egen sak</p>
|
||||||
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700;">Last opp dokumenter →</p>
|
<p style="margin:0.35rem 0 0; font-size:1.4rem; font-weight:700;">Last opp dokumenter →</p>
|
||||||
<p style="margin:0; opacity:0.85; font-size:0.85rem;">Tilgjengelig fra Light €9/mo</p>
|
<p style="margin:0; opacity:0.85; font-size:0.85rem;">Tilgjengelig fra Plus NOK 129/mnd</p>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($showSurveyCta): ?>
|
<?php if ($showSurveyCta): ?>
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<label class="input-label" for="drInput">Question or pasted text</label>
|
<label class="input-label" for="drInput">Question or pasted text</label>
|
||||||
<textarea id="drInput" name="drInput" rows="8" placeholder="Describe the legal question, paste case notes, or both. The agent will research the corpus from 3–5 angles."></textarea>
|
<textarea id="drInput" name="drInput" rows="8" placeholder="Describe the legal question, paste case notes, or both. The agent will research the corpus from 3–5 angles."></textarea>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="drStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="drStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="drRunButton" type="submit">Run deep research</button>
|
<button id="drRunButton" type="submit">Run deep research</button>
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="dcStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="dcStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="dcRunButton" type="submit">Find discrepancies</button>
|
<button id="dcRunButton" type="submit">Find discrepancies</button>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ final class CaseStore
|
|||||||
$quota = (int)$detail['storage_quota_bytes'];
|
$quota = (int)$detail['storage_quota_bytes'];
|
||||||
$used = (int)$detail['storage_used_bytes'];
|
$used = (int)$detail['storage_used_bytes'];
|
||||||
if ($quota === 0) {
|
if ($quota === 0) {
|
||||||
throw new RuntimeException('Min Sak er ikke tilgjengelig på gratis-nivå. Oppgrader for å laste opp dokumenter.');
|
throw new RuntimeException('Min Sak er ikke tilgjengelig på Gratis-nivå. Oppgrader til Plus eller Pro for å laste opp dokumenter.');
|
||||||
}
|
}
|
||||||
if ($used + $sizeBytes > $quota) {
|
if ($used + $sizeBytes > $quota) {
|
||||||
$remainMb = max(0, ($quota - $used) / 1048576);
|
$remainMb = max(0, ($quota - $used) / 1048576);
|
||||||
|
|||||||
+95
-31
@@ -4,16 +4,23 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Credit + tier system for users of tools.dobetternorge.no.
|
* Credit + tier system for users of tools.dobetternorge.no.
|
||||||
*
|
*
|
||||||
|
* Three tiers: free, plus, pro (NOK pricing, see pricing.php).
|
||||||
|
*
|
||||||
* Tables:
|
* Tables:
|
||||||
* user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier, Stripe links
|
* user_tool_credits — balance (monthly, resets), bonus_balance (never expires), tier,
|
||||||
|
* Stripe links, trial_started_at / trial_expires_at / trial_downgraded_at
|
||||||
* user_tool_usage_log — every tool call with credits_used
|
* user_tool_usage_log — every tool call with credits_used
|
||||||
* user_subscriptions — Stripe subscription ledger
|
* user_subscriptions — Stripe subscription ledger
|
||||||
*
|
*
|
||||||
* Effective balance = balance + bonus_balance.
|
* Effective balance = balance + bonus_balance.
|
||||||
* Spend order: deduct from balance first, overflow to bonus_balance.
|
* Spend order: deduct from balance first, overflow to bonus_balance.
|
||||||
* pro_plus tier bypasses balance checks (still subject to hourly cap).
|
* Pro tier has the largest monthly allowance (still subject to hourly cap).
|
||||||
*
|
*
|
||||||
* CaveauAI client sessions (dbn_tools_user_id + client_id) bypass all checks.
|
* Trial is Stripe-driven: when subscription.status='trialing', tier='plus' and
|
||||||
|
* trial_expires_at mirrors Stripe's trial_end. No homegrown trial cron — the
|
||||||
|
* Stripe webhook flips tier='free' on subscription.deleted.
|
||||||
|
*
|
||||||
|
* CaveauAI client sessions bypass all credit checks.
|
||||||
* Only SSO sessions are subject to limits.
|
* Only SSO sessions are subject to limits.
|
||||||
*/
|
*/
|
||||||
final class FreeTier
|
final class FreeTier
|
||||||
@@ -33,28 +40,25 @@ final class FreeTier
|
|||||||
'korrespond' => 3,
|
'korrespond' => 3,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
|
/** Monthly credit allowance per tier. */
|
||||||
private const MONTHLY_ALLOWANCE = [
|
private const MONTHLY_ALLOWANCE = [
|
||||||
'free' => 30,
|
'free' => 30,
|
||||||
'light' => 120,
|
'plus' => 250,
|
||||||
'pro' => 500,
|
'pro' => 1000,
|
||||||
'pro_plus' => 999999,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
|
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
|
||||||
private const HOURLY_CAP = [
|
private const HOURLY_CAP = [
|
||||||
'free' => 10,
|
'free' => 10,
|
||||||
'light' => 15,
|
'plus' => 20,
|
||||||
'pro' => 30,
|
'pro' => 40,
|
||||||
'pro_plus' => 50,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Per-user case-storage quota in bytes. */
|
/** Per-user case-storage quota in bytes. */
|
||||||
private const STORAGE_QUOTA = [
|
private const STORAGE_QUOTA = [
|
||||||
'free' => 0,
|
'free' => 0,
|
||||||
'light' => 104857600, // 100 MB
|
'plus' => 524288000, // 500 MB
|
||||||
'pro' => 1073741824, // 1 GB
|
'pro' => 5368709120, // 5 GB
|
||||||
'pro_plus' => 10737418240, // 10 GB
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
||||||
@@ -85,6 +89,37 @@ final class FreeTier
|
|||||||
return $row['tier'] ?? 'free';
|
return $row['tier'] ?? 'free';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tiers that have access to "My Case" features (upload, save analyses, use case context). */
|
||||||
|
public static function isPaidTier(string $tier): bool
|
||||||
|
{
|
||||||
|
return in_array($tier, ['plus', 'pro'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the user is currently in a Stripe trial (tier='plus', trial_expires_at in future). */
|
||||||
|
public static function isTrialActive(int $userId): bool
|
||||||
|
{
|
||||||
|
$row = self::row($userId);
|
||||||
|
if (!$row || ($row['tier'] ?? '') !== 'plus') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$expires = $row['trial_expires_at'] ?? null;
|
||||||
|
if (!$expires) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return strtotime((string)$expires) > time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days remaining in the active trial (0 if no trial / expired). */
|
||||||
|
public static function trialDaysRemaining(int $userId): int
|
||||||
|
{
|
||||||
|
if (!self::isTrialActive($userId)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$row = self::row($userId);
|
||||||
|
$expires = strtotime((string)$row['trial_expires_at']);
|
||||||
|
return max(0, (int)ceil(($expires - time()) / 86400));
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch the full credits row, applying lazy monthly reset. */
|
/** Fetch the full credits row, applying lazy monthly reset. */
|
||||||
public static function row(int $userId): ?array
|
public static function row(int $userId): ?array
|
||||||
{
|
{
|
||||||
@@ -94,9 +129,8 @@ final class FreeTier
|
|||||||
"UPDATE user_tool_credits
|
"UPDATE user_tool_credits
|
||||||
SET balance = CASE tier
|
SET balance = CASE tier
|
||||||
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
|
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
|
||||||
WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
|
WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . "
|
||||||
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
|
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
|
||||||
WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
|
|
||||||
ELSE balance END,
|
ELSE balance END,
|
||||||
last_reset = CURDATE()
|
last_reset = CURDATE()
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -140,7 +174,7 @@ final class FreeTier
|
|||||||
'tier' => $tier,
|
'tier' => $tier,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Hourly rate limit (always applies, even to pro_plus)
|
// Hourly rate limit (always applies)
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'SELECT COUNT(*) FROM user_tool_usage_log
|
'SELECT COUNT(*) FROM user_tool_usage_log
|
||||||
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
|
WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) AND credits_used > 0'
|
||||||
@@ -156,11 +190,6 @@ final class FreeTier
|
|||||||
return $base + ['ok' => true];
|
return $base + ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
// pro_plus bypasses credit check
|
|
||||||
if ($tier === 'pro_plus') {
|
|
||||||
return $base + ['ok' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($balance + $bonus) < $cost) {
|
if (($balance + $bonus) < $cost) {
|
||||||
return $base + ['ok' => false, 'reason' => 'no_credits'];
|
return $base + ['ok' => false, 'reason' => 'no_credits'];
|
||||||
}
|
}
|
||||||
@@ -171,7 +200,6 @@ final class FreeTier
|
|||||||
/**
|
/**
|
||||||
* Deduct credits for a completed tool call and log the usage.
|
* Deduct credits for a completed tool call and log the usage.
|
||||||
* Spends from `balance` first, then `bonus_balance`.
|
* Spends from `balance` first, then `bonus_balance`.
|
||||||
* pro_plus tier logs the call but does not deduct.
|
|
||||||
*
|
*
|
||||||
* Returns the new effective balance (balance + bonus_balance).
|
* Returns the new effective balance (balance + bonus_balance).
|
||||||
*/
|
*/
|
||||||
@@ -180,9 +208,8 @@ final class FreeTier
|
|||||||
$db = dbnmDb();
|
$db = dbnmDb();
|
||||||
$cost = self::cost($tool);
|
$cost = self::cost($tool);
|
||||||
$row = self::row($userId);
|
$row = self::row($userId);
|
||||||
$tier = $row['tier'] ?? 'free';
|
|
||||||
|
|
||||||
if ($cost > 0 && $tier !== 'pro_plus' && $row !== null) {
|
if ($cost > 0 && $row !== null) {
|
||||||
$balance = (int)$row['balance'];
|
$balance = (int)$row['balance'];
|
||||||
$bonus = (int)$row['bonus_balance'];
|
$bonus = (int)$row['bonus_balance'];
|
||||||
|
|
||||||
@@ -227,6 +254,10 @@ final class FreeTier
|
|||||||
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
|
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
|
||||||
'survey_completed_at' => $row['survey_completed_at'] ?? null,
|
'survey_completed_at' => $row['survey_completed_at'] ?? null,
|
||||||
'subscription_period_end' => $row['subscription_period_end'] ?? null,
|
'subscription_period_end' => $row['subscription_period_end'] ?? null,
|
||||||
|
'trial_started_at' => $row['trial_started_at'] ?? null,
|
||||||
|
'trial_expires_at' => $row['trial_expires_at'] ?? null,
|
||||||
|
'trial_active' => self::isTrialActive($userId),
|
||||||
|
'trial_days_remaining' => self::trialDaysRemaining($userId),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,27 +282,49 @@ final class FreeTier
|
|||||||
/**
|
/**
|
||||||
* Set or upgrade a user's tier (called by Stripe subscription webhook).
|
* Set or upgrade a user's tier (called by Stripe subscription webhook).
|
||||||
* Refills monthly balance to the new tier's allowance.
|
* Refills monthly balance to the new tier's allowance.
|
||||||
|
*
|
||||||
|
* When $trialEndIso is non-null, also writes trial_started_at (preserving original on updates)
|
||||||
|
* and trial_expires_at — used when subscription.status='trialing'.
|
||||||
*/
|
*/
|
||||||
public static function setTier(
|
public static function setTier(
|
||||||
int $userId,
|
int $userId,
|
||||||
string $tier,
|
string $tier,
|
||||||
?string $stripeCustomerId,
|
?string $stripeCustomerId,
|
||||||
?string $subscriptionId,
|
?string $subscriptionId,
|
||||||
?string $periodEndIso
|
?string $periodEndIso,
|
||||||
|
?string $trialEndIso = null
|
||||||
): void {
|
): void {
|
||||||
$db = dbnmDb();
|
$db = dbnmDb();
|
||||||
self::ensureRow($userId);
|
self::ensureRow($userId);
|
||||||
$allowance = self::monthlyAllowance($tier);
|
$allowance = self::monthlyAllowance($tier);
|
||||||
|
|
||||||
|
if ($trialEndIso !== null) {
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'UPDATE user_tool_credits
|
'UPDATE user_tool_credits
|
||||||
SET tier = ?, balance = ?, allowance = ?,
|
SET tier = ?, balance = ?, allowance = ?,
|
||||||
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||||
subscription_id = ?,
|
subscription_id = ?,
|
||||||
subscription_period_end = ?,
|
subscription_period_end = ?,
|
||||||
|
trial_started_at = COALESCE(trial_started_at, NOW()),
|
||||||
|
trial_expires_at = ?,
|
||||||
|
trial_downgraded_at = NULL,
|
||||||
|
last_reset = CURDATE()
|
||||||
|
WHERE user_id = ?'
|
||||||
|
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $trialEndIso, $userId]);
|
||||||
|
} else {
|
||||||
|
$db->prepare(
|
||||||
|
'UPDATE user_tool_credits
|
||||||
|
SET tier = ?, balance = ?, allowance = ?,
|
||||||
|
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||||
|
subscription_id = ?,
|
||||||
|
subscription_period_end = ?,
|
||||||
|
trial_expires_at = NULL,
|
||||||
|
trial_downgraded_at = NULL,
|
||||||
last_reset = CURDATE()
|
last_reset = CURDATE()
|
||||||
WHERE user_id = ?'
|
WHERE user_id = ?'
|
||||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refill monthly balance at subscription renewal (invoice.paid).
|
* Refill monthly balance at subscription renewal (invoice.paid).
|
||||||
@@ -291,17 +344,28 @@ final class FreeTier
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revert a user to free tier (subscription canceled or fully ended).
|
* Revert a user to free tier (subscription canceled, trial ended without conversion).
|
||||||
* Preserves bonus_balance and case_documents (handled by 90-day cron).
|
* Preserves bonus_balance and case_documents (handled by 60-day cron).
|
||||||
|
* Stamps trial_downgraded_at if a trial was active.
|
||||||
*/
|
*/
|
||||||
public static function clearTier(int $userId): void
|
public static function clearTier(int $userId): void
|
||||||
{
|
{
|
||||||
$db = dbnmDb();
|
$db = dbnmDb();
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'UPDATE user_tool_credits
|
"UPDATE user_tool_credits
|
||||||
SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
|
SET tier = 'free',
|
||||||
WHERE user_id = ?'
|
allowance = ?,
|
||||||
)->execute(['free', self::monthlyAllowance('free'), $userId]);
|
balance = ?,
|
||||||
|
subscription_id = NULL,
|
||||||
|
subscription_period_end = NULL,
|
||||||
|
trial_downgraded_at = CASE
|
||||||
|
WHEN trial_expires_at IS NOT NULL AND trial_downgraded_at IS NULL
|
||||||
|
THEN NOW()
|
||||||
|
ELSE trial_downgraded_at
|
||||||
|
END,
|
||||||
|
trial_expires_at = NULL
|
||||||
|
WHERE user_id = ?"
|
||||||
|
)->execute([self::monthlyAllowance('free'), self::monthlyAllowance('free'), $userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark survey as completed so the bonus can only be claimed once per account. */
|
/** Mark survey as completed so the bonus can only be claimed once per account. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ declare(strict_types=1);
|
|||||||
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
|
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
|
||||||
* STRIPE_WEBHOOK_SECRET whsec_...
|
* STRIPE_WEBHOOK_SECRET whsec_...
|
||||||
* STRIPE_PRICE_TOPUP_S / _M / _L
|
* STRIPE_PRICE_TOPUP_S / _M / _L
|
||||||
* STRIPE_PRICE_LIGHT / _PRO / _PRO_PLUS
|
* STRIPE_PRICE_PLUS_NOK / STRIPE_PRICE_PRO_NOK
|
||||||
*/
|
*/
|
||||||
final class StripeClient
|
final class StripeClient
|
||||||
{
|
{
|
||||||
@@ -55,9 +55,8 @@ final class StripeClient
|
|||||||
'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
|
'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
|
||||||
'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
|
'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
|
||||||
'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
|
'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
|
||||||
'light' => self::config('STRIPE_PRICE_LIGHT'),
|
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||||
'pro' => self::config('STRIPE_PRICE_PRO'),
|
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||||
'pro_plus' => self::config('STRIPE_PRICE_PRO_PLUS'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$id = $map[$sku] ?? '';
|
$id = $map[$sku] ?? '';
|
||||||
@@ -78,12 +77,16 @@ final class StripeClient
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a Stripe price ID back to the internal subscription tier (light/pro/pro_plus). */
|
/** Map a Stripe price ID back to the internal subscription tier (plus/pro). */
|
||||||
public static function tierForPrice(string $priceId): ?string
|
public static function tierForPrice(string $priceId): ?string
|
||||||
{
|
{
|
||||||
foreach (['light', 'pro', 'pro_plus'] as $tier) {
|
$map = [
|
||||||
if (self::config('STRIPE_PRICE_' . strtoupper($tier)) === $priceId) {
|
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||||
return $tier;
|
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||||
|
];
|
||||||
|
foreach ($map as $tier => $configuredPriceId) {
|
||||||
|
if ($configuredPriceId !== '' && hash_equals($configuredPriceId, $priceId)) {
|
||||||
|
return (string)$tier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -173,7 +176,7 @@ final class StripeClient
|
|||||||
$method = strtoupper($method);
|
$method = strtoupper($method);
|
||||||
$headers = [
|
$headers = [
|
||||||
'Authorization: Bearer ' . $this->secretKey,
|
'Authorization: Bearer ' . $this->secretKey,
|
||||||
'Stripe-Version: 2024-10-28.acacia',
|
'Stripe-Version: 2026-02-25.clover',
|
||||||
];
|
];
|
||||||
|
|
||||||
$ch = curl_init();
|
$ch = curl_init();
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FreeTier.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier-aware model routing for tools that expose the existing engine selector.
|
||||||
|
*
|
||||||
|
* Plus/trial users get the cost-controlled Azure mini path. Pro users get the
|
||||||
|
* full Azure path. CaveauAI sessions keep the engine requested by their UI.
|
||||||
|
*/
|
||||||
|
final class ToolModels
|
||||||
|
{
|
||||||
|
public static function engineForUser(int $userId, string $requestedEngine): string
|
||||||
|
{
|
||||||
|
$valid = ['azure_mini', 'azure_full', 'gpu', 'regex'];
|
||||||
|
$requestedEngine = in_array($requestedEngine, $valid, true) ? $requestedEngine : 'azure_mini';
|
||||||
|
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return $requestedEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FreeTier::tier($userId) === 'pro' ? 'azure_full' : 'azure_mini';
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-5
@@ -421,7 +421,7 @@ function dbnmDb(): PDO
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the current session belongs to an SSO user (Google login).
|
* True when the current session belongs to an SSO user (Google login).
|
||||||
* All SSO sessions go through the credit + tier system (free, light, pro, pro_plus).
|
* All SSO sessions go through the credit + tier system (free, plus, pro).
|
||||||
* False for CaveauAI client sessions, which bypass all credit checks.
|
* False for CaveauAI client sessions, which bypass all credit checks.
|
||||||
*
|
*
|
||||||
* Note: name is historical — paid SSO users are also subject to the credit gate.
|
* Note: name is historical — paid SSO users are also subject to the credit gate.
|
||||||
@@ -497,18 +497,22 @@ function dbnToolsCurrentTier(): string
|
|||||||
*/
|
*/
|
||||||
function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
||||||
{
|
{
|
||||||
if (!$useMyCase) return '';
|
if (!$useMyCase) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||||
if (!dbnToolsIsFreeTier()) return '';
|
if (!dbnToolsIsFreeTier()) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||||
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
$userId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||||
if ($userId <= 0) return '';
|
if ($userId <= 0) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||||
|
|
||||||
require_once __DIR__ . '/FreeTier.php';
|
require_once __DIR__ . '/FreeTier.php';
|
||||||
$tier = FreeTier::tier($userId);
|
$tier = FreeTier::tier($userId);
|
||||||
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) return '';
|
if (!FreeTier::isPaidTier($tier)) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||||
|
|
||||||
require_once __DIR__ . '/CaseStore.php';
|
require_once __DIR__ . '/CaseStore.php';
|
||||||
$effective = CaseStore::caseResolveClientId($userId);
|
$effective = CaseStore::caseResolveClientId($userId);
|
||||||
$chunks = CaseStore::caseHybridSearch($effective, $query, $k);
|
$chunks = CaseStore::caseHybridSearch($effective, $query, $k);
|
||||||
|
$GLOBALS['dbn_last_case_doc_ids'] = array_values(array_unique(array_map(
|
||||||
|
static fn($c) => (int)($c['doc_id'] ?? 0),
|
||||||
|
$chunks
|
||||||
|
)));
|
||||||
|
|
||||||
// Audit log: who ran what against whose case
|
// Audit log: who ran what against whose case
|
||||||
try {
|
try {
|
||||||
@@ -528,6 +532,12 @@ function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
|||||||
return CaseStore::formatChunksForPrompt($chunks);
|
return CaseStore::formatChunksForPrompt($chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the case_document ids retrieved by the most recent dbnToolsCaseContext() call in this request. */
|
||||||
|
function dbnToolsLastCaseDocIds(): array
|
||||||
|
{
|
||||||
|
return is_array($GLOBALS['dbn_last_case_doc_ids'] ?? null) ? $GLOBALS['dbn_last_case_doc_ids'] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
|
/** Read /etc/bnl/intersite.php for the HMAC secret shared between dobetternorge.no and tools.dobetternorge.no. */
|
||||||
function dbnToolsIntersiteSecret(): string
|
function dbnToolsIntersiteSecret(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Shared "Bruk min sak som kontekst" toggle for tool forms.
|
||||||
|
*
|
||||||
|
* Renders nothing for free / unauthenticated / CaveauAI sessions.
|
||||||
|
* For paid (Plus / Pro / active trial) users, renders a checkbox that defaults
|
||||||
|
* to checked when they have any indexed case documents.
|
||||||
|
*
|
||||||
|
* The companion JS exposes `window.dbnGetUseMyCase()` for each tool's JS to call
|
||||||
|
* when assembling its request payload — no per-tool plumbing beyond one read.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <?php require_once __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
* (Place inside the tool form, before the submit button.)
|
||||||
|
*
|
||||||
|
* Endpoint side: read `$input['use_my_case']` — already supported across the
|
||||||
|
* five wired tools (korrespond, advocate/deep-research, barnevernet, discrepancy, timeline).
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!function_exists('dbnToolsIsAuthenticated')) {
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/FreeTier.php';
|
||||||
|
require_once __DIR__ . '/CaseStore.php';
|
||||||
|
|
||||||
|
$__caseToggleUserId = 0;
|
||||||
|
$__caseToggleEnabled = false;
|
||||||
|
$__caseToggleDocCount = 0;
|
||||||
|
|
||||||
|
if (dbnToolsIsAuthenticated() && dbnToolsIsFreeTier()) {
|
||||||
|
$__caseToggleUserId = (int)($_SESSION['dbn_tools_sso_uid'] ?? 0);
|
||||||
|
if ($__caseToggleUserId > 0) {
|
||||||
|
$__caseToggleTier = FreeTier::tier($__caseToggleUserId);
|
||||||
|
if (FreeTier::isPaidTier($__caseToggleTier)) {
|
||||||
|
$__caseToggleEnabled = true;
|
||||||
|
try {
|
||||||
|
$__caseToggleOwnerId = CaseStore::caseResolveClientId($__caseToggleUserId);
|
||||||
|
$__caseToggleDocCount = count(CaseStore::listDocs($__caseToggleOwnerId));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$__caseToggleDocCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$__caseToggleEnabled) {
|
||||||
|
// Free / CaveauAI / unauthenticated — emit a no-op JS shim so tool code can call it safely.
|
||||||
|
echo '<script>window.dbnGetUseMyCase = function () { return false; };</script>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultChecked = $__caseToggleDocCount > 0 ? 'checked' : '';
|
||||||
|
$docCountLabel = $__caseToggleDocCount === 1
|
||||||
|
? '1 dokument'
|
||||||
|
: ($__caseToggleDocCount . ' dokumenter');
|
||||||
|
?>
|
||||||
|
<div class="control-row case-context-toggle" id="caseContextRow"
|
||||||
|
style="margin: 0.75rem 0; padding: 0.85rem 1rem; background: #f0f7ff;
|
||||||
|
border-left: 3px solid #00205B; border-radius: 6px; display: flex;
|
||||||
|
align-items: center; gap: 0.7rem;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.55rem; cursor: pointer; flex: 1; margin: 0;">
|
||||||
|
<input type="checkbox" id="useMyCaseToggle" name="use_my_case" value="1" <?= $defaultChecked ?>
|
||||||
|
style="width: 18px; height: 18px; accent-color: #00205B;">
|
||||||
|
<span style="font-weight: 600; color: #00205B;">Bruk min sak som kontekst</span>
|
||||||
|
<span style="color: #6b7280; font-size: 0.85rem;">
|
||||||
|
(<?= htmlspecialchars($docCountLabel) ?>)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<a href="/min-sak.php" style="color: #00205B; font-size: 0.85rem; text-decoration: none; white-space: nowrap;">
|
||||||
|
Min sak →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('useMyCaseToggle');
|
||||||
|
window.dbnGetUseMyCase = function () {
|
||||||
|
return el ? !!el.checked : false;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
unset($__caseToggleUserId, $__caseToggleEnabled, $__caseToggleDocCount, $__caseToggleTier, $__caseToggleOwnerId);
|
||||||
|
?>
|
||||||
@@ -118,6 +118,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="korrStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="korrStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="korrRunButton" type="submit">Draft</button>
|
<button id="korrRunButton" type="submit">Draft</button>
|
||||||
|
|||||||
+88
-3
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/includes/bootstrap.php';
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
require_once __DIR__ . '/includes/FreeTier.php';
|
require_once __DIR__ . '/includes/FreeTier.php';
|
||||||
require_once __DIR__ . '/includes/CaseStore.php';
|
require_once __DIR__ . '/includes/CaseStore.php';
|
||||||
|
require_once __DIR__ . '/includes/CaseResults.php';
|
||||||
|
|
||||||
if (!dbnToolsIsAuthenticated()) {
|
if (!dbnToolsIsAuthenticated()) {
|
||||||
header('Location: /?return=' . urlencode('/min-sak.php'));
|
header('Location: /?return=' . urlencode('/min-sak.php'));
|
||||||
@@ -21,7 +22,7 @@ $detail = FreeTier::balanceDetail($userId);
|
|||||||
$tier = (string)$detail['tier'];
|
$tier = (string)$detail['tier'];
|
||||||
|
|
||||||
// Free tier: show upgrade gate
|
// Free tier: show upgrade gate
|
||||||
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) {
|
if (!FreeTier::isPaidTier($tier)) {
|
||||||
require_once __DIR__ . '/includes/footer.php';
|
require_once __DIR__ . '/includes/footer.php';
|
||||||
$upgradeUrl = '/pricing.php';
|
$upgradeUrl = '/pricing.php';
|
||||||
?><!doctype html>
|
?><!doctype html>
|
||||||
@@ -38,7 +39,7 @@ if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) {
|
|||||||
<li>🧠 Alle verktøy kan referere til din egen sak</li>
|
<li>🧠 Alle verktøy kan referere til din egen sak</li>
|
||||||
<li>🇪🇺 Alt lagres i EU (Tyskland/Finland/Norge)</li>
|
<li>🇪🇺 Alt lagres i EU (Tyskland/Finland/Norge)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p style="margin:2rem 0 0;"><a href="<?= htmlspecialchars($upgradeUrl) ?>" style="background:#00205B;color:#fff;padding:1rem 2rem;border-radius:8px;font-weight:700;text-decoration:none;display:inline-block;">Se planer fra €9/mo</a></p>
|
<p style="margin:2rem 0 0;"><a href="<?= htmlspecialchars($upgradeUrl) ?>" style="background:#00205B;color:#fff;padding:1rem 2rem;border-radius:8px;font-weight:700;text-decoration:none;display:inline-block;">Se planer fra NOK 129/mo</a></p>
|
||||||
</main></body></html><?php
|
</main></body></html><?php
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,12 @@ $pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
|
|||||||
<div class="ms-card">
|
<div class="ms-card">
|
||||||
<h2>Plan</h2>
|
<h2>Plan</h2>
|
||||||
<div class="big" style="font-size:1.3rem;">
|
<div class="big" style="font-size:1.3rem;">
|
||||||
<?= htmlspecialchars(['light'=>'Light','pro'=>'Pro','pro_plus'=>'Pro+ Familie'][$tier] ?? $tier) ?>
|
<?= htmlspecialchars(['plus'=>'Plus','pro'=>'Pro Familie'][$tier] ?? ucfirst($tier)) ?>
|
||||||
|
<?php if (!empty($detail['trial_active'])): ?>
|
||||||
|
<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:0.7rem;padding:2px 8px;border-radius:999px;margin-left:0.5rem;vertical-align:middle;">
|
||||||
|
Prøveperiode · <?= (int)$detail['trial_days_remaining'] ?> dager igjen
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;">
|
<p style="margin:0;color:#6b7280;font-size:0.85rem;">
|
||||||
<a href="/pricing.php">Oppgrader</a> · <a href="/billing.php">Administrer</a>
|
<a href="/pricing.php">Oppgrader</a> · <a href="/billing.php">Administrer</a>
|
||||||
@@ -155,6 +161,51 @@ $pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
|
|||||||
</div>
|
</div>
|
||||||
<?php endforeach; endif; ?>
|
<?php endforeach; endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php $results = CaseResults::listForUser($userId, 50); ?>
|
||||||
|
<h2 style="font-family:'Crimson Pro',serif;margin:2.5rem 0 0.5rem;font-size:1.6rem;color:#00205B;">Lagrede analyser</h2>
|
||||||
|
<p class="lede" style="margin:0 0 1rem;">Alle resultater fra Korrespondanse, Advokat, BVJ, Dyp analyse, Motstrid og Tidslinje samles her — klar til å gjenåpnes, kjøres på nytt eller eksporteres.</p>
|
||||||
|
<section class="ms-results" aria-label="Lagrede analyser">
|
||||||
|
<?php if (empty($results)): ?>
|
||||||
|
<p class="ms-empty">Ingen lagrede analyser ennå. Kjør et verktøy for å lagre din første.</p>
|
||||||
|
<?php else: foreach ($results as $r): ?>
|
||||||
|
<div class="ms-result" data-result-id="<?= (int)$r['id'] ?>"
|
||||||
|
style="display:flex;align-items:center;gap:1rem;padding:0.95rem 1.25rem;border-bottom:1px solid #f3f4f6;background:#fff;">
|
||||||
|
<div style="font-size:1.4rem;line-height:1;"><?= htmlspecialchars(CaseResults::toolIcon((string)$r['tool'])) ?></div>
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="font-weight:600;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
||||||
|
<a href="/case-result.php?id=<?= (int)$r['id'] ?>" style="color:inherit;text-decoration:none;">
|
||||||
|
<?= htmlspecialchars((string)($r['title'] ?? CaseResults::toolLabel((string)$r['tool']))) ?>
|
||||||
|
</a>
|
||||||
|
<?php if (!empty($r['pinned'])): ?>
|
||||||
|
<span title="Festet" style="color:#c9a84c;margin-left:0.4rem;">★</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.85rem;color:#6b7280;">
|
||||||
|
<?= htmlspecialchars(CaseResults::toolLabel((string)$r['tool'])) ?>
|
||||||
|
· <?= htmlspecialchars(date('j. M Y H:i', strtotime((string)$r['created_at']))) ?>
|
||||||
|
<?php if (!empty($r['used_case_context'])): ?>
|
||||||
|
· <span style="background:#dbeafe;color:#1e3a8a;padding:1px 8px;border-radius:999px;font-size:0.75rem;font-weight:600;">Bruk min sak</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-result-actions" style="display:flex;gap:0.4rem;">
|
||||||
|
<button type="button" class="ms-pin" data-id="<?= (int)$r['id'] ?>"
|
||||||
|
style="background:#f3f4f6;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.85rem;">
|
||||||
|
<?= !empty($r['pinned']) ? 'Løsne' : 'Fest' ?>
|
||||||
|
</button>
|
||||||
|
<a href="/case-result.php?id=<?= (int)$r['id'] ?>"
|
||||||
|
style="background:#00205B;color:#fff;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:0.85rem;font-weight:600;">
|
||||||
|
Åpne
|
||||||
|
</a>
|
||||||
|
<button type="button" class="ms-result-delete" data-id="<?= (int)$r['id'] ?>"
|
||||||
|
style="background:#f3f4f6;border:none;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:0.85rem;">
|
||||||
|
Slett
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; endif; ?>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -222,6 +273,40 @@ $pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
|
|||||||
} catch (e) { alert('Nettverksfeil: ' + e.message); }
|
} catch (e) { alert('Nettverksfeil: ' + e.message); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Saved analyses: pin / delete
|
||||||
|
document.querySelectorAll('.ms-pin').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/case/result-action.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'pin', id: parseInt(id, 10) }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok) window.location.reload();
|
||||||
|
else alert(json.error?.message || 'Festing feilet.');
|
||||||
|
} catch (e) { alert('Nettverksfeil: ' + e.message); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.ms-result-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Slette denne analysen for godt?')) return;
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/case/result-action.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'delete', id: parseInt(id, 10) }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.ok) window.location.reload();
|
||||||
|
else alert(json.error?.message || 'Sletting feilet.');
|
||||||
|
} catch (e) { alert('Nettverksfeil: ' + e.message); }
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+67
-60
@@ -20,83 +20,84 @@ function pt(string $key, string $lang): string {
|
|||||||
return htmlspecialchars(dbnToolsT($key, $lang));
|
return htmlspecialchars(dbnToolsT($key, $lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New 3-tier NOK ladder. Plus carries a 14-day trial (card required, no charge for 14 days).
|
||||||
$tierNames = [
|
$tierNames = [
|
||||||
'free' => $uiLang === 'no' ? 'Gratis' : ($uiLang === 'uk' ? 'Безкоштовно' : ($uiLang === 'pl' ? 'Bezpłatnie' : 'Free')),
|
'free' => $uiLang === 'no' ? 'Gratis' : ($uiLang === 'uk' ? 'Безкоштовно' : ($uiLang === 'pl' ? 'Bezpłatnie' : 'Free')),
|
||||||
'light' => 'Light',
|
'plus' => 'Plus',
|
||||||
'pro' => 'Pro',
|
'pro' => $uiLang === 'no' ? 'Pro Familie' : ($uiLang === 'uk' ? 'Pro Сім\'я' : ($uiLang === 'pl' ? 'Pro Rodzina' : 'Pro Family')),
|
||||||
'pro_plus' => $uiLang === 'no' ? 'Pro+ Familie' : ($uiLang === 'uk' ? 'Pro+ Сім\'я' : ($uiLang === 'pl' ? 'Pro+ Rodzina' : 'Pro+ Family')),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$creditsPerMonth = $uiLang === 'no' ? 'kreditter / mnd' : ($uiLang === 'uk' ? 'кредитів / міс' : ($uiLang === 'pl' ? 'kredytów / mies' : 'credits / mo'));
|
||||||
|
$perMonth = $uiLang === 'no' ? '/ måned' : ($uiLang === 'uk' ? '/ міс' : ($uiLang === 'pl' ? '/ mies' : '/ month'));
|
||||||
|
$capSuffix = $uiLang === 'no' ? '/ time' : ($uiLang === 'uk' ? '/ год' : ($uiLang === 'pl' ? '/ godz' : '/ hour'));
|
||||||
|
|
||||||
$tiers = [
|
$tiers = [
|
||||||
[
|
[
|
||||||
'sku' => 'free',
|
'sku' => 'free',
|
||||||
'name' => $tierNames['free'],
|
'name' => $tierNames['free'],
|
||||||
'price' => '€0',
|
'price' => 'NOK 0',
|
||||||
'period' => dbnToolsT('pricing_period_always', $uiLang),
|
'period' => $uiLang === 'no' ? 'alltid' : 'always',
|
||||||
'credits' => '30 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
'credits' => '30 ' . $creditsPerMonth,
|
||||||
'storage' => dbnToolsT('pricing_no_storage', $uiLang),
|
'storage' => $uiLang === 'no' ? 'Ingen saksoppbevaring' : 'No case storage',
|
||||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
'seats' => $uiLang === 'no' ? '1 bruker' : '1 user',
|
||||||
'cap' => '10 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
'cap' => '10 ' . $capSuffix,
|
||||||
'features' => [
|
'features' => $uiLang === 'no' ? [
|
||||||
dbnToolsT('pricing_free_f1', $uiLang),
|
'Alle 11 verktøy på innlimt tekst',
|
||||||
dbnToolsT('pricing_free_f2', $uiLang),
|
'Norsk juridisk korpus (~220K passasjer)',
|
||||||
dbnToolsT('pricing_free_f3', $uiLang),
|
'EU-vert (Tyskland / Finland / Norge)',
|
||||||
|
] : [
|
||||||
|
'All 11 tools on pasted text',
|
||||||
|
'Norwegian legal corpus (~220K passages)',
|
||||||
|
'EU-hosted (Germany / Finland / Norway)',
|
||||||
],
|
],
|
||||||
'cta' => $isAuthed ? null : dbnToolsT('pricing_cta_login', $uiLang),
|
'cta' => $isAuthed ? null : dbnToolsT('pricing_cta_login', $uiLang),
|
||||||
'highlight' => false,
|
'highlight' => false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'sku' => 'light',
|
'sku' => 'plus',
|
||||||
'name' => $tierNames['light'],
|
'name' => $tierNames['plus'],
|
||||||
'price' => '€9',
|
'price' => 'NOK 129',
|
||||||
'period' => dbnToolsT('pricing_period_mo', $uiLang),
|
'period' => $perMonth,
|
||||||
'credits' => '120 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
'credits' => '250 ' . $creditsPerMonth,
|
||||||
'storage' => '100 MB',
|
'storage' => '500 MB',
|
||||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
'seats' => $uiLang === 'no' ? '1 bruker' : '1 user',
|
||||||
'cap' => '15 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
'cap' => '20 ' . $capSuffix,
|
||||||
'features' => [
|
'features' => $uiLang === 'no' ? [
|
||||||
dbnToolsT('pricing_light_f1', $uiLang),
|
'Min Sak — last opp dokumenter med OCR',
|
||||||
dbnToolsT('pricing_light_f2', $uiLang),
|
'Bruk min sak som kontekst i alle verktøy',
|
||||||
dbnToolsT('pricing_light_f3', $uiLang),
|
'Lagrede analyser — alle resultater samlet',
|
||||||
dbnToolsT('pricing_light_f4', $uiLang),
|
'14 dagers gratis prøveperiode (kort kreves)',
|
||||||
|
] : [
|
||||||
|
'My Case — upload documents with OCR',
|
||||||
|
'Use my case as context in every tool',
|
||||||
|
'Saved analyses — every run kept and searchable',
|
||||||
|
'14-day free trial (card required)',
|
||||||
],
|
],
|
||||||
'highlight' => false,
|
'highlight' => true,
|
||||||
|
'badge' => $uiLang === 'no' ? 'Mest populær' : 'Most popular',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'sku' => 'pro',
|
'sku' => 'pro',
|
||||||
'name' => $tierNames['pro'],
|
'name' => $tierNames['pro'],
|
||||||
'price' => '€29',
|
'price' => 'NOK 299',
|
||||||
'period' => dbnToolsT('pricing_period_mo', $uiLang),
|
'period' => $perMonth,
|
||||||
'credits' => '500 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
'credits' => '1000 ' . $creditsPerMonth,
|
||||||
'storage' => '1 GB',
|
'storage' => '5 GB',
|
||||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
'seats' => $uiLang === 'no' ? '3 brukere · delt sak' : '3 users · shared case',
|
||||||
'cap' => '30 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
'cap' => '40 ' . $capSuffix,
|
||||||
'features' => [
|
'features' => $uiLang === 'no' ? [
|
||||||
dbnToolsT('pricing_pro_f1', $uiLang),
|
'Alt i Plus, med mer plass og raskere modeller',
|
||||||
dbnToolsT('pricing_pro_f2', $uiLang),
|
'Familie-sete: 3 innlogginger på samme sak',
|
||||||
dbnToolsT('pricing_pro_f3', $uiLang),
|
'Prioritert GPT-4o for kompleks analyse',
|
||||||
dbnToolsT('pricing_pro_f4', $uiLang),
|
'Audit-logg for hvem som kjørte hva',
|
||||||
],
|
] : [
|
||||||
'highlight' => true,
|
'Everything in Plus, with more space and faster models',
|
||||||
'badge' => dbnToolsT('pricing_badge_popular', $uiLang),
|
'Family seats: 3 logins sharing one case',
|
||||||
],
|
'Priority GPT-4o for complex analysis',
|
||||||
[
|
'Audit log of who ran what',
|
||||||
'sku' => 'pro_plus',
|
|
||||||
'name' => $tierNames['pro_plus'],
|
|
||||||
'price' => '€79',
|
|
||||||
'period' => dbnToolsT('pricing_period_mo', $uiLang),
|
|
||||||
'credits' => dbnToolsT('pricing_unlimited', $uiLang),
|
|
||||||
'storage' => '10 GB',
|
|
||||||
'seats' => dbnToolsT('pricing_seats_family', $uiLang),
|
|
||||||
'cap' => '50 ' . dbnToolsT('pricing_cap_per_seat', $uiLang),
|
|
||||||
'features' => [
|
|
||||||
dbnToolsT('pricing_proplus_f1', $uiLang),
|
|
||||||
dbnToolsT('pricing_proplus_f2', $uiLang),
|
|
||||||
dbnToolsT('pricing_proplus_f3', $uiLang),
|
|
||||||
dbnToolsT('pricing_proplus_f4', $uiLang),
|
|
||||||
],
|
],
|
||||||
'highlight' => false,
|
'highlight' => false,
|
||||||
'badge' => dbnToolsT('pricing_badge_family', $uiLang),
|
'badge' => $uiLang === 'no' ? 'For familier' : 'For families',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -106,9 +107,9 @@ $topupNotes = [
|
|||||||
'topup_l' => dbnToolsT('pricing_topup_l_note', $uiLang),
|
'topup_l' => dbnToolsT('pricing_topup_l_note', $uiLang),
|
||||||
];
|
];
|
||||||
$topups = [
|
$topups = [
|
||||||
['sku' => 'topup_s', 'price' => '€5', 'credits' => 30, 'note' => $topupNotes['topup_s']],
|
['sku' => 'topup_s', 'price' => 'NOK 49', 'credits' => 30, 'note' => $topupNotes['topup_s']],
|
||||||
['sku' => 'topup_m', 'price' => '€15', 'credits' => 100, 'note' => $topupNotes['topup_m']],
|
['sku' => 'topup_m', 'price' => 'NOK 149', 'credits' => 100, 'note' => $topupNotes['topup_m']],
|
||||||
['sku' => 'topup_l', 'price' => '€40', 'credits' => 300, 'note' => $topupNotes['topup_l']],
|
['sku' => 'topup_l', 'price' => 'NOK 399', 'credits' => 300, 'note' => $topupNotes['topup_l']],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -194,6 +195,12 @@ $topups = [
|
|||||||
<p class="status-pill-info"><?= pt('pricing_status_canceled', $uiLang) ?></p>
|
<p class="status-pill-info"><?= pt('pricing_status_canceled', $uiLang) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="background:linear-gradient(135deg,#fef3c7,#fcd34d);color:#78350f;padding:1rem 1.5rem;border-radius:10px;margin-bottom:2rem;text-align:center;font-weight:600;">
|
||||||
|
<?= $uiLang === 'no'
|
||||||
|
? '🎉 Prøv Plus gratis i 14 dager — kort kreves, kanseller når som helst, ingen belastning før dag 15.'
|
||||||
|
: '🎉 Try Plus free for 14 days — card required, cancel anytime, no charge until day 15.' ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php if ($isAuthed && !$surveyDone): ?>
|
<?php if ($isAuthed && !$surveyDone): ?>
|
||||||
<div class="survey-banner">
|
<div class="survey-banner">
|
||||||
<div class="copy">
|
<div class="copy">
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Migration 001: Premium "My Case" tier
|
||||||
|
-- Run against dobetternorge_maindb:
|
||||||
|
-- mysql -u root dobetternorge_maindb < scripts/sql/001_premium_my_case.sql
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Creates case_tool_results — persists every tool run for paid users
|
||||||
|
-- 2. Adds trial_* columns to user_tool_credits — mirrors Stripe trial state
|
||||||
|
-- 3. Simplifies tier enums to the MVP ladder (free|plus|pro)
|
||||||
|
|
||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
-- ── 1. case_tool_results ────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS case_tool_results (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL COMMENT 'Session user (may be a family member)',
|
||||||
|
owner_user_id INT UNSIGNED NOT NULL COMMENT 'caseResolveClientId result; whose corpus was queried',
|
||||||
|
tool VARCHAR(40) NOT NULL COMMENT 'korrespond | advocate | barnevernet | deep-research | discrepancy | timeline',
|
||||||
|
title VARCHAR(200) NULL COMMENT 'User-editable; defaults to first 80 chars of input',
|
||||||
|
used_case_context TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
case_doc_ids JSON NULL COMMENT 'case_documents.id values that contributed chunks',
|
||||||
|
input_payload JSON NOT NULL COMMENT 'Full request body for re-run',
|
||||||
|
output_payload JSON NOT NULL COMMENT 'Full tool response',
|
||||||
|
model VARCHAR(60) NULL COMMENT 'Azure deployment name actually used',
|
||||||
|
latency_ms INT UNSIGNED NULL,
|
||||||
|
credits_charged TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
pinned TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_user_created (user_id, created_at DESC),
|
||||||
|
KEY idx_owner_tool (owner_user_id, tool, created_at DESC),
|
||||||
|
KEY idx_pinned (user_id, pinned, created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Saved tool outputs per case — premium "My Case" feature';
|
||||||
|
|
||||||
|
-- ── 2. trial columns on user_tool_credits ───────────────────────────────────
|
||||||
|
ALTER TABLE user_tool_credits
|
||||||
|
ADD COLUMN trial_started_at DATETIME NULL AFTER tier,
|
||||||
|
ADD COLUMN trial_expires_at DATETIME NULL AFTER trial_started_at,
|
||||||
|
ADD COLUMN trial_downgraded_at DATETIME NULL AFTER trial_expires_at;
|
||||||
|
|
||||||
|
-- ── 3. simplify tier enum ───────────────────────────────────────────────────
|
||||||
|
-- No production subscribers exist (demo configuration only) — safe to collapse.
|
||||||
|
UPDATE user_tool_credits SET tier = 'plus' WHERE tier = 'light';
|
||||||
|
UPDATE user_tool_credits SET tier = 'pro' WHERE tier = 'pro_plus';
|
||||||
|
|
||||||
|
ALTER TABLE user_tool_credits
|
||||||
|
MODIFY COLUMN tier ENUM('free','plus','pro') NOT NULL DEFAULT 'free';
|
||||||
|
|
||||||
|
UPDATE user_subscriptions SET tier = 'plus' WHERE tier = 'light';
|
||||||
|
UPDATE user_subscriptions SET tier = 'pro' WHERE tier = 'pro_plus';
|
||||||
|
|
||||||
|
ALTER TABLE user_subscriptions
|
||||||
|
MODIFY COLUMN tier ENUM('plus','pro') NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -86,6 +86,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
<p class="upload-hint" data-i18n="timelineNotesHint">These notes are included in the prompt to help the model interpret ambiguous dates, actors, or abbreviations. Not stored.</p>
|
<p class="upload-hint" data-i18n="timelineNotesHint">These notes are included in the prompt to help the model interpret ambiguous dates, actors, or abbreviations. Not stored.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||||
<button id="runButton" type="submit" data-i18n="timelineRun">Run</button>
|
<button id="runButton" type="submit" data-i18n="timelineRun">Run</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user