Add premium My Case MVP

This commit is contained in:
2026-05-23 10:17:34 +02:00
parent e0aeefc73e
commit 83fc71414f
33 changed files with 1275 additions and 148 deletions
+288
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
+109 -45
View File
@@ -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,
'free' => 30,
'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,
'free' => 10,
'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
'free' => 0,
'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
{
@@ -93,10 +128,9 @@ final class FreeTier
$db->prepare(
"UPDATE user_tool_credits
SET balance = CASE tier
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
WHEN 'light' THEN " . self::MONTHLY_ALLOWANCE['light'] . "
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
WHEN 'pro_plus' THEN " . self::MONTHLY_ALLOWANCE['pro_plus'] . "
WHEN 'free' THEN " . self::MONTHLY_ALLOWANCE['free'] . "
WHEN 'plus' THEN " . self::MONTHLY_ALLOWANCE['plus'] . "
WHEN 'pro' THEN " . self::MONTHLY_ALLOWANCE['pro'] . "
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,26 +282,48 @@ 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);
$db->prepare(
'UPDATE user_tool_credits
SET tier = ?, balance = ?, allowance = ?,
stripe_customer_id = COALESCE(?, stripe_customer_id),
subscription_id = ?,
subscription_period_end = ?,
last_reset = CURDATE()
WHERE user_id = ?'
)->execute([$tier, $allowance, $allowance, $stripeCustomerId, $subscriptionId, $periodEndIso, $userId]);
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]);
}
}
/**
@@ -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. */
+12 -9
View File
@@ -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();
+25
View File
@@ -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
View File
@@ -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
{
+83
View File
@@ -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);
?>