feat(dashboard): add corpus dashboard at /dashboard/

Full private corpus dashboard for tools.dobetternorge.no users — each SSO
account gets an auto-provisioned CaveauAI tenant (clients row, corpus) on
first visit. Includes upload (file/paste/URL), RAG chat with SSE streaming
and citation chips, document CRUD, FalkorDB graph relations tab, and
improved save-from-tool flow with tag/preview support.

- dashboard/{index,documents,document,upload,chat,settings}.php
- api/dashboard/{corpus-init,documents,upload,ingest-status,chat-stream,
  save-from-tool,graph}.php
- includes/{CorpusProvision,layout_dashboard,layout_dashboard_footer}.php
- assets/css/dashboard.css  assets/js/corpus-save.js (routing upgrade)
- includes/{bootstrap,layout}.php extended for dashboard provisioning

Migration 141 (clients.dbn_sso_uid + import_method enum) applied on chloe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 17:15:40 +02:00
parent 83fc71414f
commit 06d01a3bce
20 changed files with 2632 additions and 28 deletions
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
/**
* CorpusProvision — idempotent per-SSO-user tenant provisioning for /dashboard/.
*
* The dashboard treats every SSO user as their own CaveauAI client tenant so
* that the existing client_id-based row filtering is enough to keep one user's
* documents from leaking to another. Provisioning is lazy: the first time the
* SSO user hits the dashboard, we create:
*
* - one row in `clients` (slug `dbn-user-<sso_uid>`, plan `sandbox`,
* dbn_sso_uid = sso_uid, fresh api_key hash)
* - one row in `client_users` (role owner, random password hash — login is
* always via SSO bridge, never password)
* - one row in `client_corpora` (slug `default`, is_default=1)
*
* CaveauAI sessions (which already have client_id + client_user_id set on the
* tools session) are returned as-is without touching the DB.
*
* Output shape:
* [
* 'client_id' => int,
* 'client_user_id' => int,
* 'corpus_id' => int,
* 'created' => bool, // true the first time, false on every call after
* ]
*/
final class CorpusProvision
{
public static function ensureForSsoUser(int $ssoUid, string $email, string $displayName): array
{
if ($ssoUid <= 0) {
throw new DbnToolsHttpException('SSO user id is required.', 400, 'missing_sso_uid');
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new DbnToolsHttpException('A valid email is required.', 400, 'invalid_email');
}
dbnToolsBootCaveau();
$db = getDb();
$existing = self::lookupBySso($db, $ssoUid);
if ($existing !== null) {
$corpusId = self::ensureDefaultCorpus($db, $existing['client_id'], $email);
return [
'client_id' => $existing['client_id'],
'client_user_id' => $existing['client_user_id'],
'corpus_id' => $corpusId,
'created' => false,
];
}
$db->beginTransaction();
try {
$clientId = self::createClient($db, $ssoUid, $email, $displayName);
$clientUserId = self::createOwnerUser($db, $clientId, $email, $displayName);
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
self::subscribeIncludedPackages($db, $clientId);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw new DbnToolsHttpException(
'Could not provision dashboard tenant: ' . $e->getMessage(),
500,
'provision_failed'
);
}
return [
'client_id' => $clientId,
'client_user_id' => $clientUserId,
'corpus_id' => $corpusId,
'created' => true,
];
}
public static function ensureForCaveauSession(int $clientId, string $email): array
{
if ($clientId <= 0) {
throw new DbnToolsHttpException('Caveau client_id is required.', 400, 'missing_client_id');
}
dbnToolsBootCaveau();
$db = getDb();
$corpusId = self::ensureDefaultCorpus($db, $clientId, $email);
return [
'client_id' => $clientId,
'client_user_id' => 0,
'corpus_id' => $corpusId,
'created' => false,
];
}
private static function lookupBySso(PDO $db, int $ssoUid): ?array
{
$stmt = $db->prepare('SELECT id FROM clients WHERE dbn_sso_uid = ? LIMIT 1');
$stmt->execute([$ssoUid]);
$clientId = (int)($stmt->fetchColumn() ?: 0);
if ($clientId === 0) {
return null;
}
$stmt = $db->prepare(
"SELECT id FROM client_users
WHERE client_id = ? AND role = 'owner' AND is_active = 1
ORDER BY id ASC LIMIT 1"
);
$stmt->execute([$clientId]);
$userId = (int)($stmt->fetchColumn() ?: 0);
return ['client_id' => $clientId, 'client_user_id' => $userId];
}
private static function createClient(PDO $db, int $ssoUid, string $email, string $displayName): int
{
require_once dbnToolsAiPortalRoot() . '/platform/includes/client_auth.php';
$apiKey = generateApiKey();
$slug = self::uniqueSlug($db, 'dbn-user-' . $ssoUid);
$name = $displayName !== '' ? $displayName : ('Dashboard ' . $email);
$stmt = $db->prepare("
INSERT INTO clients (
dbn_sso_uid, slug, name, contact_email, country, timezone,
plan, embedding_tier,
api_key_hash, api_key_prefix,
monthly_query_limit, monthly_document_limit, monthly_user_limit,
is_active, plan_source, subscription_status
) VALUES (
?, ?, ?, ?, 'NO', 'Europe/Oslo',
'sandbox', 'standard',
?, ?,
500, 50, 1,
1, 'signup', 'none'
)
");
$stmt->execute([
$ssoUid,
$slug,
$name,
$email,
$apiKey['hash'],
$apiKey['prefix'],
]);
return (int)$db->lastInsertId();
}
private static function createOwnerUser(PDO $db, int $clientId, string $email, string $displayName): int
{
$stmt = $db->prepare('SELECT id, client_id FROM client_users WHERE email = ? LIMIT 1');
$stmt->execute([$email]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing && (int)$existing['client_id'] === $clientId) {
return (int)$existing['id'];
}
if ($existing) {
throw new RuntimeException("Email {$email} already belongs to another workspace.");
}
$usernameBase = preg_replace('/[^a-z0-9._-]+/', '', strtolower(strstr($email, '@', true) ?: 'owner'));
$usernameBase = $usernameBase !== '' ? $usernameBase : 'owner';
$username = self::uniqueUsername($db, $usernameBase);
$stmt = $db->prepare("
INSERT INTO client_users
(client_id, username, email, display_name, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?, 'owner', 1)
");
$stmt->execute([
$clientId,
$username,
$email,
$displayName !== '' ? $displayName : null,
password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),
]);
return (int)$db->lastInsertId();
}
private static function ensureDefaultCorpus(PDO $db, int $clientId, string $email): int
{
$stmt = $db->prepare(
'SELECT id FROM client_corpora
WHERE client_id = ? AND is_default = 1
ORDER BY id ASC LIMIT 1'
);
$stmt->execute([$clientId]);
$id = (int)($stmt->fetchColumn() ?: 0);
if ($id > 0) {
return $id;
}
$stmt = $db->prepare("
INSERT INTO client_corpora (client_id, name, slug, description, is_default)
VALUES (?, ?, 'default', ?, 1)
");
$stmt->execute([
$clientId,
'Min korpus',
'Default personal corpus for ' . $email,
]);
return (int)$db->lastInsertId();
}
private static function subscribeIncludedPackages(PDO $db, int $clientId): void
{
$packageSlug = dbnToolsRequiredPackageSlug();
$stmt = $db->prepare('SELECT id FROM corpus_packages WHERE slug = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$packageSlug]);
$packageId = (int)($stmt->fetchColumn() ?: 0);
if ($packageId === 0) {
return;
}
$db->prepare(
"INSERT IGNORE INTO client_corpus_subscriptions
(client_id, package_id, is_active, source, subscribed_at)
VALUES (?, ?, 1, 'dbn_dashboard', NOW())"
)->execute([$clientId, $packageId]);
}
private static function uniqueSlug(PDO $db, string $base): string
{
$base = preg_replace('/[^a-z0-9-]+/', '-', strtolower($base)) ?: 'dbn-user';
$base = trim($base, '-');
$stmt = $db->prepare('SELECT id FROM clients WHERE slug = ? LIMIT 1');
$slug = $base;
$suffix = 2;
while (true) {
$stmt->execute([$slug]);
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
return $slug;
}
$slug = $base . '-' . $suffix;
$suffix++;
}
}
private static function uniqueUsername(PDO $db, string $base): string
{
$stmt = $db->prepare('SELECT id FROM client_users WHERE username = ? LIMIT 1');
$username = $base;
$suffix = 2;
while (true) {
$stmt->execute([$username]);
if (!$stmt->fetch(PDO::FETCH_ASSOC)) {
return $username;
}
$username = $base . $suffix;
$suffix++;
}
}
}