Add premium My Case MVP
This commit is contained in:
@@ -192,6 +192,8 @@ require_once __DIR__ . '/includes/layout.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="advStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<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/BvjAnalyzerAgent.php';
|
||||
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
@@ -54,7 +56,7 @@ try {
|
||||
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
||||
$engine = (string)($input['engine'] ?? 'azure_mini');
|
||||
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
||||
$sliceInput = $input['slices'] ?? [];
|
||||
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
||||
$additionalNotes = mb_substr(trim((string)($input['additional_notes'] ?? '')), 0, 2000, 'UTF-8');
|
||||
@@ -112,6 +114,17 @@ try {
|
||||
'file_count' => count($uploadedFiles),
|
||||
]);
|
||||
|
||||
// Optional: append user's case-context chunks to the last uploaded document text,
|
||||
// so the agent reads them as supplementary background.
|
||||
$useMyCase = !empty($input['use_my_case']);
|
||||
if ($useMyCase && !empty($uploadedFiles)) {
|
||||
$retrievalQuery = mb_substr((string)$uploadedFiles[0]['text'], 0, 2000, 'UTF-8');
|
||||
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
|
||||
if ($caseBlock !== '') {
|
||||
$uploadedFiles[0]['text'] .= "\n\n" . $caseBlock;
|
||||
}
|
||||
}
|
||||
|
||||
$result = (new DbnBvjAnalyzerAgent())->run(
|
||||
$uploadedFiles,
|
||||
$advocateRole,
|
||||
@@ -138,6 +151,20 @@ try {
|
||||
'bvj_doc_type' => $result['doc_meta']['doc_type'] ?? null,
|
||||
]);
|
||||
|
||||
if ($ftUid > 0) {
|
||||
$ownerId = CaseStore::caseResolveClientId($ftUid);
|
||||
$resultId = CaseResults::save($ftUid, $ownerId, 'barnevernet', $input, $result, [
|
||||
'used_case_context' => $useMyCase ? 1 : 0,
|
||||
'case_doc_ids' => dbnToolsLastCaseDocIds(),
|
||||
'model' => $result['trace_metadata']['deployment'] ?? $engine,
|
||||
'latency_ms' => $result['latency_ms'],
|
||||
'credits_charged' => FreeTier::cost('barnevernet'),
|
||||
]);
|
||||
if ($resultId > 0) {
|
||||
$result['result_id'] = $resultId;
|
||||
}
|
||||
}
|
||||
|
||||
$emit('final', ['result' => $result]);
|
||||
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
|
||||
@@ -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/DeepResearchAgent.php';
|
||||
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
@@ -59,7 +61,7 @@ try {
|
||||
$seedQuery = trim((string)($input['query'] ?? ''));
|
||||
$pastedText = trim((string)($input['paste_text'] ?? ''));
|
||||
$sliceInput = $input['slices'] ?? [];
|
||||
$engine = (string)($input['engine'] ?? 'azure_mini');
|
||||
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
||||
$controls = is_array($input['controls'] ?? null) ? $input['controls'] : [];
|
||||
$advocateRole = trim((string)($input['advocate_role'] ?? ''));
|
||||
if (mb_strlen($advocateRole, 'UTF-8') > 200) {
|
||||
@@ -115,6 +117,16 @@ try {
|
||||
'upload_count' => count($uploadedFiles),
|
||||
]);
|
||||
|
||||
// Optional: append user's case-context chunks to pasted_text so the agent sees them.
|
||||
$useMyCase = !empty($input['use_my_case']);
|
||||
if ($useMyCase) {
|
||||
$retrievalQuery = $seedQuery !== '' ? $seedQuery : mb_substr($pastedText, 0, 2000, 'UTF-8');
|
||||
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
|
||||
if ($caseBlock !== '') {
|
||||
$pastedText = ($pastedText === '') ? $caseBlock : ($pastedText . "\n\n" . $caseBlock);
|
||||
}
|
||||
}
|
||||
|
||||
$result = (new DbnDeepResearchAgent())->run(
|
||||
$seedQuery,
|
||||
$pastedText,
|
||||
@@ -144,6 +156,21 @@ try {
|
||||
'advocate_role' => $advocateRole !== '' ? $advocateRole : null,
|
||||
]);
|
||||
|
||||
if ($ftUid > 0) {
|
||||
$toolSlug = $advocateRole !== '' ? 'advocate' : 'deep-research';
|
||||
$ownerId = CaseStore::caseResolveClientId($ftUid);
|
||||
$resultId = CaseResults::save($ftUid, $ownerId, $toolSlug, $input, $result, [
|
||||
'used_case_context' => $useMyCase ? 1 : 0,
|
||||
'case_doc_ids' => dbnToolsLastCaseDocIds(),
|
||||
'model' => $result['trace_metadata']['deployment'] ?? $engine,
|
||||
'latency_ms' => $result['latency_ms'],
|
||||
'credits_charged' => FreeTier::cost($toolSlug),
|
||||
]);
|
||||
if ($resultId > 0) {
|
||||
$result['result_id'] = $resultId;
|
||||
}
|
||||
}
|
||||
|
||||
$emit('final', ['result' => $result]);
|
||||
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
|
||||
+27
-1
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/DiscrepancyAgent.php';
|
||||
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
@@ -42,7 +44,7 @@ try {
|
||||
}
|
||||
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
$engine = (string)($input['engine'] ?? 'azure_mini');
|
||||
$engine = ToolModels::engineForUser($ftUid, (string)($input['engine'] ?? 'azure_mini'));
|
||||
$sliceInput = $input['slices'] ?? [];
|
||||
|
||||
// Extract file A
|
||||
@@ -111,6 +113,16 @@ try {
|
||||
'file_b' => $fileB['filename'],
|
||||
]);
|
||||
|
||||
// Optional: append the user's case-context as supplementary background to Doc A.
|
||||
$useMyCase = !empty($input['use_my_case']);
|
||||
if ($useMyCase) {
|
||||
$retrievalQuery = mb_substr((string)$fileA['text'], 0, 2000, 'UTF-8');
|
||||
$caseBlock = dbnToolsCaseContext(true, $retrievalQuery, 5);
|
||||
if ($caseBlock !== '') {
|
||||
$fileA['text'] .= "\n\n" . $caseBlock;
|
||||
}
|
||||
}
|
||||
|
||||
$result = (new DbnDiscrepancyAgent())->run(
|
||||
$fileA,
|
||||
$fileB,
|
||||
@@ -135,6 +147,20 @@ try {
|
||||
'deployment' => $result['trace_metadata']['deployment'] ?? null,
|
||||
]);
|
||||
|
||||
if ($ftUid > 0) {
|
||||
$ownerId = CaseStore::caseResolveClientId($ftUid);
|
||||
$resultId = CaseResults::save($ftUid, $ownerId, 'discrepancy', $input, $result, [
|
||||
'used_case_context' => $useMyCase ? 1 : 0,
|
||||
'case_doc_ids' => dbnToolsLastCaseDocIds(),
|
||||
'model' => $result['trace_metadata']['deployment'] ?? $engine,
|
||||
'latency_ms' => $result['latency_ms'],
|
||||
'credits_charged' => FreeTier::cost('discrepancy'),
|
||||
]);
|
||||
if ($resultId > 0) {
|
||||
$result['result_id'] = $resultId;
|
||||
}
|
||||
}
|
||||
|
||||
$emit('final', ['result' => $result]);
|
||||
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/KorrespondAgent.php';
|
||||
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
@@ -184,6 +185,21 @@ try {
|
||||
'deployment' => 'gpt-4o',
|
||||
]);
|
||||
|
||||
// Premium: persist the run for paid (Plus/Pro) users so it shows up in Min Sak → Saved analyses.
|
||||
if ($ftUid > 0) {
|
||||
$ownerId = CaseStore::caseResolveClientId($ftUid);
|
||||
$resultId = CaseResults::save($ftUid, $ownerId, 'korrespond', $input, $result, [
|
||||
'used_case_context' => !empty($intake['use_my_case']) ? 1 : 0,
|
||||
'case_doc_ids' => dbnToolsLastCaseDocIds(),
|
||||
'model' => 'gpt-4o',
|
||||
'latency_ms' => $result['latency_ms'],
|
||||
'credits_charged' => FreeTier::cost('korrespond'),
|
||||
]);
|
||||
if ($resultId > 0) {
|
||||
$result['result_id'] = $resultId;
|
||||
}
|
||||
}
|
||||
|
||||
$emit('final', ['result' => $result]);
|
||||
|
||||
} catch (DbnToolsHttpException $e) {
|
||||
|
||||
+10
-1
@@ -18,7 +18,7 @@ if ($userId <= 0 || $email === '') {
|
||||
$input = dbnToolsJsonInput(2000);
|
||||
$sku = (string)($input['sku'] ?? '');
|
||||
|
||||
$validSubscriptions = ['light', 'pro', 'pro_plus'];
|
||||
$validSubscriptions = ['plus', 'pro'];
|
||||
$validTopups = ['topup_s', 'topup_m', 'topup_l'];
|
||||
|
||||
if (!in_array($sku, array_merge($validSubscriptions, $validTopups), true)) {
|
||||
@@ -55,9 +55,18 @@ try {
|
||||
];
|
||||
|
||||
if ($isSub) {
|
||||
FreeTier::ensureRow($userId);
|
||||
$detail = FreeTier::balanceDetail($userId);
|
||||
$params['subscription_data'] = [
|
||||
'metadata' => ['user_id' => (string)$userId, 'tier' => $sku],
|
||||
];
|
||||
if ($sku === 'plus' && empty($detail['trial_started_at'])) {
|
||||
$params['subscription_data']['trial_period_days'] = 14;
|
||||
$params['subscription_data']['trial_settings'] = [
|
||||
'end_behavior' => ['missing_payment_method' => 'cancel'],
|
||||
];
|
||||
}
|
||||
$params['payment_method_collection'] = 'always';
|
||||
} else {
|
||||
$params['payment_intent_data'] = [
|
||||
'metadata' => ['user_id' => (string)$userId, 'sku' => $sku, 'credits' => (string)StripeClient::topupCredits($sku)],
|
||||
|
||||
@@ -76,6 +76,10 @@ try {
|
||||
handleSubscriptionDeleted($db, $object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.trial_will_end':
|
||||
// Stripe sends the customer reminder email. We mirror state on subscription.updated/deleted.
|
||||
break;
|
||||
|
||||
case 'invoice.paid':
|
||||
handleInvoicePaid($db, $object);
|
||||
break;
|
||||
@@ -154,8 +158,10 @@ function handleSubscriptionChange(PDO $db, array $sub): void
|
||||
|
||||
$periodEndTs = (int)($sub['current_period_end'] ?? 0);
|
||||
$periodStartTs = (int)($sub['current_period_start'] ?? 0);
|
||||
$trialEndTs = (int)($sub['trial_end'] ?? 0);
|
||||
$periodEndIso = $periodEndTs > 0 ? gmdate('Y-m-d H:i:s', $periodEndTs) : null;
|
||||
$periodStartIso = $periodStartTs > 0 ? gmdate('Y-m-d H:i:s', $periodStartTs) : null;
|
||||
$trialEndIso = $trialEndTs > 0 ? gmdate('Y-m-d H:i:s', $trialEndTs) : null;
|
||||
|
||||
// Upsert subscription ledger
|
||||
$db->prepare(
|
||||
@@ -172,7 +178,7 @@ function handleSubscriptionChange(PDO $db, array $sub): void
|
||||
|
||||
// Only flip the live tier flag if subscription is active/trialing.
|
||||
if (in_array($status, ['active', 'trialing'], true)) {
|
||||
FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso);
|
||||
FreeTier::setTier($userId, $tier, $customerId, $subId, $periodEndIso, $status === 'trialing' ? $trialEndIso : null);
|
||||
} elseif (in_array($status, ['canceled', 'unpaid', 'incomplete_expired'], true)) {
|
||||
FreeTier::clearTier($userId);
|
||||
}
|
||||
|
||||
+31
-2
@@ -2,6 +2,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../includes/LegalTools.php';
|
||||
require_once __DIR__ . '/../includes/CaseResults.php';
|
||||
require_once __DIR__ . '/../includes/ToolModels.php';
|
||||
|
||||
dbnToolsRequireMethod('POST');
|
||||
dbnToolsRequireAuth();
|
||||
@@ -11,12 +13,13 @@ if ($ftRemaining >= 0) { header('X-Credits-Remaining: ' . $ftRemaining); }
|
||||
$input = dbnToolsJsonInput(400000);
|
||||
$language = dbnToolsNormalizeLanguage($input['language'] ?? 'en');
|
||||
|
||||
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language): array {
|
||||
dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language, $ftUid): array {
|
||||
$text = dbnToolsString($input, 'text', 128000);
|
||||
|
||||
$validEngines = ['azure_mini', 'azure_full', 'gpu'];
|
||||
$engine = in_array((string)($input['engine'] ?? ''), $validEngines, true)
|
||||
? (string)$input['engine'] : 'azure_mini';
|
||||
$engine = ToolModels::engineForUser($ftUid, $engine);
|
||||
|
||||
$validFocus = ['all', 'deadlines', 'hearings', 'cps'];
|
||||
$focus = in_array((string)($input['focus'] ?? ''), $validFocus, true)
|
||||
@@ -29,5 +32,31 @@ dbnToolsWithTelemetry('timeline', $language, function () use ($input, $language)
|
||||
$includeBackground = ($input['include_background'] ?? true) !== false;
|
||||
$userNotes = dbnToolsString($input, 'user_notes', 2000, false);
|
||||
|
||||
return (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes);
|
||||
// Optional: prepend the user's case-context chunks so the timeline includes events
|
||||
// referenced in their uploaded case documents.
|
||||
$useMyCase = !empty($input['use_my_case']);
|
||||
if ($useMyCase) {
|
||||
$caseBlock = dbnToolsCaseContext(true, $text, 5);
|
||||
if ($caseBlock !== '') {
|
||||
$text = $text . "\n\n" . $caseBlock;
|
||||
}
|
||||
}
|
||||
|
||||
$result = (new DbnLegalToolsService())->timeline($text, $language, $engine, $focus, $confidenceFilter, $includeRelative, $includeBackground, $userNotes);
|
||||
|
||||
// Persist for paid users (silently no-op for free)
|
||||
if ($ftUid > 0) {
|
||||
$ownerId = CaseStore::caseResolveClientId($ftUid);
|
||||
$resultId = CaseResults::save($ftUid, $ownerId, 'timeline', $input, $result, [
|
||||
'used_case_context' => $useMyCase ? 1 : 0,
|
||||
'case_doc_ids' => dbnToolsLastCaseDocIds(),
|
||||
'model' => $engine,
|
||||
'latency_ms' => (int)($result['latency_ms'] ?? 0),
|
||||
'credits_charged' => FreeTier::cost('timeline'),
|
||||
]);
|
||||
if ($resultId > 0) {
|
||||
$result['result_id'] = $resultId;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
});
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
language: lang,
|
||||
controls: getControls(),
|
||||
advocate_role: advocateRole,
|
||||
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||
};
|
||||
if (branchContext) {
|
||||
payload.prior_context = branchContext;
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
slices,
|
||||
controls: getControls(),
|
||||
additional_notes: additionalNotes,
|
||||
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||||
};
|
||||
|
||||
if (branchContext) {
|
||||
|
||||
@@ -202,7 +202,10 @@
|
||||
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
|
||||
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();
|
||||
form.append('payload', JSON.stringify(payload));
|
||||
form.append('file_a', fileA);
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
goal: els.goal.value.trim(),
|
||||
clarifications: pendingClarifications,
|
||||
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_background = currentIncludeBackground();
|
||||
payload.user_notes = (document.getElementById('timelineNotes')?.value || '').trim();
|
||||
payload.use_my_case = (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false;
|
||||
}
|
||||
|
||||
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>
|
||||
<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">
|
||||
<p id="bvjStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<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',
|
||||
'storage_used_bytes' => 0, 'storage_quota_bytes' => 0,
|
||||
'survey_completed_at' => null, 'subscription_period_end' => null,
|
||||
'trial_active' => false, 'trial_days_remaining' => 0, 'trial_expires_at' => null,
|
||||
];
|
||||
|
||||
$tier = (string)$detail['tier'];
|
||||
$isPaidTier = in_array($tier, ['plus', 'pro'], true);
|
||||
$effective = (int)$detail['balance'] + (int)$detail['bonus_balance'];
|
||||
$storageMb = round($detail['storage_used_bytes'] / 1048576, 1);
|
||||
$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 = [
|
||||
'free' => 'Gratis',
|
||||
'light' => 'Light',
|
||||
'pro' => 'Pro',
|
||||
'pro_plus' => 'Pro+ Familie',
|
||||
'plus' => 'Plus',
|
||||
'pro' => 'Pro Familie',
|
||||
'caveau' => 'CaveauAI',
|
||||
];
|
||||
|
||||
@@ -66,9 +67,8 @@ $status = (string)($_GET['status'] ?? '');
|
||||
.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-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_plus { background: #fde68a; color: #92400e; }
|
||||
.tier-caveau { background: #d1fae5; color: #065f46; }
|
||||
.balance-big { font-size: 2.6rem; font-weight: 700; color: #00205B; margin: 0.5rem 0; }
|
||||
.balance-break { color: #6b7280; font-size: 0.9rem; }
|
||||
@@ -104,13 +104,16 @@ $status = (string)($_GET['status'] ?? '');
|
||||
<p style="margin:0.5rem 0 1rem;">
|
||||
<span class="tier-badge tier-<?= htmlspecialchars($tier) ?>"><?= htmlspecialchars($tierLabels[$tier] ?? $tier) ?></span>
|
||||
</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'])): ?>
|
||||
<p class="balance-break">Fornyes <?= htmlspecialchars(date('j. F Y', strtotime((string)$detail['subscription_period_end']))) ?></p>
|
||||
<?php elseif ($tier === 'free'): ?>
|
||||
<p class="balance-break">Ingen aktiv abonnement. <a href="/pricing.php">Se planer</a></p>
|
||||
<?php endif; ?>
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-primary" href="/pricing.php">Se alle planer</a>
|
||||
@@ -123,12 +126,9 @@ $status = (string)($_GET['status'] ?? '');
|
||||
<p class="balance-break">
|
||||
<?= (int)$detail['balance'] ?> månedlige · <?= (int)$detail['bonus_balance'] ?> bonus
|
||||
</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>
|
||||
|
||||
<?php if (in_array($tier, ['light','pro','pro_plus'], true)): ?>
|
||||
<?php if ($isPaidTier): ?>
|
||||
<div class="billing-card">
|
||||
<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>
|
||||
|
||||
+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;
|
||||
$tierLabels = [
|
||||
'free' => ['Gratis', '#f3f4f6', '#374151'],
|
||||
'light' => ['Light', '#ddd6fe', '#5b21b6'],
|
||||
'pro' => ['Pro', '#bfdbfe', '#1e40af'],
|
||||
'pro_plus' => ['Pro+ Familie', '#fde68a', '#92400e'],
|
||||
'plus' => ['Plus', '#ddd6fe', '#5b21b6'],
|
||||
'pro' => ['Pro Familie', '#bfdbfe', '#1e40af'],
|
||||
];
|
||||
$tierLabel = $tierLabels[$dashTier] ?? ['CaveauAI', '#d1fae5', '#065f46'];
|
||||
$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.35rem 0 0; font-size:1.8rem; font-weight:700; color:#00205B;">
|
||||
<?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 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>
|
||||
<?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;">
|
||||
<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>
|
||||
@@ -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;">
|
||||
<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; 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>
|
||||
<?php endif; ?>
|
||||
<?php if ($showSurveyCta): ?>
|
||||
|
||||
@@ -149,6 +149,8 @@ require_once __DIR__ . '/includes/layout.php';
|
||||
<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>
|
||||
|
||||
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="drStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<button id="drRunButton" type="submit">Run deep research</button>
|
||||
|
||||
@@ -113,6 +113,8 @@ require_once __DIR__ . '/includes/layout.php';
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="dcStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<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'];
|
||||
$used = (int)$detail['storage_used_bytes'];
|
||||
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) {
|
||||
$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.
|
||||
*
|
||||
* Three tiers: free, plus, pro (NOK pricing, see pricing.php).
|
||||
*
|
||||
* 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_subscriptions — Stripe subscription ledger
|
||||
*
|
||||
* Effective balance = balance + 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.
|
||||
*/
|
||||
final class FreeTier
|
||||
@@ -33,28 +40,25 @@ final class FreeTier
|
||||
'korrespond' => 3,
|
||||
];
|
||||
|
||||
/** Monthly credit allowance per tier. pro_plus is "effectively unlimited" but hourly-capped. */
|
||||
/** Monthly credit allowance per tier. */
|
||||
private const MONTHLY_ALLOWANCE = [
|
||||
'free' => 30,
|
||||
'light' => 120,
|
||||
'pro' => 500,
|
||||
'pro_plus' => 999999,
|
||||
'plus' => 250,
|
||||
'pro' => 1000,
|
||||
];
|
||||
|
||||
/** Hourly rate-limit per tier (number of paid tool calls per rolling hour). */
|
||||
private const HOURLY_CAP = [
|
||||
'free' => 10,
|
||||
'light' => 15,
|
||||
'pro' => 30,
|
||||
'pro_plus' => 50,
|
||||
'plus' => 20,
|
||||
'pro' => 40,
|
||||
];
|
||||
|
||||
/** Per-user case-storage quota in bytes. */
|
||||
private const STORAGE_QUOTA = [
|
||||
'free' => 0,
|
||||
'light' => 104857600, // 100 MB
|
||||
'pro' => 1073741824, // 1 GB
|
||||
'pro_plus' => 10737418240, // 10 GB
|
||||
'plus' => 524288000, // 500 MB
|
||||
'pro' => 5368709120, // 5 GB
|
||||
];
|
||||
|
||||
/** Credit cost for a given tool slug. Returns 1 for unknown tools. */
|
||||
@@ -85,6 +89,37 @@ final class FreeTier
|
||||
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. */
|
||||
public static function row(int $userId): ?array
|
||||
{
|
||||
@@ -94,9 +129,8 @@ final class FreeTier
|
||||
"UPDATE user_tool_credits
|
||||
SET balance = CASE tier
|
||||
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_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
|
||||
ELSE balance END,
|
||||
last_reset = CURDATE()
|
||||
WHERE user_id = ?
|
||||
@@ -140,7 +174,7 @@ final class FreeTier
|
||||
'tier' => $tier,
|
||||
];
|
||||
|
||||
// Hourly rate limit (always applies, even to pro_plus)
|
||||
// Hourly rate limit (always applies)
|
||||
$stmt = $db->prepare(
|
||||
'SELECT COUNT(*) FROM user_tool_usage_log
|
||||
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];
|
||||
}
|
||||
|
||||
// pro_plus bypasses credit check
|
||||
if ($tier === 'pro_plus') {
|
||||
return $base + ['ok' => true];
|
||||
}
|
||||
|
||||
if (($balance + $bonus) < $cost) {
|
||||
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.
|
||||
* 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).
|
||||
*/
|
||||
@@ -180,9 +208,8 @@ final class FreeTier
|
||||
$db = dbnmDb();
|
||||
$cost = self::cost($tool);
|
||||
$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'];
|
||||
$bonus = (int)$row['bonus_balance'];
|
||||
|
||||
@@ -227,6 +254,10 @@ final class FreeTier
|
||||
'storage_quota_bytes' => self::storageQuota((string)$row['tier']),
|
||||
'survey_completed_at' => $row['survey_completed_at'] ?? 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).
|
||||
* 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(
|
||||
int $userId,
|
||||
string $tier,
|
||||
?string $stripeCustomerId,
|
||||
?string $subscriptionId,
|
||||
?string $periodEndIso
|
||||
?string $periodEndIso,
|
||||
?string $trialEndIso = null
|
||||
): void {
|
||||
$db = dbnmDb();
|
||||
self::ensureRow($userId);
|
||||
$allowance = self::monthlyAllowance($tier);
|
||||
|
||||
if ($trialEndIso !== null) {
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, balance = ?, allowance = ?,
|
||||
stripe_customer_id = COALESCE(?, stripe_customer_id),
|
||||
subscription_id = ?,
|
||||
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()
|
||||
WHERE user_id = ?'
|
||||
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Preserves bonus_balance and case_documents (handled by 90-day cron).
|
||||
* Revert a user to free tier (subscription canceled, trial ended without conversion).
|
||||
* 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
|
||||
{
|
||||
$db = dbnmDb();
|
||||
$db->prepare(
|
||||
'UPDATE user_tool_credits
|
||||
SET tier = ?, allowance = ?, subscription_id = NULL, subscription_period_end = NULL
|
||||
WHERE user_id = ?'
|
||||
)->execute(['free', self::monthlyAllowance('free'), $userId]);
|
||||
"UPDATE user_tool_credits
|
||||
SET tier = 'free',
|
||||
allowance = ?,
|
||||
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. */
|
||||
|
||||
@@ -10,7 +10,7 @@ declare(strict_types=1);
|
||||
* STRIPE_PUBLISHABLE_KEY pk_live_... or pk_test_...
|
||||
* STRIPE_WEBHOOK_SECRET whsec_...
|
||||
* STRIPE_PRICE_TOPUP_S / _M / _L
|
||||
* STRIPE_PRICE_LIGHT / _PRO / _PRO_PLUS
|
||||
* STRIPE_PRICE_PLUS_NOK / STRIPE_PRICE_PRO_NOK
|
||||
*/
|
||||
final class StripeClient
|
||||
{
|
||||
@@ -55,9 +55,8 @@ final class StripeClient
|
||||
'topup_s' => self::config('STRIPE_PRICE_TOPUP_S'),
|
||||
'topup_m' => self::config('STRIPE_PRICE_TOPUP_M'),
|
||||
'topup_l' => self::config('STRIPE_PRICE_TOPUP_L'),
|
||||
'light' => self::config('STRIPE_PRICE_LIGHT'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO'),
|
||||
'pro_plus' => self::config('STRIPE_PRICE_PRO_PLUS'),
|
||||
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||
];
|
||||
}
|
||||
$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
|
||||
{
|
||||
foreach (['light', 'pro', 'pro_plus'] as $tier) {
|
||||
if (self::config('STRIPE_PRICE_' . strtoupper($tier)) === $priceId) {
|
||||
return $tier;
|
||||
$map = [
|
||||
'plus' => self::config('STRIPE_PRICE_PLUS_NOK'),
|
||||
'pro' => self::config('STRIPE_PRICE_PRO_NOK'),
|
||||
];
|
||||
foreach ($map as $tier => $configuredPriceId) {
|
||||
if ($configuredPriceId !== '' && hash_equals($configuredPriceId, $priceId)) {
|
||||
return (string)$tier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -173,7 +176,7 @@ final class StripeClient
|
||||
$method = strtoupper($method);
|
||||
$headers = [
|
||||
'Authorization: Bearer ' . $this->secretKey,
|
||||
'Stripe-Version: 2024-10-28.acacia',
|
||||
'Stripe-Version: 2026-02-25.clover',
|
||||
];
|
||||
|
||||
$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).
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
{
|
||||
if (!$useMyCase) return '';
|
||||
if (!dbnToolsIsFreeTier()) return '';
|
||||
if (!$useMyCase) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
if (!dbnToolsIsFreeTier()) { $GLOBALS['dbn_last_case_doc_ids'] = []; return ''; }
|
||||
$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';
|
||||
$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';
|
||||
$effective = CaseStore::caseResolveClientId($userId);
|
||||
$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
|
||||
try {
|
||||
@@ -528,6 +532,12 @@ function dbnToolsCaseContext(bool $useMyCase, string $query, int $k = 5): string
|
||||
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. */
|
||||
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>
|
||||
|
||||
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="korrStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<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/FreeTier.php';
|
||||
require_once __DIR__ . '/includes/CaseStore.php';
|
||||
require_once __DIR__ . '/includes/CaseResults.php';
|
||||
|
||||
if (!dbnToolsIsAuthenticated()) {
|
||||
header('Location: /?return=' . urlencode('/min-sak.php'));
|
||||
@@ -21,7 +22,7 @@ $detail = FreeTier::balanceDetail($userId);
|
||||
$tier = (string)$detail['tier'];
|
||||
|
||||
// Free tier: show upgrade gate
|
||||
if (!in_array($tier, ['light', 'pro', 'pro_plus'], true)) {
|
||||
if (!FreeTier::isPaidTier($tier)) {
|
||||
require_once __DIR__ . '/includes/footer.php';
|
||||
$upgradeUrl = '/pricing.php';
|
||||
?><!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>🇪🇺 Alt lagres i EU (Tyskland/Finland/Norge)</li>
|
||||
</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
|
||||
exit;
|
||||
}
|
||||
@@ -112,7 +113,12 @@ $pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
|
||||
<div class="ms-card">
|
||||
<h2>Plan</h2>
|
||||
<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>
|
||||
<p style="margin:0;color:#6b7280;font-size:0.85rem;">
|
||||
<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>
|
||||
<?php endforeach; endif; ?>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -222,6 +273,40 @@ $pct = $quota > 0 ? min(100, round(($used / $quota) * 100)) : 0;
|
||||
} 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>
|
||||
</body>
|
||||
|
||||
+67
-60
@@ -20,83 +20,84 @@ function pt(string $key, string $lang): string {
|
||||
return htmlspecialchars(dbnToolsT($key, $lang));
|
||||
}
|
||||
|
||||
// New 3-tier NOK ladder. Plus carries a 14-day trial (card required, no charge for 14 days).
|
||||
$tierNames = [
|
||||
'free' => $uiLang === 'no' ? 'Gratis' : ($uiLang === 'uk' ? 'Безкоштовно' : ($uiLang === 'pl' ? 'Bezpłatnie' : 'Free')),
|
||||
'light' => 'Light',
|
||||
'pro' => 'Pro',
|
||||
'pro_plus' => $uiLang === 'no' ? 'Pro+ Familie' : ($uiLang === 'uk' ? 'Pro+ Сім\'я' : ($uiLang === 'pl' ? 'Pro+ Rodzina' : 'Pro+ Family')),
|
||||
'plus' => 'Plus',
|
||||
'pro' => $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 = [
|
||||
[
|
||||
'sku' => 'free',
|
||||
'name' => $tierNames['free'],
|
||||
'price' => '€0',
|
||||
'period' => dbnToolsT('pricing_period_always', $uiLang),
|
||||
'credits' => '30 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
||||
'storage' => dbnToolsT('pricing_no_storage', $uiLang),
|
||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
||||
'cap' => '10 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
||||
'features' => [
|
||||
dbnToolsT('pricing_free_f1', $uiLang),
|
||||
dbnToolsT('pricing_free_f2', $uiLang),
|
||||
dbnToolsT('pricing_free_f3', $uiLang),
|
||||
'price' => 'NOK 0',
|
||||
'period' => $uiLang === 'no' ? 'alltid' : 'always',
|
||||
'credits' => '30 ' . $creditsPerMonth,
|
||||
'storage' => $uiLang === 'no' ? 'Ingen saksoppbevaring' : 'No case storage',
|
||||
'seats' => $uiLang === 'no' ? '1 bruker' : '1 user',
|
||||
'cap' => '10 ' . $capSuffix,
|
||||
'features' => $uiLang === 'no' ? [
|
||||
'Alle 11 verktøy på innlimt tekst',
|
||||
'Norsk juridisk korpus (~220K passasjer)',
|
||||
'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),
|
||||
'highlight' => false,
|
||||
],
|
||||
[
|
||||
'sku' => 'light',
|
||||
'name' => $tierNames['light'],
|
||||
'price' => '€9',
|
||||
'period' => dbnToolsT('pricing_period_mo', $uiLang),
|
||||
'credits' => '120 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
||||
'storage' => '100 MB',
|
||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
||||
'cap' => '15 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
||||
'features' => [
|
||||
dbnToolsT('pricing_light_f1', $uiLang),
|
||||
dbnToolsT('pricing_light_f2', $uiLang),
|
||||
dbnToolsT('pricing_light_f3', $uiLang),
|
||||
dbnToolsT('pricing_light_f4', $uiLang),
|
||||
'sku' => 'plus',
|
||||
'name' => $tierNames['plus'],
|
||||
'price' => 'NOK 129',
|
||||
'period' => $perMonth,
|
||||
'credits' => '250 ' . $creditsPerMonth,
|
||||
'storage' => '500 MB',
|
||||
'seats' => $uiLang === 'no' ? '1 bruker' : '1 user',
|
||||
'cap' => '20 ' . $capSuffix,
|
||||
'features' => $uiLang === 'no' ? [
|
||||
'Min Sak — last opp dokumenter med OCR',
|
||||
'Bruk min sak som kontekst i alle verktøy',
|
||||
'Lagrede analyser — alle resultater samlet',
|
||||
'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',
|
||||
'name' => $tierNames['pro'],
|
||||
'price' => '€29',
|
||||
'period' => dbnToolsT('pricing_period_mo', $uiLang),
|
||||
'credits' => '500 ' . dbnToolsT('pricing_credits_mo', $uiLang),
|
||||
'storage' => '1 GB',
|
||||
'seats' => dbnToolsT('pricing_seat_1', $uiLang),
|
||||
'cap' => '30 ' . dbnToolsT('pricing_cap_suffix', $uiLang),
|
||||
'features' => [
|
||||
dbnToolsT('pricing_pro_f1', $uiLang),
|
||||
dbnToolsT('pricing_pro_f2', $uiLang),
|
||||
dbnToolsT('pricing_pro_f3', $uiLang),
|
||||
dbnToolsT('pricing_pro_f4', $uiLang),
|
||||
],
|
||||
'highlight' => true,
|
||||
'badge' => dbnToolsT('pricing_badge_popular', $uiLang),
|
||||
],
|
||||
[
|
||||
'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),
|
||||
'price' => 'NOK 299',
|
||||
'period' => $perMonth,
|
||||
'credits' => '1000 ' . $creditsPerMonth,
|
||||
'storage' => '5 GB',
|
||||
'seats' => $uiLang === 'no' ? '3 brukere · delt sak' : '3 users · shared case',
|
||||
'cap' => '40 ' . $capSuffix,
|
||||
'features' => $uiLang === 'no' ? [
|
||||
'Alt i Plus, med mer plass og raskere modeller',
|
||||
'Familie-sete: 3 innlogginger på samme sak',
|
||||
'Prioritert GPT-4o for kompleks analyse',
|
||||
'Audit-logg for hvem som kjørte hva',
|
||||
] : [
|
||||
'Everything in Plus, with more space and faster models',
|
||||
'Family seats: 3 logins sharing one case',
|
||||
'Priority GPT-4o for complex analysis',
|
||||
'Audit log of who ran what',
|
||||
],
|
||||
'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),
|
||||
];
|
||||
$topups = [
|
||||
['sku' => 'topup_s', 'price' => '€5', 'credits' => 30, 'note' => $topupNotes['topup_s']],
|
||||
['sku' => 'topup_m', 'price' => '€15', 'credits' => 100, 'note' => $topupNotes['topup_m']],
|
||||
['sku' => 'topup_l', 'price' => '€40', 'credits' => 300, 'note' => $topupNotes['topup_l']],
|
||||
['sku' => 'topup_s', 'price' => 'NOK 49', 'credits' => 30, 'note' => $topupNotes['topup_s']],
|
||||
['sku' => 'topup_m', 'price' => 'NOK 149', 'credits' => 100, 'note' => $topupNotes['topup_m']],
|
||||
['sku' => 'topup_l', 'price' => 'NOK 399', 'credits' => 300, 'note' => $topupNotes['topup_l']],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
@@ -194,6 +195,12 @@ $topups = [
|
||||
<p class="status-pill-info"><?= pt('pricing_status_canceled', $uiLang) ?></p>
|
||||
<?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): ?>
|
||||
<div class="survey-banner">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/includes/case_toggle.php'; ?>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="toolStatus" class="form-status" role="status" aria-live="polite"></p>
|
||||
<button id="runButton" type="submit" data-i18n="timelineRun">Run</button>
|
||||
|
||||
Reference in New Issue
Block a user