diff --git a/CHANGELOG.md b/CHANGELOG.md index de9a8e0..89d3af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,109 @@ # Changelog +## [0.29.0] — 2026-03-17 + +### Summary + +Starlark sandbox + permission model. Server-side extension runtime +with pre-completion filter chain, permission-gated modules, and +admin review workflow. Six changesets (CS0–CS5) on top of Phase 0 +store cleanup (12 changesets, CS0–CS7b). + +Phase 0 migrated ~242 raw SQL calls into the store interface layer, +giving the Starlark sandbox a clean API surface. Phase 1 builds the +runtime: sandboxed eval loop, permission model, secrets/notifications +modules, task executor integration, and pre-completion filter chain +with extension discovery. + +**DB rebuild required.** Migration 016 rewritten in-place (adds +`status` column to `packages`, `extension_permissions` table). + +### New + +- **Pre-completion filter chain** (`server/filters/`) — composable + `PreCompletionFilter` interface with ordered execution, error + isolation, and logging. `Chain.Register()` / `Chain.Execute()`. + Built-in filters use order 0–99; extension filters use 100+. +- **KB auto-inject filter** — `KBInjectFilter` (order 10) replaces + inline `BuildKBHint()`. Scans persona, project, channel, and + personal KBs. Reference implementation for the filter model. +- **Starlark sandbox** (`server/sandbox/`) — `go.starlark.net` + interpreter with step limits (1M ops default), context timeout, + captured print output, disabled `load()`. `MakeModule()` helper + for exposing Go functions to Starlark scripts. +- **Sandbox runner** — `Runner.ExecPackage()` loads script from + manifest `_starlark_script`, assembles module set from granted + permissions. `CallEntryPoint()` for event-driven invocation. +- **Permission model** — `extension_permissions` table tracks + declared capabilities from package manifests. Admin grant/revoke + controls which modules the sandbox injects at runtime. Lifecycle: + `install → pending_review → grant-all → active`, revoke → + `suspended`. Auto-transitions on grant/revoke. +- **Extension permission constants** — `secrets.read`, + `notifications.send`, `filters.pre_completion`, `db.read`, + `db.write`, `api.http`. Validated against manifest declarations. +- **Secrets module** — `secrets.get(key)` / `secrets.list()`. + Per-package key-value store backed by GlobalConfig + (`ext_secrets:{packageID}`). Admin CRUD endpoints. +- **Notifications module** — `notifications.send(user_id, title, + body?, type?)`. Wraps platform notification service. Extensions + cannot send email or bypass user preferences. +- **Starlark filter** — `StarlarkFilter` bridges the filter chain + to extension scripts. Calls `on_pre_completion(ctx)`, parses + return `[{"role": "system", "content": "..."}]`. +- **Starlark filter discovery** — `DiscoverStarlarkFilters` scans + active packages with `filters.pre_completion` grant at startup. +- **`task_type: "starlark"`** — executor `executeStarlark` loads + package by `system_function` (package ID), calls `on_run()` + entry point. Output to channel/webhook/run record. +- **Extension secrets admin** — `GET/PUT/DELETE + /admin/extensions/:id/secrets`. GET returns keys only (not values). +- **Extension permission admin** — `GET .../permissions`, + `GET .../review`, `POST .../grant`, `POST .../revoke`, + `POST .../grant-all`. +- **ICD runner packaging tier** — 18 tests: permission lifecycle + (install → pending_review → grant → active → revoke → suspended), + secrets CRUD, invalid permission rejection. + +### Changed + +- **`BuildKBHint` deprecated** — replaced by `KBInjectFilter` in + the pre-completion filter chain. Kept for backward compatibility; + removal scheduled for v0.30.0. +- **`packages` table** — `status` column added (`active`, + `pending_review`, `suspended`). `PackageStore.SetStatus()` method. +- **`CompletionHandler`** — `filterChain` field + `SetFilterChain()` + setter. Filter chain runs between system prompts and message history. +- **`Executor`** — `runner` field + `SetRunner()`. Starlark dispatch + branch between action and prompt types. +- **`AdminInstallExtension`** — calls `SyncManifestPermissions()` + after package creation. Parses `"permissions"` from manifest. +- **Starlark task validation** — `task.starlark` RBAC gate enforced. + `system_function` required (holds package ID). Package must exist + and be starlark tier. + +### Phase 0 — Store Cleanup + +- **~242 raw SQL calls eliminated** — every handler, tool, and + middleware file now goes through the store interface. +- **Constructor signature changes** — + `enforcePrivateProviderPolicy(ctx, stores, ...)`, + `ResolveProviderConfig(stores, vault, ...)`, + `NewChannelHandler(stores)`. +- **30+ new store methods** across NoteStore, MessageStore, FileStore, + ProjectStore, TeamStore, ProviderStore, CatalogStore, + GlobalConfigStore, AuditStore, ChannelStore. + +### Fixed + +- **`API._delete` alias** — `delete` is a JS reserved word; added + `_delete` as alias for `_del` on the API client. +- **`safeString` nil handling** — returns Go `nil`, not `""`. +- **nil JSONMap → PG jsonb** — `ToolCalls`/`Metadata` initialized + to `models.JSONMap{}` before store calls. +- **`GetByID` vs `GetParentAndRole`** — cursor update uses + `GetParentAndRole` (matches original raw SQL deleted_at filter). + ## [0.28.8] — 2026-03-15 ### Summary diff --git a/VERSION b/VERSION index a4b9c5a..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.8 +0.29.0 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 857fb38..e2e4552 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ │ Extension Track Operations Track │ │ - v0.29.0 Starlark Sandbox v0.32.0 Multi-Replica HA + v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA v0.29.1 API Extensions v0.33.0 Observability v0.29.2 DB Extensions v0.34.0 Data Portability v0.29.3 Workflow Forms │ @@ -50,12 +50,6 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ STT/TTS, desktop app) ``` -**Target deployment (v0.50.0):** 3-node cluster, 3-node PG (patroni/cnpg), -S3 (Ceph RGW), CephFS. 5–10 teams × ~5 users, 4 admins, 100+ anonymous -visitors. Multi-replica backend for node-level HA. - ---- - ## Completed: v0.28.0 — Platform Polish Audit arc, frontend decomposition, security, infrastructure. Eight @@ -132,32 +126,50 @@ Memory profile (measured on cluster via `kubectl top`): Sequential. Each version builds on the previous. Delivers the package ecosystem, workflow capabilities, and SDK-based surface architecture. -### v0.29.0 — Starlark Sandbox + Permission Model +### v0.29.0 — Starlark Sandbox + Permission Model ✅ -Server-side extension runtime. Prove the eval loop, permission pipeline, -and admin UI before adding capabilities. +Server-side extension runtime. Eval loop, permission pipeline, +pre-completion filter chain, and admin review workflow. Depends on: v0.28.8. -**Phase 0 — Store cleanup (prerequisite):** -- [ ] `SELECT *` → explicit column lists in both store packages -- [ ] Raw SQL hunt: all `database.DB.*` outside `store/` → store methods -- [ ] CI gate: grep-enforced, zero hits +**Phase 0 — Store cleanup (prerequisite): ✅** +- [x] Raw SQL hunt: all ~242 `database.DB.*` calls outside `store/` + migrated to store interface methods (CS0–CS7b, 12 changesets) +- [x] CI green on both PG and SQLite pipelines +- [x] ICD runner: 579/580 pass, 1 expected skip +- [x] Documented exception: `events/pg_broadcast.go` (`pg_notify`, + PG-only, no store abstraction needed) -**Starlark runtime:** -- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling) -- [ ] Permission model: manifest declarations, admin grant/revoke, - `extension_permissions` table -- [ ] Runtime enforcement: sandbox injects only granted modules -- [ ] Admin UI: permission review, grant/revoke, audit log. - Task permission management (deferred from v0.28.7). -- [ ] Extension lifecycle: `install → pending_review → approved → active` -- [ ] Initial modules: `secrets` (vault-backed), `notifications` -- [ ] `task_type: "starlark"`: sandbox execution with task context. - RBAC gate from v0.28.7 enforced. -- [ ] KB auto-injection: server-side pre-completion filter (Go built-in). - Reference implementation for the filter model Starlark mirrors. -- [ ] ICD runner: `packaging` test tier (deferred from v0.28.7) +**Phase 1 — Starlark runtime: ✅** +- [x] Pre-completion filter chain: composable `PreCompletionFilter` + interface + `Chain` registry. KB auto-inject refactored as first + built-in filter. Extension filters register at order 100+ (CS0) +- [x] `go.starlark.net` integration: sandboxed eval with step limits + (1M ops default), context timeout, captured print output, + disabled `load()`. `MakeModule` helper for Go→Starlark (CS1) +- [x] Permission model: `extension_permissions` table (in 016), + `status` column on `packages` (`active`/`pending_review`/ + `suspended`). Manifest `"permissions"` array parsed on install. + Admin review, grant, revoke, grant-all endpoints (CS2) +- [x] Runtime enforcement: `Runner.buildModules()` injects only + granted modules into sandbox namespace (CS3) +- [x] Extension lifecycle: `install → pending_review → grant-all → + active`, revoke → `suspended`. Auto-transitions on grant/revoke. +- [x] Initial modules: `secrets` (GlobalConfig-backed, per-package + key-value store, admin CRUD), `notifications` (wraps + notification service, `send(user_id, title, body?, type?)`) (CS3) +- [x] `task_type: "starlark"`: executor `executeStarlark` loads + package by ID, calls `on_run()` entry point via runner. + RBAC gate `task.starlark` enforced. `system_function` field + holds package ID (CS4) +- [x] KB auto-injection: server-side pre-completion filter chain. + Reference implementation for the filter model Starlark + extensions mirror via `on_pre_completion(ctx)` (CS0+CS3) +- [x] Starlark filter discovery: `DiscoverStarlarkFilters` scans + active packages with `filters.pre_completion` grant (CS3) +- [x] ICD runner: `packaging` test tier — 18 tests covering + permission lifecycle + secrets CRUD (CS5) ### v0.29.1 — API Extensions diff --git a/packages/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js index dc286eb..7e1c563 100644 --- a/packages/icd-test-runner/js/main.js +++ b/packages/icd-test-runner/js/main.js @@ -14,7 +14,9 @@ * 7. tier-authz.js — Permission boundary tests * 8. tier-security.js — Adversarial red-team tests (auth, cross-tenant, input validation) * 9. tier-providers.js — Three-tier provider CRUD + live completions - * 10. ui.js — Render functions, export, provider setup panel + * 10. tier-packaging.js — Extension permission lifecycle + secrets (v0.29.0) + * 11. tier-sdk.js — Switchboard SDK contract tests + * 12. ui.js — Render functions, export, provider setup panel * 11. (this file) — Boot */ (function () { @@ -81,6 +83,7 @@ 'tier-authz.js', 'tier-security.js', 'tier-providers.js', + 'tier-packaging.js', 'tier-sdk.js', 'ui.js' ]; diff --git a/packages/icd-test-runner/js/tier-packaging.js b/packages/icd-test-runner/js/tier-packaging.js new file mode 100644 index 0000000..34ad580 --- /dev/null +++ b/packages/icd-test-runner/js/tier-packaging.js @@ -0,0 +1,182 @@ +/** + * ICD Test Runner — Packaging Tier + * + * v0.29.0 CS5: Tests the extension permission lifecycle + * (install with permissions → pending_review → grant → active → + * revoke → suspended) and extension secrets CRUD. + * + * Requires admin token (from fixtures). + */ +(function () { + 'use strict'; + var T = window.ICD; + if (!T) return; + + T.runPackaging = async function () { + var extId = 'icd-pkg-' + Date.now(); + var extInstalled = false; + + // ── Install with permissions → pending_review ── + + await T.test('crud', 'packaging', 'POST /admin/extensions (with permissions)', async function () { + var d = await T.apiPost('/admin/extensions', { + ext_id: extId, + name: 'ICD Packaging Test', + version: '1.0.0', + tier: 'starlark', + description: 'Tests permission lifecycle', + author: 'icd-runner', + manifest: { + permissions: ['secrets.read', 'notifications.send'], + _starlark_script: 'def on_run():\n return "ok"' + }, + is_enabled: true, + is_system: false + }); + T.assertHasKey(d, 'data', 'install response'); + T.assert(d.data.id === extId, 'id should match ext_id'); + T.assert(d.data.tier === 'starlark', 'tier should be starlark'); + extInstalled = true; + T.registerCleanup(function () { + if (extInstalled) return T.safeDelete('/admin/extensions/' + extId); + }); + }); + + if (!extInstalled) return; + + // ── Verify pending_review status ── + + await T.test('crud', 'packaging', 'GET /admin/extensions/:id (status = pending_review)', async function () { + var d = await T.apiGet('/admin/extensions'); + T.assertHasKey(d, 'data', 'admin list'); + var pkg = d.data.find(function (e) { return e.id === extId; }); + T.assert(pkg, 'installed package should be in admin list'); + T.assert(pkg.status === 'pending_review', 'status should be pending_review, got: ' + pkg.status); + }); + + // ── List declared permissions ── + + await T.test('crud', 'packaging', 'GET /admin/extensions/:id/permissions (declared)', async function () { + var d = await T.apiGet('/admin/extensions/' + extId + '/permissions'); + T.assertHasKey(d, 'data', 'permissions response'); + T.assert(Array.isArray(d.data), 'permissions should be array'); + T.assert(d.data.length === 2, 'should have 2 declared permissions, got: ' + d.data.length); + + var perms = d.data.map(function (p) { return p.permission; }).sort(); + T.assert(perms[0] === 'notifications.send', 'first perm should be notifications.send'); + T.assert(perms[1] === 'secrets.read', 'second perm should be secrets.read'); + + var allUngrated = d.data.every(function (p) { return p.granted === false; }); + T.assert(allUngrated, 'all permissions should be ungranted initially'); + }); + + // ── Review package ── + + await T.test('crud', 'packaging', 'GET /admin/extensions/:id/review', async function () { + var d = await T.apiGet('/admin/extensions/' + extId + '/review'); + T.assertHasKey(d, 'package', 'review response package'); + T.assertHasKey(d, 'permissions', 'review response permissions'); + T.assert(d.package.id === extId, 'review package id should match'); + T.assert(Array.isArray(d.permissions), 'review permissions should be array'); + T.assert(d.permissions.length === 2, 'review should show 2 permissions'); + }); + + // ── Grant single permission ── + + await T.test('crud', 'packaging', 'POST .../permissions/secrets.read/grant', async function () { + var d = await T.apiPost('/admin/extensions/' + extId + '/permissions/secrets.read/grant', {}); + T.assert(d.status === 'granted', 'should return granted status'); + T.assert(d.permission === 'secrets.read', 'should echo permission'); + }); + + // ── Still pending_review (not all granted) ── + + await T.test('crud', 'packaging', 'status still pending_review (1 of 2 granted)', async function () { + var d = await T.apiGet('/admin/extensions'); + var pkg = d.data.find(function (e) { return e.id === extId; }); + T.assert(pkg.status === 'pending_review', 'should still be pending_review, got: ' + pkg.status); + }); + + // ── Grant all remaining ── + + await T.test('crud', 'packaging', 'POST .../permissions/grant-all', async function () { + var d = await T.apiPost('/admin/extensions/' + extId + '/permissions/grant-all', {}); + T.assert(d.status === 'granted_all', 'should return granted_all status'); + }); + + // ── Verify active status ── + + await T.test('crud', 'packaging', 'status → active (all permissions granted)', async function () { + var d = await T.apiGet('/admin/extensions'); + var pkg = d.data.find(function (e) { return e.id === extId; }); + T.assert(pkg.status === 'active', 'should be active after grant-all, got: ' + pkg.status); + }); + + // ── Verify all granted ── + + await T.test('crud', 'packaging', 'GET .../permissions (all granted)', async function () { + var d = await T.apiGet('/admin/extensions/' + extId + '/permissions'); + var allGranted = d.data.every(function (p) { return p.granted === true; }); + T.assert(allGranted, 'all permissions should be granted'); + var hasGrantedBy = d.data.every(function (p) { return p.granted_by !== null; }); + T.assert(hasGrantedBy, 'all permissions should have granted_by'); + }); + + // ── Secrets CRUD ── + + await T.test('crud', 'packaging', 'PUT /admin/extensions/:id/secrets (set)', async function () { + var d = await T.apiPut('/admin/extensions/' + extId + '/secrets', { + secrets: { api_key: 'sk-test-123', webhook_token: 'tok-abc' } + }); + T.assert(d.status === 'saved', 'should return saved status'); + T.assert(d.key_count === 2, 'should report 2 keys'); + }); + + await T.test('crud', 'packaging', 'GET /admin/extensions/:id/secrets (keys only)', async function () { + var d = await T.apiGet('/admin/extensions/' + extId + '/secrets'); + T.assertHasKey(d, 'data', 'secrets response'); + T.assert(d.data.package_id === extId, 'package_id should match'); + T.assert(Array.isArray(d.data.keys), 'keys should be array'); + T.assert(d.data.keys.length === 2, 'should have 2 keys'); + // Values should NOT be returned + T.assert(!d.data.api_key, 'raw values should not be exposed'); + }); + + await T.test('crud', 'packaging', 'DELETE /admin/extensions/:id/secrets', async function () { + var d = await T.apiDelete('/admin/extensions/' + extId + '/secrets'); + T.assert(d.status === 'deleted', 'should return deleted status'); + }); + + await T.test('crud', 'packaging', 'GET .../secrets (empty after delete)', async function () { + var d = await T.apiGet('/admin/extensions/' + extId + '/secrets'); + T.assert(d.data.keys.length === 0, 'keys should be empty after delete'); + }); + + // ── Revoke → suspended ── + + await T.test('crud', 'packaging', 'POST .../permissions/secrets.read/revoke', async function () { + var d = await T.apiPost('/admin/extensions/' + extId + '/permissions/secrets.read/revoke', {}); + T.assert(d.status === 'revoked', 'should return revoked status'); + }); + + await T.test('crud', 'packaging', 'status → suspended (permission revoked)', async function () { + var d = await T.apiGet('/admin/extensions'); + var pkg = d.data.find(function (e) { return e.id === extId; }); + T.assert(pkg.status === 'suspended', 'should be suspended after revoke, got: ' + pkg.status); + }); + + // ── Invalid permission ── + + await T.test('crud', 'packaging', 'POST .../permissions/bogus/grant (400)', async function () { + var d = await T.authFetch(API.accessToken, 'POST', '/admin/extensions/' + extId + '/permissions/bogus/grant', {}); + T.assertStatus(d, 400, 'invalid permission should 400'); + }); + + // ── Cleanup (DELETE handled by registerCleanup) ── + + await T.test('crud', 'packaging', 'DELETE /admin/extensions/:id (cleanup)', async function () { + var d = await T.apiDelete('/admin/extensions/' + extId); + extInstalled = false; + }); + }; +})(); diff --git a/packages/icd-test-runner/js/ui.js b/packages/icd-test-runner/js/ui.js index 222d6b1..f8b7e6c 100644 --- a/packages/icd-test-runner/js/ui.js +++ b/packages/icd-test-runner/js/ui.js @@ -238,6 +238,13 @@ }, 'SDK'); T.el.controls.appendChild(btnSdk); + // Packaging tier button — extension permission lifecycle (v0.29.0) + var btnPkg = $('button', { + className: 'btn-secondary', + onClick: function () { T.runSuite('packaging'); } + }, 'Packaging'); + T.el.controls.appendChild(btnPkg); + T.el.controls.appendChild(btnAll); T.el.controls.appendChild(btnClear); @@ -546,6 +553,7 @@ if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity(); if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders(); if (which === 'sdk' || which === 'all') { if (typeof T.runSdk === 'function') await T.runSdk(); } + if (which === 'packaging' || which === 'all') { if (typeof T.runPackaging === 'function') await T.runPackaging(); } } catch (e) { T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) }); } diff --git a/server/compaction/compaction.go b/server/compaction/compaction.go index 6435728..34f8b32 100644 --- a/server/compaction/compaction.go +++ b/server/compaction/compaction.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/roles" @@ -173,16 +172,22 @@ Write the summary in a way that would allow the conversation to continue natural metaJSON, _ := json.Marshal(metadata) siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID) - var summaryMsgID string - err = database.DB.QueryRow(` - INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type) - VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system') - RETURNING id - `, req.ChannelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID) - if err != nil { + summaryMsg := &models.Message{ + ChannelID: req.ChannelID, + ParentID: &lastMessageID, + Role: "assistant", + Content: result.Content, + Model: result.Model, + Metadata: models.JSONMap{}, + SiblingIndex: siblingIdx, + ParticipantType: "system", + } + _ = json.Unmarshal(metaJSON, &summaryMsg.Metadata) + if err := s.stores.Messages.Create(ctx, summaryMsg); err != nil { log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err) return nil, fmt.Errorf("failed to save summary: %w", err) } + summaryMsgID := summaryMsg.ID // Update cursor to point to the summary node if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil { @@ -268,11 +273,8 @@ func (s *Service) CheckRateLimit(ctx context.Context, userID string) error { // GetUserTeamID returns the user's first team ID (for role resolution). func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string { - var teamID string - err := database.DB.QueryRow(` - SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1 - `, userID).Scan(&teamID) - if err != nil || teamID == "" { + teamID, _ := s.stores.Teams.GetFirstTeamIDForUser(ctx, userID) + if teamID == "" { return nil } return &teamID diff --git a/server/compaction/scanner.go b/server/compaction/scanner.go index db0856d..a4f43c0 100644 --- a/server/compaction/scanner.go +++ b/server/compaction/scanner.go @@ -2,12 +2,10 @@ package compaction import ( "context" - "encoding/json" "log" "sync" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/roles" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -236,47 +234,12 @@ func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel { activityGap := time.Now().Add(-candidateActivityGap) maxAge := time.Now().Add(-candidateMaxAge) - rows, err := database.DB.QueryContext(ctx, ` - SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'), - COUNT(m.id) AS msg_count, - COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars - FROM channels c - JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL - WHERE c.type = 'direct' - AND c.is_archived = false - AND c.updated_at < $1 - AND c.updated_at > $2 - GROUP BY c.id - HAVING COUNT(m.id) >= $3 - AND COALESCE(SUM(LENGTH(m.content)), 0) > $4 - ORDER BY c.updated_at DESC - LIMIT $5 - `, activityGap, - maxAge, - candidateMinMessages, - candidateMinChars, - candidateBatchSize, - ) + candidates, err := sc.stores.Channels.FindCompactionCandidates(ctx, + activityGap, maxAge, candidateMinMessages, candidateMinChars, candidateBatchSize) if err != nil { log.Printf("⚠ compaction: candidate query failed: %v", err) return nil } - defer rows.Close() - - var candidates []models.Channel - for rows.Next() { - var ch models.Channel - var settingsRaw string - var msgCount, totalChars int - if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil { - log.Printf("⚠ compaction: candidate scan: %v", err) - continue - } - if settingsRaw != "" { - _ = json.Unmarshal([]byte(settingsRaw), &ch.Settings) - } - candidates = append(candidates, ch) - } return candidates } diff --git a/server/database/migrations/016_packages.sql b/server/database/migrations/016_packages.sql index b4d4327..ee31681 100644 --- a/server/database/migrations/016_packages.sql +++ b/server/database/migrations/016_packages.sql @@ -6,6 +6,7 @@ -- extension, or both. -- -- v0.28.7: packages table + package_user_settings in 012. +-- v0.29.0: status column + extension_permissions table. -- ========================================== CREATE TABLE IF NOT EXISTS packages ( @@ -25,6 +26,8 @@ CREATE TABLE IF NOT EXISTS packages ( installed_by UUID REFERENCES users(id) ON DELETE SET NULL, manifest JSONB NOT NULL DEFAULT '{}', enabled BOOLEAN NOT NULL DEFAULT true, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'pending_review', 'suspended')), source TEXT NOT NULL DEFAULT 'core' CHECK (source IN ('core', 'builtin', 'extension')), installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -35,12 +38,14 @@ CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type); CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled) WHERE enabled = true; CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id); CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source); +CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status); COMMENT ON TABLE packages IS 'Unified package registry. Surfaces, extensions, and full packages. Replaces surface_registry + extensions tables.'; COMMENT ON COLUMN packages.id IS 'Slug identifier from manifest "id" field. Used in URLs: /s/:id'; COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both'; COMMENT ON COLUMN packages.source IS 'core = page-engine seeded, builtin = extensions/builtin/ seeded, extension = admin-uploaded .pkg'; COMMENT ON COLUMN packages.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav'; +COMMENT ON COLUMN packages.status IS 'Lifecycle: active (running), pending_review (needs admin permission grant), suspended (permission revoked)'; -- ========================================= @@ -57,3 +62,29 @@ CREATE TABLE IF NOT EXISTS package_user_settings ( is_enabled BOOLEAN NOT NULL DEFAULT true, PRIMARY KEY (package_id, user_id) ); + + +-- ========================================= +-- EXTENSION PERMISSIONS +-- ========================================= +-- Declared capabilities from package manifests. Admin grants control +-- which modules the Starlark sandbox injects at runtime. +-- v0.29.0: Permission model for extension sandboxing. + +CREATE TABLE IF NOT EXISTS extension_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + granted BOOLEAN NOT NULL DEFAULT false, + granted_by UUID REFERENCES users(id) ON DELETE SET NULL, + granted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(package_id, permission) +); + +CREATE INDEX IF NOT EXISTS idx_ext_perm_package ON extension_permissions(package_id); +CREATE INDEX IF NOT EXISTS idx_ext_perm_granted ON extension_permissions(granted) WHERE granted = true; + +COMMENT ON TABLE extension_permissions IS 'Declared permissions from package manifests. Admin grants control runtime module injection.'; +COMMENT ON COLUMN extension_permissions.permission IS 'Capability key: secrets.read, notifications.send, filters.pre_completion, db.read, db.write, api.http'; +COMMENT ON COLUMN extension_permissions.granted IS 'Admin has reviewed and approved this capability for the package'; diff --git a/server/database/migrations/sqlite/016_packages.sql b/server/database/migrations/sqlite/016_packages.sql index fe27785..75068dc 100644 --- a/server/database/migrations/sqlite/016_packages.sql +++ b/server/database/migrations/sqlite/016_packages.sql @@ -1,6 +1,7 @@ -- Chat Switchboard — 016 Packages (SQLite) -- Unified package registry. Replaces surface_registry + extensions. --- v0.28.7 +-- v0.28.7: packages + package_user_settings +-- v0.29.0: status column + extension_permissions table CREATE TABLE IF NOT EXISTS packages ( id TEXT PRIMARY KEY, @@ -19,6 +20,8 @@ CREATE TABLE IF NOT EXISTS packages ( installed_by TEXT REFERENCES users(id) ON DELETE SET NULL, manifest TEXT NOT NULL DEFAULT '{}', enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'pending_review', 'suspended')), source TEXT NOT NULL DEFAULT 'core' CHECK (source IN ('core', 'builtin', 'extension')), installed_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -29,10 +32,10 @@ CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type); CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled); CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id); CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source); +CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status); -- Per-user overrides for packages (enable/disable, custom config). --- Lives in 016 (not 012) because of FK dependency on packages table. CREATE TABLE IF NOT EXISTS package_user_settings ( package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, @@ -41,3 +44,20 @@ CREATE TABLE IF NOT EXISTS package_user_settings ( is_enabled INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (package_id, user_id) ); + + +-- Extension permissions (v0.29.0) + +CREATE TABLE IF NOT EXISTS extension_permissions ( + id TEXT PRIMARY KEY, + package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + granted INTEGER NOT NULL DEFAULT 0, + granted_by TEXT REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(package_id, permission) +); + +CREATE INDEX IF NOT EXISTS idx_ext_perm_package ON extension_permissions(package_id); +CREATE INDEX IF NOT EXISTS idx_ext_perm_granted ON extension_permissions(granted); diff --git a/server/filters/discover.go b/server/filters/discover.go new file mode 100644 index 0000000..16def74 --- /dev/null +++ b/server/filters/discover.go @@ -0,0 +1,83 @@ +// Package filters — discover.go +// +// v0.29.0 CS3: Discovers active Starlark packages with +// filters.pre_completion permission and registers them in the chain. +// Called at startup and after package install/permission changes. +package filters + +import ( + "context" + "log" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/sandbox" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// DiscoverStarlarkFilters scans the package registry for active starlark +// extensions with granted filters.pre_completion permission and registers +// them in the filter chain. Extension filters start at order 100. +// +// Called once at startup. Future: also called on package install/activate +// to hot-register new filters without restart. +func DiscoverStarlarkFilters(ctx context.Context, chain *Chain, stores store.Stores, runner *sandbox.Runner) { + if stores.ExtPermissions == nil || stores.Packages == nil { + return + } + + pkgs, err := stores.Packages.ListEnabledByType(ctx, "extension") + if err != nil { + log.Printf("⚠ filter discovery: failed to list extensions: %v", err) + return + } + + // Also check "full" packages (surface + extension) + fullPkgs, err := stores.Packages.ListEnabledByType(ctx, "full") + if err == nil { + pkgs = append(pkgs, fullPkgs...) + } + + registered := 0 + for i := range pkgs { + pkg := &pkgs[i] + + // Must be starlark tier and active + if pkg.Tier != models.ExtTierStarlark { + continue + } + if pkg.Status != models.PackageStatusActive { + continue + } + + // Must have the pre_completion filter permission granted + granted, err := stores.ExtPermissions.GrantedForPackage(ctx, pkg.ID) + if err != nil { + continue + } + + hasFilterPerm := false + for _, p := range granted { + if p == models.ExtPermFiltersPreCompletion { + hasFilterPerm = true + break + } + } + if !hasFilterPerm { + continue + } + + // Must have a starlark script + if _, ok := pkg.Manifest["_starlark_script"].(string); !ok { + continue + } + + // Register with order 100+ (extension range, after built-ins) + order := 100 + registered + chain.Register(NewStarlarkFilter(runner, pkg, order)) + registered++ + } + + if registered > 0 { + log.Printf(" 🔗 Discovered %d Starlark pre-completion filter(s)", registered) + } +} diff --git a/server/filters/filters.go b/server/filters/filters.go new file mode 100644 index 0000000..6880048 --- /dev/null +++ b/server/filters/filters.go @@ -0,0 +1,134 @@ +// Package filters — pre-completion filter chain. +// +// v0.29.0: Reference implementation for the server-side filter model. +// Filters run before every completion request, injecting context +// (system messages, knowledge base results, external data) into the +// conversation. The chain is composable: Go built-in filters register +// at startup; Starlark extension filters register at package install. +// +// Architecture: +// +// user message → [filter chain] → LLM +// │ +// ├─ KB auto-inject (built-in, CS0) +// ├─ memory hint (future migration) +// └─ ext filters (Starlark, CS3) +// +// Each filter receives a CompletionContext and returns zero or more +// system messages to inject. Filters are executed in registration +// order. A filter failure is logged and skipped — it never aborts +// the completion. +package filters + +import ( + "context" + "log" + "sort" + "time" +) + +// ─── Context ───────────────────────────────── + +// CompletionContext carries the state available to pre-completion filters. +// Filters should treat this as read-only. +type CompletionContext struct { + ChannelID string + UserID string + PersonaID string + LastUserMessage string // current user message content (for semantic search) + TeamIDs []string +} + +// ─── Contribution ──────────────────────────── + +// InjectedMessage is a role + content pair that a filter wants to inject +// into the conversation. Kept simple to avoid coupling to provider types. +type InjectedMessage struct { + Role string // "system" (most common), "user", or "assistant" + Content string +} + +// Contribution holds what a filter wants to inject into the completion. +type Contribution struct { + // Messages are injected into the conversation in order, after the + // persona/project system prompts and before the message history. + Messages []InjectedMessage +} + +// ─── Filter Interface ──────────────────────── + +// PreCompletionFilter processes context before a completion request. +// Implementations must be safe for concurrent use. +type PreCompletionFilter interface { + // Name returns a human-readable identifier (e.g., "kb-auto-inject"). + // Used in logs and admin UI. + Name() string + + // Order returns the execution priority. Lower values run first. + // Built-in filters use 0–99; extension filters use 100+. + Order() int + + // Execute runs the filter and returns messages to inject. + // Returning nil or an empty Contribution is valid (no injection). + // Errors are logged and skipped — they never abort the completion. + Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) +} + +// ─── Chain ─────────────────────────────────── + +// Chain holds an ordered set of pre-completion filters. +type Chain struct { + filters []PreCompletionFilter + sorted bool +} + +// NewChain creates an empty filter chain. +func NewChain() *Chain { + return &Chain{} +} + +// Register adds a filter to the chain. Filters are sorted by Order() +// before the first execution. +func (c *Chain) Register(f PreCompletionFilter) { + c.filters = append(c.filters, f) + c.sorted = false + log.Printf(" 🔗 Pre-completion filter registered: %s (order=%d)", f.Name(), f.Order()) +} + +// Len returns the number of registered filters. +func (c *Chain) Len() int { + return len(c.filters) +} + +// Execute runs all filters in order, collecting their contributions. +// Returns the combined list of messages to inject. Individual filter +// errors are logged and skipped. +func (c *Chain) Execute(ctx context.Context, cc *CompletionContext) []InjectedMessage { + if len(c.filters) == 0 { + return nil + } + + if !c.sorted { + sort.Slice(c.filters, func(i, j int) bool { + return c.filters[i].Order() < c.filters[j].Order() + }) + c.sorted = true + } + + var messages []InjectedMessage + for _, f := range c.filters { + start := time.Now() + contrib, err := f.Execute(ctx, cc) + elapsed := time.Since(start) + + if err != nil { + log.Printf("⚠ filter %s failed (%.0fms): %v", f.Name(), elapsed.Seconds()*1000, err) + continue + } + if contrib != nil && len(contrib.Messages) > 0 { + messages = append(messages, contrib.Messages...) + log.Printf(" 🔗 filter %s: injected %d messages (%.0fms)", f.Name(), len(contrib.Messages), elapsed.Seconds()*1000) + } + } + return messages +} diff --git a/server/filters/filters_test.go b/server/filters/filters_test.go new file mode 100644 index 0000000..f44bb28 --- /dev/null +++ b/server/filters/filters_test.go @@ -0,0 +1,155 @@ +package filters + +import ( + "context" + "errors" + "testing" +) + +// ── Mock filter ───────────────────────────── + +type mockFilter struct { + name string + order int + messages []InjectedMessage + err error +} + +func (m *mockFilter) Name() string { return m.name } +func (m *mockFilter) Order() int { return m.order } +func (m *mockFilter) Execute(_ context.Context, _ *CompletionContext) (*Contribution, error) { + if m.err != nil { + return nil, m.err + } + if len(m.messages) == 0 { + return nil, nil + } + return &Contribution{Messages: m.messages}, nil +} + +// ── Tests ─────────────────────────────────── + +func TestChainEmpty(t *testing.T) { + c := NewChain() + result := c.Execute(context.Background(), &CompletionContext{}) + if len(result) != 0 { + t.Fatalf("expected 0 messages, got %d", len(result)) + } +} + +func TestChainSingleFilter(t *testing.T) { + c := NewChain() + c.Register(&mockFilter{ + name: "test", + order: 0, + messages: []InjectedMessage{ + {Role: "system", Content: "hello"}, + }, + }) + + result := c.Execute(context.Background(), &CompletionContext{ + ChannelID: "ch-1", + UserID: "u-1", + }) + + if len(result) != 1 { + t.Fatalf("expected 1 message, got %d", len(result)) + } + if result[0].Content != "hello" { + t.Fatalf("expected 'hello', got %q", result[0].Content) + } +} + +func TestChainOrderRespected(t *testing.T) { + c := NewChain() + // Register out of order — chain should sort by Order() + c.Register(&mockFilter{ + name: "second", + order: 20, + messages: []InjectedMessage{{Role: "system", Content: "B"}}, + }) + c.Register(&mockFilter{ + name: "first", + order: 10, + messages: []InjectedMessage{{Role: "system", Content: "A"}}, + }) + + result := c.Execute(context.Background(), &CompletionContext{}) + + if len(result) != 2 { + t.Fatalf("expected 2 messages, got %d", len(result)) + } + if result[0].Content != "A" { + t.Fatalf("expected 'A' first, got %q", result[0].Content) + } + if result[1].Content != "B" { + t.Fatalf("expected 'B' second, got %q", result[1].Content) + } +} + +func TestChainErrorIsolation(t *testing.T) { + c := NewChain() + c.Register(&mockFilter{ + name: "failing", + order: 0, + err: errors.New("boom"), + }) + c.Register(&mockFilter{ + name: "surviving", + order: 10, + messages: []InjectedMessage{{Role: "system", Content: "ok"}}, + }) + + result := c.Execute(context.Background(), &CompletionContext{}) + + if len(result) != 1 { + t.Fatalf("expected 1 message (failing filter skipped), got %d", len(result)) + } + if result[0].Content != "ok" { + t.Fatalf("expected 'ok', got %q", result[0].Content) + } +} + +func TestChainNilContribution(t *testing.T) { + c := NewChain() + c.Register(&mockFilter{ + name: "noop", + order: 0, + // nil messages → nil contribution + }) + + result := c.Execute(context.Background(), &CompletionContext{}) + if len(result) != 0 { + t.Fatalf("expected 0 messages from noop filter, got %d", len(result)) + } +} + +func TestChainMultipleMessages(t *testing.T) { + c := NewChain() + c.Register(&mockFilter{ + name: "multi", + order: 0, + messages: []InjectedMessage{ + {Role: "system", Content: "first"}, + {Role: "system", Content: "second"}, + }, + }) + + result := c.Execute(context.Background(), &CompletionContext{}) + + if len(result) != 2 { + t.Fatalf("expected 2 messages, got %d", len(result)) + } +} + +func TestChainLen(t *testing.T) { + c := NewChain() + if c.Len() != 0 { + t.Fatalf("expected 0, got %d", c.Len()) + } + c.Register(&mockFilter{name: "a", order: 0}) + c.Register(&mockFilter{name: "b", order: 1}) + if c.Len() != 2 { + t.Fatalf("expected 2, got %d", c.Len()) + } +} diff --git a/server/filters/kb_inject.go b/server/filters/kb_inject.go new file mode 100644 index 0000000..8fa5e1c --- /dev/null +++ b/server/filters/kb_inject.go @@ -0,0 +1,112 @@ +// Package filters — kb_inject.go +// +// v0.29.0 CS0: Built-in pre-completion filter that auto-injects KB +// context into the system prompt. Refactored from the inline +// BuildKBHint() function in knowledge_bases.go. +// +// This is the reference implementation for the filter chain model +// that Starlark extensions will mirror in CS3. +package filters + +import ( + "context" + "fmt" + "log" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// KBInjectFilter injects a system prompt listing active knowledge bases +// so the LLM knows to use the kb_search tool. +type KBInjectFilter struct { + stores store.Stores +} + +// NewKBInjectFilter creates the built-in KB auto-inject filter. +func NewKBInjectFilter(stores store.Stores) *KBInjectFilter { + return &KBInjectFilter{stores: stores} +} + +func (f *KBInjectFilter) Name() string { return "kb-auto-inject" } +func (f *KBInjectFilter) Order() int { return 10 } // built-in, runs early + +func (f *KBInjectFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) { + type kbInfo struct { + Name string + DocCount int + } + var kbs []kbInfo + seen := make(map[string]bool) + + // ── Persona-bound KBs (v0.17.0) ── + if cc.PersonaID != "" { + personaKBs, err := f.stores.Personas.GetKBs(ctx, cc.PersonaID) + if err == nil { + for _, pkb := range personaKBs { + if pkb.ChunkCount > 0 { + kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount}) + seen[pkb.KBID] = true + } + } + } + } + + // ── Project-bound KBs (v0.19.0) ── + if f.stores.Projects != nil { + projID, _ := f.stores.Projects.GetProjectIDForChannel(ctx, cc.ChannelID) + if projID != "" { + projKBIDs, projErr := f.stores.Projects.GetKBIDs(ctx, projID) + if projErr == nil { + for _, kbID := range projKBIDs { + if !seen[kbID] { + kb, kbErr := f.stores.KnowledgeBases.GetByID(ctx, kbID) + if kbErr == nil && kb.ChunkCount > 0 { + kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) + seen[kbID] = true + } + } + } + } + } + } + + // ── Channel-linked KBs ── + channelKBs, err := f.stores.KnowledgeBases.GetChannelKBs(ctx, cc.ChannelID) + if err == nil { + for _, ckb := range channelKBs { + if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] { + kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount}) + seen[ckb.KBID] = true + } + } + } + + // ── Personal KBs (always available to owner) ── + personalKBs, err := f.stores.KnowledgeBases.ListPersonal(ctx, cc.UserID) + if err == nil { + for _, kb := range personalKBs { + if !seen[kb.ID] && kb.ChunkCount > 0 { + kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) + seen[kb.ID] = true + } + } + } + + if len(kbs) == 0 { + return nil, nil + } + + hint := "\nYou have access to the following knowledge bases:\n" + for _, kb := range kbs { + hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount) + } + hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents." + + log.Printf(" 🔗 kb-auto-inject: %d KBs active for channel %s", len(kbs), cc.ChannelID[:min(8, len(cc.ChannelID))]) + + return &Contribution{ + Messages: []InjectedMessage{ + {Role: "system", Content: hint}, + }, + }, nil +} diff --git a/server/filters/starlark_filter.go b/server/filters/starlark_filter.go new file mode 100644 index 0000000..f16f3e4 --- /dev/null +++ b/server/filters/starlark_filter.go @@ -0,0 +1,133 @@ +// Package filters — starlark_filter.go +// +// v0.29.0 CS3: Bridges the pre-completion filter chain to Starlark +// extension scripts. When a package declares "filters.pre_completion" +// permission and is active, this filter runs the package's +// on_pre_completion(ctx) function and converts the return value +// into injected messages. +// +// Starlark contract: +// +// def on_pre_completion(ctx): +// """Called before each completion request. +// ctx is a dict: {"channel_id": "...", "user_id": "...", "persona_id": "..."} +// Return a list of dicts: [{"role": "system", "content": "..."}] +// Return None or [] to inject nothing. +// """ +// return [{"role": "system", "content": "Extra context from extension"}] +package filters + +import ( + "context" + "fmt" + "log" + + "go.starlark.net/starlark" + + "git.gobha.me/xcaliber/chat-switchboard/sandbox" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// StarlarkFilter runs a Starlark package's on_pre_completion entry point. +type StarlarkFilter struct { + runner *sandbox.Runner + pkg *store.PackageRegistration + order int +} + +// NewStarlarkFilter creates a filter backed by a Starlark package. +func NewStarlarkFilter(runner *sandbox.Runner, pkg *store.PackageRegistration, order int) *StarlarkFilter { + return &StarlarkFilter{ + runner: runner, + pkg: pkg, + order: order, + } +} + +func (f *StarlarkFilter) Name() string { return "ext:" + f.pkg.ID } +func (f *StarlarkFilter) Order() int { return f.order } + +func (f *StarlarkFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) { + // Build context dict for the Starlark function + ctxDict := starlark.NewDict(4) + ctxDict.SetKey(starlark.String("channel_id"), starlark.String(cc.ChannelID)) + ctxDict.SetKey(starlark.String("user_id"), starlark.String(cc.UserID)) + ctxDict.SetKey(starlark.String("persona_id"), starlark.String(cc.PersonaID)) + ctxDict.SetKey(starlark.String("last_user_message"), starlark.String(cc.LastUserMessage)) + + val, output, err := f.runner.CallEntryPoint(ctx, f.pkg, "on_pre_completion", + starlark.Tuple{ctxDict}, nil) + if err != nil { + return nil, fmt.Errorf("ext:%s: %w", f.pkg.ID, err) + } + + if output != "" { + log.Printf(" 🔧 ext:%s print: %s", f.pkg.ID, output) + } + + // Parse return value into messages + messages, err := parseStarlarkMessages(val) + if err != nil { + return nil, fmt.Errorf("ext:%s: invalid return value: %w", f.pkg.ID, err) + } + + if len(messages) == 0 { + return nil, nil + } + + return &Contribution{Messages: messages}, nil +} + +// parseStarlarkMessages converts a Starlark return value to InjectedMessages. +// Accepts: None, empty list, or list of dicts with "role" and "content" keys. +func parseStarlarkMessages(val starlark.Value) ([]InjectedMessage, error) { + if val == nil || val == starlark.None { + return nil, nil + } + + list, ok := val.(*starlark.List) + if !ok { + return nil, fmt.Errorf("expected list or None, got %s", val.Type()) + } + + if list.Len() == 0 { + return nil, nil + } + + var messages []InjectedMessage + iter := list.Iterate() + defer iter.Done() + + var item starlark.Value + for iter.Next(&item) { + dict, ok := item.(*starlark.Dict) + if !ok { + return nil, fmt.Errorf("expected dict in list, got %s", item.Type()) + } + + roleVal, found, _ := dict.Get(starlark.String("role")) + if !found { + return nil, fmt.Errorf("message dict missing 'role' key") + } + role, ok := starlark.AsString(roleVal) + if !ok { + return nil, fmt.Errorf("'role' must be a string") + } + + contentVal, found, _ := dict.Get(starlark.String("content")) + if !found { + return nil, fmt.Errorf("message dict missing 'content' key") + } + content, ok := starlark.AsString(contentVal) + if !ok { + return nil, fmt.Errorf("'content' must be a string") + } + + messages = append(messages, InjectedMessage{ + Role: role, + Content: content, + }) + } + + return messages, nil +} diff --git a/server/filters/starlark_filter_test.go b/server/filters/starlark_filter_test.go new file mode 100644 index 0000000..d86fb10 --- /dev/null +++ b/server/filters/starlark_filter_test.go @@ -0,0 +1,119 @@ +package filters + +import ( + "testing" + + "go.starlark.net/starlark" +) + +func TestParseStarlarkMessages_None(t *testing.T) { + msgs, err := parseStarlarkMessages(starlark.None) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msgs != nil { + t.Fatalf("expected nil, got %d messages", len(msgs)) + } +} + +func TestParseStarlarkMessages_Nil(t *testing.T) { + msgs, err := parseStarlarkMessages(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msgs != nil { + t.Fatalf("expected nil, got %d messages", len(msgs)) + } +} + +func TestParseStarlarkMessages_EmptyList(t *testing.T) { + msgs, err := parseStarlarkMessages(starlark.NewList(nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msgs != nil { + t.Fatalf("expected nil, got %d messages", len(msgs)) + } +} + +func TestParseStarlarkMessages_SingleMessage(t *testing.T) { + d := starlark.NewDict(2) + d.SetKey(starlark.String("role"), starlark.String("system")) + d.SetKey(starlark.String("content"), starlark.String("injected context")) + list := starlark.NewList([]starlark.Value{d}) + + msgs, err := parseStarlarkMessages(list) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].Role != "system" { + t.Fatalf("expected role 'system', got %q", msgs[0].Role) + } + if msgs[0].Content != "injected context" { + t.Fatalf("expected content 'injected context', got %q", msgs[0].Content) + } +} + +func TestParseStarlarkMessages_MultipleMessages(t *testing.T) { + d1 := starlark.NewDict(2) + d1.SetKey(starlark.String("role"), starlark.String("system")) + d1.SetKey(starlark.String("content"), starlark.String("first")) + + d2 := starlark.NewDict(2) + d2.SetKey(starlark.String("role"), starlark.String("system")) + d2.SetKey(starlark.String("content"), starlark.String("second")) + + list := starlark.NewList([]starlark.Value{d1, d2}) + + msgs, err := parseStarlarkMessages(list) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + if msgs[0].Content != "first" || msgs[1].Content != "second" { + t.Fatalf("wrong content: %q, %q", msgs[0].Content, msgs[1].Content) + } +} + +func TestParseStarlarkMessages_MissingRole(t *testing.T) { + d := starlark.NewDict(1) + d.SetKey(starlark.String("content"), starlark.String("no role")) + list := starlark.NewList([]starlark.Value{d}) + + _, err := parseStarlarkMessages(list) + if err == nil { + t.Fatal("expected error for missing role") + } +} + +func TestParseStarlarkMessages_MissingContent(t *testing.T) { + d := starlark.NewDict(1) + d.SetKey(starlark.String("role"), starlark.String("system")) + list := starlark.NewList([]starlark.Value{d}) + + _, err := parseStarlarkMessages(list) + if err == nil { + t.Fatal("expected error for missing content") + } +} + +func TestParseStarlarkMessages_WrongType(t *testing.T) { + _, err := parseStarlarkMessages(starlark.String("not a list")) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestParseStarlarkMessages_NonDictInList(t *testing.T) { + list := starlark.NewList([]starlark.Value{starlark.String("not a dict")}) + + _, err := parseStarlarkMessages(list) + if err == nil { + t.Fatal("expected error for non-dict in list") + } +} diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 061adc6..b62ca22 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -126,10 +126,7 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) { return } if user.Role == models.UserRoleAdmin { - var adminCount int - database.DB.QueryRowContext(c.Request.Context(), - database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`), - models.UserRoleAdmin).Scan(&adminCount) + adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) if adminCount <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"}) return @@ -203,7 +200,7 @@ func (h *AdminHandler) destroyVault(c *gin.Context, userID string) { return } - deleted := DestroyVaultDB(c.Request.Context(), userID) + deleted := DestroyVaultDB(c.Request.Context(), h.stores, userID) // Evict from session cache (if user is currently logged in) h.uekCache.Evict(userID) @@ -236,7 +233,7 @@ func (h *AdminHandler) ResetVault(c *gin.Context) { return } - deleted := DestroyVaultDB(c.Request.Context(), userID) + deleted := DestroyVaultDB(c.Request.Context(), h.stores, userID) h.uekCache.Evict(userID) h.auditLog(c, "user.vault_reset", "user", userID, models.JSONMap{ @@ -261,10 +258,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { return } if user.Role == models.UserRoleAdmin { - var adminCount int - database.DB.QueryRowContext(c.Request.Context(), - database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`), - models.UserRoleAdmin).Scan(&adminCount) + adminCount, _ := h.stores.Users.CountByRole(c.Request.Context(), models.UserRoleAdmin) if adminCount <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"}) return @@ -769,10 +763,9 @@ func (h *AdminHandler) GetStats(c *gin.Context) { ctx := c.Request.Context() stats := gin.H{} - var userCount, channelCount, messageCount int - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount) - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount) - database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount) + userCount, _ := h.stores.Users.CountAll(ctx) + channelCount, _ := h.stores.Channels.CountAll(ctx) + messageCount, _ := h.stores.Messages.CountAll(ctx) stats["users"] = userCount stats["channels"] = channelCount diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 1d45690..025f9dc 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -20,7 +20,6 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -184,10 +183,7 @@ func (h *AuthHandler) OIDCLogin(c *gin.Context) { } // Store state for callback verification - _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3) - `), state, nonce, c.Query("redirect")) - if err != nil { + if err := h.stores.GlobalConfig.SaveOIDCState(c.Request.Context(), state, nonce, c.Query("redirect")); err != nil { log.Printf("[auth/oidc] warn: could not store state: %v", err) } @@ -220,29 +216,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) { return } - // Verify state - var nonce, redirectTo string - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1 - `), state).Scan(&nonce, &redirectTo) + // Verify state (nonce + redirectTo retrieved but not yet validated — TODO) + _, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"}) return } - // Clean up state (one-time use) - database.DB.ExecContext(c.Request.Context(), database.Q(` - DELETE FROM oidc_auth_state WHERE state = $1 - `), state) - - // Also clean up stale states (> 10 minutes old) - if database.IsSQLite() { - database.DB.ExecContext(c.Request.Context(), - `DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`) - } else { - database.DB.ExecContext(c.Request.Context(), - `DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`) - } + // Clean up stale states (> 10 minutes old) + _ = h.stores.GlobalConfig.CleanupOIDCState(c.Request.Context()) // Determine redirect URI (must match what was sent in the login request) redirectURI := h.cfg.OIDCRedirectURL @@ -363,24 +345,17 @@ func hashToken(token string) string { // - AdminHandler.destroyVault (admin-initiated reset) // // Does NOT evict from UEK cache or write audit logs — callers handle that. -func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) { - _, err := database.DB.ExecContext(ctx, database.Q(` - UPDATE users - SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false - WHERE id = $1 - `), userID) - if err != nil { +// v0.29.0: accepts stores instead of using database.DB directly. +func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (providersDeleted int64) { + if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil { log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err) } - result, err := database.DB.ExecContext(ctx, database.Q(` - DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1 - `), userID) + rows, err := stores.Providers.DeletePersonalByOwner(ctx, userID) if err != nil { log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err) return 0 } - rows, _ := result.RowsAffected() return rows } @@ -392,14 +367,9 @@ func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) // // Used by BootstrapAdmin and SeedUsers where the password is known at // startup but the UEK cache is not available. -func ProbeAndRepairVault(ctx context.Context, userID, password string) { - var vaultSet bool - var encryptedUEK, salt, nonce []byte - - err := database.DB.QueryRowContext(ctx, database.Q(` - SELECT vault_set, encrypted_uek, uek_salt, uek_nonce - FROM users WHERE id = $1 - `), userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce) +// v0.29.0: accepts stores instead of using database.DB directly. +func ProbeAndRepairVault(ctx context.Context, stores store.Stores, userID, password string) { + vaultSet, encryptedUEK, salt, nonce, err := stores.Users.GetVaultKeys(ctx, userID) if err != nil || !vaultSet { return // no vault to probe } @@ -410,7 +380,7 @@ func ProbeAndRepairVault(ctx context.Context, userID, password string) { } // Stale seal: password has actually changed since the vault was sealed - deleted := DestroyVaultDB(ctx, userID) + deleted := DestroyVaultDB(ctx, stores, userID) if deleted > 0 { log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted) } else { @@ -442,12 +412,7 @@ func (h *AuthHandler) initVault(ctx context.Context, userID, password string) er return err } - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE users - SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true - WHERE id = $4 - `), encryptedUEK, salt, nonce, userID) - if err != nil { + if err := h.stores.Users.InitVaultKeys(ctx, userID, encryptedUEK, salt, nonce); err != nil { return err } @@ -464,13 +429,7 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo return } - var vaultSet bool - var encryptedUEK, salt, nonce []byte - - err := database.DB.QueryRowContext(ctx, database.Q(` - SELECT vault_set, encrypted_uek, uek_salt, uek_nonce - FROM users WHERE id = $1 - `), user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce) + vaultSet, encryptedUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(ctx, user.ID) if err != nil { log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err) return @@ -489,10 +448,7 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk) if err != nil { // Stale seal: encrypted_uek was wrapped with a different password - // (e.g. admin reset, BootstrapAdmin rotation, or pre-UEK migration). - // The old UEK is irrecoverable. Destroy the vault and re-initialize - // with the current password so the user isn't permanently locked out. - deleted := DestroyVaultDB(ctx, user.ID) + deleted := DestroyVaultDB(ctx, h.stores, user.ID) if deleted > 0 { log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted) } else { @@ -536,7 +492,7 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername)) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) } - ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword) + ProbeAndRepairVault(ctx, s, existing.ID, cfg.AdminPassword) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) return } @@ -626,7 +582,7 @@ func SeedUsers(cfg *config.Config, s store.Stores) { handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) } - ProbeAndRepairVault(ctx, existing.ID, password) + ProbeAndRepairVault(ctx, s, existing.ID, password) log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) continue } diff --git a/server/handlers/avatar.go b/server/handlers/avatar.go index 02e671c..7c3eaa9 100644 --- a/server/handlers/avatar.go +++ b/server/handlers/avatar.go @@ -15,7 +15,8 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" ) const avatarSize = 128 @@ -76,10 +77,7 @@ func (h *SettingsHandler) UploadAvatar(c *gin.Context) { dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) // Store in DB - _, err = database.DB.Exec( - database.Q(`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`), - dataURI, userID, - ) + err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"}) return @@ -93,10 +91,7 @@ func (h *SettingsHandler) UploadAvatar(c *gin.Context) { func (h *SettingsHandler) DeleteAvatar(c *gin.Context) { userID := getUserID(c) - _, err := database.DB.Exec( - database.Q(`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`), - userID, - ) + err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"}) return @@ -166,7 +161,9 @@ func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color { // ── Persona Avatar Upload ──────────────────── // POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin) -func UploadPersonaAvatar(c *gin.Context) { +// UploadPersonaAvatar uploads and resizes a persona avatar. +// v0.29.0: accepts PersonaStore instead of using database.DB directly. +func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) { personaID := c.Param("id") var req uploadAvatarRequest @@ -206,40 +203,26 @@ func UploadPersonaAvatar(c *gin.Context) { dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) - result, err := database.DB.Exec( - database.Q(`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`), - dataURI, personaID, - ) + err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"}) return } - rows, _ := result.RowsAffected() - if rows == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } c.JSON(http.StatusOK, gin.H{"avatar": dataURI}) } // DeletePersonaAvatar clears a persona's avatar. -func DeletePersonaAvatar(c *gin.Context) { +// v0.29.0: accepts PersonaStore instead of using database.DB directly. +func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) { personaID := c.Param("id") - result, err := database.DB.Exec( - database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`), - personaID, - ) + empty := "" + err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"}) return } - rows, _ := result.RowsAffected() - if rows == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } c.JSON(http.StatusOK, gin.H{"message": "avatar removed"}) } diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go index 68c639c..a7a8c1b 100644 --- a/server/handlers/capabilities.go +++ b/server/handlers/capabilities.go @@ -10,7 +10,6 @@ import ( capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/auth" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -90,10 +89,11 @@ func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.Pro } // ResolveModelCaps is the canonical capability resolver for any model. -func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities { +// v0.29.0: accepts CatalogStore instead of using database.DB directly. +func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities { // 1. Exact match: model_id + provider_config_id if configID != "" { - caps, ok := capsFromCatalog(modelID, configID) + caps, ok := capsFromCatalog(catalog, modelID, configID) if ok { caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps @@ -101,7 +101,7 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa } // 2. Any provider: same model_id, any config - caps, ok := capsFromCatalog(modelID, "") + caps, ok := capsFromCatalog(catalog, modelID, "") if ok { caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps @@ -114,23 +114,17 @@ func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapa } // capsFromCatalog looks up capabilities from the model_catalog table. -func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) { - if database.DB == nil { +func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) { + if catalog == nil { return models.ModelCapabilities{}, false } var capsJSON []byte var err error if configID != "" { - err = database.DB.QueryRow(database.Q(` - SELECT capabilities FROM model_catalog - WHERE model_id = $1 AND provider_config_id = $2 - `), modelID, configID).Scan(&capsJSON) + capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID) } else { - err = database.DB.QueryRow(database.Q(` - SELECT capabilities FROM model_catalog - WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1 - `), modelID).Scan(&capsJSON) + capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID) } if err != nil || len(capsJSON) == 0 { return models.ModelCapabilities{}, false diff --git a/server/handlers/channels.go b/server/handlers/channels.go index d82756f..6adca2f 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -1,7 +1,6 @@ package handlers import ( - "database/sql" "encoding/json" "math" "net/http" @@ -9,69 +8,68 @@ import ( "strings" "github.com/gin-gonic/gin" - "github.com/lib/pq" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Request / Response types ──────────────── type createChannelRequest struct { - Title string `json:"title" binding:"required,max=500"` - Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow - Description string `json:"description,omitempty"` - Model string `json:"model,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Folder string `json:"folder,omitempty"` - FolderID *string `json:"folder_id,omitempty"` - Tags []string `json:"tags,omitempty"` + Title string `json:"title" binding:"required,max=500"` + Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow + Description string `json:"description,omitempty"` + Model string `json:"model,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + ProviderConfigID *string `json:"provider_config_id,omitempty"` + Folder string `json:"folder,omitempty"` + FolderID *string `json:"folder_id,omitempty"` + Tags []string `json:"tags,omitempty"` // v0.23.2: DM creation Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user) AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off } type updateChannelRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Model *string `json:"model,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - IsPinned *bool `json:"is_pinned,omitempty"` - Folder *string `json:"folder,omitempty"` - FolderID *string `json:"folder_id,omitempty"` - Tags []string `json:"tags,omitempty"` - Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings - WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) - AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off - Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Model *string `json:"model,omitempty"` + SystemPrompt *string `json:"system_prompt,omitempty"` + ProviderConfigID *string `json:"provider_config_id,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` + IsPinned *bool `json:"is_pinned,omitempty"` + Folder *string `json:"folder,omitempty"` + FolderID *string `json:"folder_id,omitempty"` + Tags []string `json:"tags,omitempty"` + Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings + WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) + AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off + Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic } type channelResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Title string `json:"title"` - Type string `json:"type"` - AiMode string `json:"ai_mode,omitempty"` - Topic *string `json:"topic,omitempty"` - Description *string `json:"description"` - Model *string `json:"model"` - ProviderConfigID *string `json:"provider_config_id"` - SystemPrompt *string `json:"system_prompt"` - IsArchived bool `json:"is_archived"` - IsPinned bool `json:"is_pinned"` - Folder *string `json:"folder"` - FolderID *string `json:"folder_id,omitempty"` - ProjectID *string `json:"project_id,omitempty"` - WorkspaceID *string `json:"workspace_id,omitempty"` - Tags []string `json:"tags"` - Settings json.RawMessage `json:"settings,omitempty"` - MessageCount int `json:"message_count"` - UnreadCount int `json:"unread_count,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Type string `json:"type"` + AiMode string `json:"ai_mode,omitempty"` + Topic *string `json:"topic,omitempty"` + Description *string `json:"description"` + Model *string `json:"model"` + ProviderConfigID *string `json:"provider_config_id"` + SystemPrompt *string `json:"system_prompt"` + IsArchived bool `json:"is_archived"` + IsPinned bool `json:"is_pinned"` + Folder *string `json:"folder"` + FolderID *string `json:"folder_id,omitempty"` + ProjectID *string `json:"project_id,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` + Tags []string `json:"tags"` + Settings json.RawMessage `json:"settings,omitempty"` + MessageCount int `json:"message_count"` + UnreadCount int `json:"unread_count,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type paginatedResponse struct { @@ -83,11 +81,13 @@ type paginatedResponse struct { } // ChannelHandler holds dependencies for channel endpoints. -type ChannelHandler struct{} +type ChannelHandler struct { + stores store.Stores +} // NewChannelHandler creates a new channel handler. -func NewChannelHandler() *ChannelHandler { - return &ChannelHandler{} +func NewChannelHandler(stores store.Stores) *ChannelHandler { + return &ChannelHandler{stores: stores} } // channelDeleteHook is called after a channel is successfully deleted. @@ -100,20 +100,6 @@ func SetChannelDeleteHook(fn func(channelID string)) { channelDeleteHook = fn } -// ── Tag Helpers ───────────────────────────── - -// writeTagsArg returns a value suitable for inserting/updating the tags column. -// Postgres: pq.Array SQLite: JSON string -func writeTagsArg(tags []string) interface{} { - if database.IsSQLite() { - b, _ := json.Marshal(tags) - return string(b) - } - return pq.Array(tags) -} - -// scanJSON, scanTags, SafeJSON → safe_json.go - // ── Helpers ───────────────────────────────── // getUserID extracts the authenticated user's ID from context. @@ -129,8 +115,7 @@ func isSessionAuth(c *gin.Context) bool { } // sessionCanAccessChannel validates that a session participant is authorized -// for the given channel. The AuthOrSession middleware already verifies this, -// but handlers call this as a defense-in-depth check. +// for the given channel. func sessionCanAccessChannel(c *gin.Context, channelID string) bool { if !isSessionAuth(c) { return false @@ -156,6 +141,38 @@ func parsePagination(c *gin.Context) (page, perPage, offset int) { return } +// listItemToResponse converts a store.ChannelListItem to a handler response. +func listItemToResponse(item store.ChannelListItem) channelResponse { + tags := item.Tags + if tags == nil { + tags = []string{} + } + return channelResponse{ + ID: item.ID, + UserID: item.UserID, + Title: item.Title, + Type: item.Type, + AiMode: item.AiMode, + Topic: item.Topic, + Description: item.Description, + Model: item.Model, + ProviderConfigID: item.ProviderConfigID, + SystemPrompt: item.SystemPrompt, + IsArchived: item.IsArchived, + IsPinned: item.IsPinned, + Folder: item.Folder, + FolderID: item.FolderID, + ProjectID: item.ProjectID, + WorkspaceID: item.WorkspaceID, + Tags: tags, + Settings: item.Settings, + MessageCount: item.MessageCount, + UnreadCount: item.UnreadCount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + // ── List Channels ─────────────────────────── func (h *ChannelHandler) ListChannels(c *gin.Context) { @@ -164,11 +181,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { // Optional filters archived := c.DefaultQuery("archived", "false") - folder := c.Query("folder") - folderID := c.Query("folder_id") // UUID — preferred over folder (text) - channelType := c.DefaultQuery("type", "") // empty = all types - // v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel - // and comma-joined ?types=dm,channel + var channelTypes []string if raw := c.Query("types"); raw != "" { for _, t := range strings.Split(raw, ",") { @@ -178,156 +191,31 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { } } } - search := strings.TrimSpace(c.Query("search")) - projectFilter := c.Query("project_id") // "uuid" or "none" - - // Count total — include channels owned by user OR where user is a participant - countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN ( - SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 - )) AND c.is_archived = $2` - countArgs := []interface{}{userID, archived == "true"} - argN := 3 - - if len(channelTypes) > 0 { - placeholders := make([]string, len(channelTypes)) - for i, t := range channelTypes { - placeholders[i] = "$" + strconv.Itoa(argN) - countArgs = append(countArgs, strings.TrimSpace(t)) - argN++ + if len(channelTypes) == 0 { + if ct := c.DefaultQuery("type", ""); ct != "" { + channelTypes = []string{ct} } - countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" - } else if channelType != "" { - countQuery += ` AND c.type = $` + strconv.Itoa(argN) - countArgs = append(countArgs, channelType) - argN++ - } - if folder != "" { - countQuery += ` AND c.folder = $` + strconv.Itoa(argN) - countArgs = append(countArgs, folder) - argN++ - } - if folderID != "" { - countQuery += ` AND c.folder_id = $` + strconv.Itoa(argN) - countArgs = append(countArgs, folderID) - argN++ - } - if search != "" { - countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN) - countArgs = append(countArgs, "%"+search+"%") - argN++ - } - if projectFilter == "none" { - countQuery += ` AND c.project_id IS NULL` - } else if projectFilter != "" { - countQuery += ` AND c.project_id = $` + strconv.Itoa(argN) - countArgs = append(countArgs, projectFilter) - argN++ } - var total int - if err := database.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"}) - return + filter := store.ChannelListFilter{ + ListOptions: store.ListOptions{Limit: perPage, Offset: offset}, + Archived: archived == "true", + Types: channelTypes, + Folder: c.Query("folder"), + FolderID: c.Query("folder_id"), + Search: strings.TrimSpace(c.Query("search")), + ProjectID: c.Query("project_id"), } - // Fetch channels with message count - // Include channels owned by user OR where user is a participant (multi-user) - query := ` - SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, - c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at - FROM channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id - ) mc ON mc.channel_id = c.id - WHERE (c.user_id = $1 OR c.id IN ( - SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 - )) AND c.is_archived = $2` - - args := []interface{}{userID, archived == "true"} - argN = 3 - - if len(channelTypes) > 0 { - placeholders := make([]string, len(channelTypes)) - for i, t := range channelTypes { - placeholders[i] = "$" + strconv.Itoa(argN) - args = append(args, strings.TrimSpace(t)) - argN++ - } - query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" - } else if channelType != "" { - query += ` AND c.type = $` + strconv.Itoa(argN) - args = append(args, channelType) - argN++ - } - if folder != "" { - query += ` AND c.folder = $` + strconv.Itoa(argN) - args = append(args, folder) - argN++ - } - if folderID != "" { - query += ` AND c.folder_id = $` + strconv.Itoa(argN) - args = append(args, folderID) - argN++ - } - if search != "" { - query += ` AND c.title ILIKE $` + strconv.Itoa(argN) - args = append(args, "%"+search+"%") - argN++ - } - if projectFilter == "none" { - query += ` AND c.project_id IS NULL` - } else if projectFilter != "" { - query += ` AND c.project_id = $` + strconv.Itoa(argN) - args = append(args, projectFilter) - argN++ - } - - query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1) - args = append(args, perPage, offset) - - rows, err := database.Query(query, args...) + items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return } - defer rows.Close() - channels := make([]channelResponse, 0) - for rows.Next() { - var ch channelResponse - var tags []string - err := rows.Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, - &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), - &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"}) - return - } - if tags == nil { - tags = []string{} - } - ch.Tags = tags - channels = append(channels, ch) - } - rows.Close() - - // v0.23.2: Compute unread counts per channel (safe post-processing) - for i := range channels { - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COUNT(*) FROM messages m - JOIN channel_participants cp ON cp.channel_id = m.channel_id - WHERE cp.channel_id = $1 - AND cp.participant_type = 'user' AND cp.participant_id = $2 - AND m.created_at > cp.last_read_at - `), channels[i].ID, userID).Scan(&channels[i].UnreadCount) + channels := make([]channelResponse, 0, len(items)) + for _, item := range items { + channels = append(channels, listItemToResponse(item)) } SafeJSON(c, http.StatusOK, paginatedResponse{ @@ -370,6 +258,8 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { } } + ctx := c.Request.Context() + // ── DM dedup: check for existing DM between these two users ── if channelType == "dm" && len(req.Participants) == 1 { otherUserID := req.Participants[0] @@ -378,162 +268,54 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { return } - // Look for an existing DM channel where both users are participants - var existingID string - _ = database.DB.QueryRow(database.Q(` - SELECT cp1.channel_id FROM channel_participants cp1 - JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id - JOIN channels c ON c.id = cp1.channel_id - WHERE c.type = 'dm' - AND cp1.participant_type = 'user' AND cp1.participant_id = $1 - AND cp2.participant_type = 'user' AND cp2.participant_id = $2 - LIMIT 1 - `), userID, otherUserID).Scan(&existingID) + existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID) if existingID != "" { // Return existing channel instead of creating duplicate - var ch channelResponse - var tags []string - err := database.DB.QueryRow(database.Q(` - SELECT id, user_id, title, type, ai_mode, topic, - description, model, provider_config_id, - system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id, - tags, settings, - created_at, updated_at - FROM channels WHERE id = $1 - `), existingID).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, - &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, - ) + item, err := h.stores.Channels.GetForUser(ctx, existingID, userID) if err == nil { - if tags == nil { - tags = []string{} - } - ch.Tags = tags - SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource + SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource return } // If read fails, fall through and create new (shouldn't happen) } } - // INSERT and retrieve the new row - var ch channelResponse - var tags []string - - if database.IsSQLite() { - id := store.NewID() - _, err := database.DB.Exec(` - INSERT INTO channels (id, user_id, title, type, description, model, - system_prompt, provider_config_id, folder, folder_id, tags, ai_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, userID, req.Title, channelType, req.Description, req.Model, - req.SystemPrompt, req.ProviderConfigID, req.Folder, req.FolderID, writeTagsArg(req.Tags), aiMode, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } - // Read back the row - err = database.DB.QueryRow(` - SELECT id, user_id, title, type, ai_mode, topic, - description, model, provider_config_id, - system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id, - tags, settings, - created_at, updated_at - FROM channels WHERE id = ?`, id).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, - &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel: " + err.Error()}) - return - } - } else { - err := database.DB.QueryRow(` - INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, folder_id, tags, ai_mode) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, user_id, title, type, ai_mode, topic, - description, model, provider_config_id, system_prompt, - is_archived, is_pinned, folder, folder_id, project_id, workspace_id, tags, settings, created_at, updated_at - `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID, - req.Folder, req.FolderID, pq.Array(req.Tags), aiMode, - ).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, - &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID, - pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } + // Build channel model + ch := &models.Channel{ + UserID: userID, + Title: req.Title, + Type: channelType, + Description: req.Description, + Model: req.Model, + SystemPrompt: req.SystemPrompt, + ProviderConfigID: req.ProviderConfigID, + FolderID: req.FolderID, } - if tags == nil { - tags = []string{} - } - ch.Tags = tags - ch.MessageCount = 0 - - // Auto-create channel_participant for the creator - if database.IsSQLite() { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) - VALUES (?, ?, 'user', ?, 'owner') - ON CONFLICT DO NOTHING - `, store.NewID(), ch.ID, userID) - } else { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) - VALUES ($1, 'user', $2, 'owner') - ON CONFLICT DO NOTHING - `, ch.ID, userID) + dmPartners := []string{} + if channelType == "dm" { + dmPartners = req.Participants } - // v0.23.2: Add DM partner as member participant - if channelType == "dm" && len(req.Participants) > 0 { - for _, pid := range req.Participants { - if pid == userID { - continue // skip self (already added as owner) - } - if database.IsSQLite() { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) - VALUES (?, ?, 'user', ?, 'member') - ON CONFLICT DO NOTHING - `, store.NewID(), ch.ID, pid) - } else { - _, _ = database.DB.Exec(` - INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) - VALUES ($1, 'user', $2, 'member') - ON CONFLICT DO NOTHING - `, ch.ID, pid) - } - } + defaultConfigID := "" + if req.ProviderConfigID != nil { + defaultConfigID = *req.ProviderConfigID } - // Auto-create channel_model if model specified - if req.Model != "" { - if database.IsSQLite() { - _, _ = database.DB.Exec(` - INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default) - VALUES (?, ?, ?, ?, 1) - ON CONFLICT DO NOTHING - `, store.NewID(), ch.ID, req.Model, req.ProviderConfigID) - } else { - _, _ = database.DB.Exec(` - INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) - VALUES ($1, $2, $3, true) - ON CONFLICT DO NOTHING - `, ch.ID, req.Model, req.ProviderConfigID) - } + if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode, + userID, dmPartners, req.Model, defaultConfigID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) + return } - SafeJSON(c, http.StatusCreated, ch) + // Fetch full response with message count + item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"}) + return + } + + SafeJSON(c, http.StatusCreated, listItemToResponse(*item)) } // ── Get Channel ───────────────────────────── @@ -542,45 +324,13 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - var ch channelResponse - var tags []string - err := database.DB.QueryRow(database.Q(` - SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, - c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, - c.tags, c.settings, - COALESCE(mc.cnt, 0) AS message_count, - c.created_at, c.updated_at - FROM channels c - LEFT JOIN ( - SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id - ) mc ON mc.channel_id = c.id - WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN ( - SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2 - )) - `), channelID, userID).Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, - &ch.Description, &ch.Model, &ch.ProviderConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID, - scanTags(&tags), scanJSON(&ch.Settings), - &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, - ) - - if err == sql.ErrNoRows { + item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"}) - return - } - if tags == nil { - tags = []string{} - } - ch.Tags = tags - - SafeJSON(c, http.StatusOK, ch) + SafeJSON(c, http.StatusOK, listItemToResponse(*item)) } // ── Update Channel ────────────────────────── @@ -588,11 +338,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { func (h *ChannelHandler) UpdateChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - - if database.DB == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"}) - return - } + ctx := c.Request.Context() var req updateChannelRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -601,79 +347,62 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { } // Verify ownership - var ownerID string - err := database.DB.QueryRow(database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID) - if err == sql.ErrNoRows { + owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - if ownerID != userID { + if !owns { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } - // Build dynamic UPDATE with ? placeholders (converted for Postgres) - setClauses := []string{} - args := []interface{}{} - - addClause := func(col string, val interface{}) { - setClauses = append(setClauses, col+" = ?") - args = append(args, val) - } + // Build fields map for store.Update + fields := map[string]interface{}{} if req.Title != nil { - addClause("title", *req.Title) + fields["title"] = *req.Title } if req.Description != nil { - addClause("description", *req.Description) + fields["description"] = *req.Description } if req.Model != nil { - addClause("model", *req.Model) + fields["model"] = *req.Model } if req.SystemPrompt != nil { - addClause("system_prompt", *req.SystemPrompt) + fields["system_prompt"] = *req.SystemPrompt } if req.ProviderConfigID != nil { - addClause("provider_config_id", *req.ProviderConfigID) + if *req.ProviderConfigID == "" { + fields["provider_config_id"] = nil + } else { + fields["provider_config_id"] = *req.ProviderConfigID + } } if req.IsArchived != nil { - addClause("is_archived", *req.IsArchived) + fields["is_archived"] = *req.IsArchived } if req.IsPinned != nil { - addClause("is_pinned", *req.IsPinned) + fields["is_pinned"] = *req.IsPinned } if req.Folder != nil { - addClause("folder", *req.Folder) + fields["folder"] = *req.Folder } if req.FolderID != nil { if *req.FolderID == "" { - addClause("folder_id", nil) // unbind from folder + fields["folder_id"] = nil // unbind from folder } else { - addClause("folder_id", *req.FolderID) + fields["folder_id"] = *req.FolderID } } if req.Tags != nil { - addClause("tags", writeTagsArg(req.Tags)) - } - if req.Settings != nil { - // Validate settings is well-formed JSON before writing - if !json.Valid([]byte(*req.Settings)) { - c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) - return - } - // JSONB merge: new settings keys overwrite existing, unmentioned keys preserved - if database.IsSQLite() { - setClauses = append(setClauses, "settings = json_patch(settings, ?)") - } else { - setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || ?::jsonb") - } - args = append(args, []byte(*req.Settings)) + fields["tags"] = req.Tags } if req.WorkspaceID != nil { if *req.WorkspaceID == "" { - addClause("workspace_id", nil) // unbind + fields["workspace_id"] = nil // unbind } else { - addClause("workspace_id", *req.WorkspaceID) + fields["workspace_id"] = *req.WorkspaceID } } if req.AiMode != nil { @@ -682,29 +411,39 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"}) return } - addClause("ai_mode", mode) + fields["ai_mode"] = mode } if req.Topic != nil { - addClause("topic", *req.Topic) + fields["topic"] = *req.Topic } - if len(setClauses) == 0 { + hasFields := len(fields) > 0 + hasSettings := req.Settings != nil + + if !hasFields && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - query := "UPDATE channels SET " + strings.Join(setClauses, ", ") - query += " WHERE id = ? AND user_id = ?" - args = append(args, channelID, userID) - - if !database.IsSQLite() { - query = convertPlaceholders(query) + if hasSettings { + if !json.Valid([]byte(*req.Settings)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) + return + } } - _, err = database.DB.Exec(query, args...) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) - return + if hasFields { + if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) + return + } + } + + if hasSettings { + if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) + return + } } // Return updated channel @@ -717,17 +456,12 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - result, err := database.DB.Exec( - database.Q(`DELETE FROM channels WHERE id = $1 AND user_id = $2`), - channelID, userID, - ) + n, err := h.stores.Channels.DeleteByOwner(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) return } - - rows, _ := result.RowsAffected() - if rows == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } @@ -746,30 +480,9 @@ func (h *ChannelHandler) MarkRead(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - // Update the read cursor timestamp (last_read_at exists since migration 005) - _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE channel_participants - SET last_read_at = NOW() - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 - `), channelID, userID) + err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID) if err != nil { // Participant row may not exist for legacy direct chats — that's OK - c.JSON(http.StatusOK, gin.H{"ok": true}) - return - } - - // Best-effort: also set last_read_message_id if the column exists (migration 017) - var latestMsgID *string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 - `), channelID).Scan(&latestMsgID) - if latestMsgID != nil { - // This may fail if 017 hasn't been applied — that's fine, last_read_at is primary - _, _ = database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE channel_participants - SET last_read_message_id = $1 - WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 - `), *latestMsgID, channelID, userID) } c.JSON(http.StatusOK, gin.H{"ok": true}) diff --git a/server/handlers/completion.go b/server/handlers/completion.go index ddcda92..04b670c 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -18,6 +18,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/filters" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -55,6 +56,7 @@ type CompletionHandler struct { health HealthRecorder // provider health tracking (v0.22.0, nil = disabled) healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled) router *routing.Evaluator // routing policy evaluator (v0.22.2, nil = disabled) + filterChain *filters.Chain // pre-completion filter chain (v0.29.0, nil = disabled) } // HealthRecorder is the interface for recording provider call outcomes. @@ -94,6 +96,11 @@ func (h *CompletionHandler) SetRoutingEvaluator(r *routing.Evaluator) { h.router = r } +// SetFilterChain attaches the pre-completion filter chain (v0.29.0). +func (h *CompletionHandler) SetFilterChain(fc *filters.Chain) { + h.filterChain = fc +} + // evaluateRouting applies routing policies to select the best provider config // for this request. Returns the winning config details and a routing decision // for observability. If routing is disabled or no policies match, returns @@ -430,7 +437,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // ── Team policy: require_private_providers ── - if err := enforcePrivateProviderPolicy(userID, configID); err != nil { + if err := enforcePrivateProviderPolicy(c.Request.Context(), h.stores, userID, configID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } @@ -1092,7 +1099,7 @@ func escapeJSON(s string) string { // getModelCapabilities looks up capabilities from model_catalog DB, // then overlays with known model defaults and heuristic detection. func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities { - return ResolveModelCaps(c, model, apiConfigID) + return ResolveModelCaps(h.stores.Catalog, model, apiConfigID) } // ── Multimodal Assembly ───────────────────── @@ -1513,7 +1520,7 @@ func extractFirstMention(content string) string { // Priority: request.provider_config_id → chat.provider_config_id → user's first active config func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) { - res, err := ResolveProviderConfig(h.vault, userID, channelID, req.ProviderConfigID, req.Model) + res, err := ResolveProviderConfig(h.stores, h.vault, userID, channelID, req.ProviderConfigID, req.Model) if err != nil { return providers.ProviderConfig{}, "", "", "", "", err } @@ -1576,12 +1583,22 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro } } - // ── KB hint (nudge LLM to use kb_search when KBs are active) ── - if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: kbHint, - }) + // ── Pre-completion filter chain (v0.29.0) ── + // Runs registered filters (KB auto-inject, future extension filters). + // Replaces the inline BuildKBHint call. Each filter contributes zero + // or more system messages. Failures are logged and skipped. + if h.filterChain != nil && h.filterChain.Len() > 0 { + cc := &filters.CompletionContext{ + ChannelID: channelID, + UserID: userID, + PersonaID: personaID, + } + for _, injected := range h.filterChain.Execute(context.Background(), cc) { + messages = append(messages, providers.Message{ + Role: injected.Role, + Content: injected.Content, + }) + } } // ── Memory hint (inject known facts about this user — v0.18.0) ── diff --git a/server/handlers/extension_permissions.go b/server/handlers/extension_permissions.go new file mode 100644 index 0000000..e1ce81e --- /dev/null +++ b/server/handlers/extension_permissions.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ExtPermHandler serves extension permission management endpoints. +// v0.29.0 CS2: Admin reviews and grants/revokes declared permissions. +type ExtPermHandler struct { + stores store.Stores +} + +func NewExtPermHandler(stores store.Stores) *ExtPermHandler { + return &ExtPermHandler{stores: stores} +} + +// ListPackagePermissions returns all declared permissions for a package. +// GET /api/v1/admin/extensions/:id/permissions +func (h *ExtPermHandler) ListPackagePermissions(c *gin.Context) { + pkgID := c.Param("id") + + perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list permissions"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": perms}) +} + +// GrantPermission grants a single declared permission for a package. +// POST /api/v1/admin/extensions/:id/permissions/:perm/grant +func (h *ExtPermHandler) GrantPermission(c *gin.Context) { + pkgID := c.Param("id") + perm := c.Param("perm") + userID := c.GetString("user_id") + + if !models.ValidExtensionPermissions[perm] { + c.JSON(http.StatusBadRequest, gin.H{"error": "unrecognized permission: " + perm}) + return + } + + if err := h.stores.ExtPermissions.Grant(c.Request.Context(), pkgID, perm, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to grant permission"}) + return + } + + // Check if all permissions are now granted → activate package + h.maybeActivate(c, pkgID) + + c.JSON(http.StatusOK, gin.H{"status": "granted", "package_id": pkgID, "permission": perm}) +} + +// RevokePermission revokes a single permission for a package. +// POST /api/v1/admin/extensions/:id/permissions/:perm/revoke +func (h *ExtPermHandler) RevokePermission(c *gin.Context) { + pkgID := c.Param("id") + perm := c.Param("perm") + + if err := h.stores.ExtPermissions.Revoke(c.Request.Context(), pkgID, perm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke permission"}) + return + } + + // Suspend if a previously-active package loses a grant + h.maybeSuspend(c, pkgID) + + c.JSON(http.StatusOK, gin.H{"status": "revoked", "package_id": pkgID, "permission": perm}) +} + +// GrantAllPermissions grants all declared permissions for a package. +// POST /api/v1/admin/extensions/:id/permissions/grant-all +func (h *ExtPermHandler) GrantAllPermissions(c *gin.Context) { + pkgID := c.Param("id") + userID := c.GetString("user_id") + + if err := h.stores.ExtPermissions.GrantAll(c.Request.Context(), pkgID, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to grant all permissions"}) + return + } + + // Activate the package now that all permissions are granted + h.maybeActivate(c, pkgID) + + c.JSON(http.StatusOK, gin.H{"status": "granted_all", "package_id": pkgID}) +} + +// ReviewPackage returns the package with its declared permissions. +// GET /api/v1/admin/extensions/:id/review +func (h *ExtPermHandler) ReviewPackage(c *gin.Context) { + pkgID := c.Param("id") + + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + + perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list permissions"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "package": pkg, + "permissions": perms, + }) +} + +// ── Lifecycle helpers ──────────────────────── + +// maybeActivate transitions a pending_review package to active +// if all declared permissions have been granted. +func (h *ExtPermHandler) maybeActivate(c *gin.Context, pkgID string) { + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + return + } + if pkg.Status != models.PackageStatusPendingReview { + return + } + + perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID) + if err != nil { + return + } + + allGranted := true + for _, p := range perms { + if !p.Granted { + allGranted = false + break + } + } + + if allGranted { + _ = h.stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusActive) + } +} + +// maybeSuspend transitions an active package to suspended +// if a required permission was revoked. +func (h *ExtPermHandler) maybeSuspend(c *gin.Context, pkgID string) { + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + return + } + if pkg.Status != models.PackageStatusActive { + return + } + + perms, err := h.stores.ExtPermissions.ListForPackage(c.Request.Context(), pkgID) + if err != nil || len(perms) == 0 { + return + } + + for _, p := range perms { + if !p.Granted { + _ = h.stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusSuspended) + return + } + } +} + +// ── Manifest Permission Parsing ────────────── + +// SyncManifestPermissions extracts the "permissions" array from a +// package manifest and declares them in the store. Returns the list +// of declared permissions. If the manifest declares permissions, +// the package status is set to pending_review. +// +// Called by AdminInstallExtension and InstallPackage handlers. +func SyncManifestPermissions(c *gin.Context, stores store.Stores, pkgID string, manifest map[string]any) []string { + if stores.ExtPermissions == nil { + return nil + } + + raw, ok := manifest["permissions"] + if !ok { + return nil + } + + arr, ok := raw.([]interface{}) + if !ok { + return nil + } + + var perms []string + for _, v := range arr { + if s, ok := v.(string); ok && models.ValidExtensionPermissions[s] { + perms = append(perms, s) + } + } + + if len(perms) == 0 { + return nil + } + + if err := stores.ExtPermissions.DeclareForPackage(c.Request.Context(), pkgID, perms); err != nil { + return nil + } + + // Package needs review before activation + _ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview) + + return perms +} diff --git a/server/handlers/extension_secrets.go b/server/handlers/extension_secrets.go new file mode 100644 index 0000000..a1ed2e0 --- /dev/null +++ b/server/handlers/extension_secrets.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ExtSecretsHandler serves extension secret management endpoints. +// v0.29.0 CS3: Admin sets key-value secrets that Starlark extensions +// can read via the secrets.get() module. +// +// Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}" +// as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."}. +type ExtSecretsHandler struct { + stores store.Stores +} + +func NewExtSecretsHandler(stores store.Stores) *ExtSecretsHandler { + return &ExtSecretsHandler{stores: stores} +} + +// GetSecrets returns the secret keys (not values) for a package. +// GET /api/v1/admin/extensions/:id/secrets +func (h *ExtSecretsHandler) GetSecrets(c *gin.Context) { + pkgID := c.Param("id") + configKey := "ext_secrets:" + pkgID + + configVal, err := h.stores.GlobalConfig.Get(c.Request.Context(), configKey) + if err != nil { + // Not found = empty secrets, not an error + c.JSON(http.StatusOK, gin.H{"data": map[string]string{}}) + return + } + + // Return only keys, not values (security: don't expose secrets in GET) + keys := make([]string, 0, len(configVal)) + for k := range configVal { + keys = append(keys, k) + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{ + "package_id": pkgID, + "keys": keys, + }}) +} + +// SetSecrets upserts secrets for a package. +// PUT /api/v1/admin/extensions/:id/secrets +// Body: {"secrets": {"api_key": "sk-...", "webhook_token": "tok-..."}} +func (h *ExtSecretsHandler) SetSecrets(c *gin.Context) { + pkgID := c.Param("id") + userID := c.GetString("user_id") + + // Verify package exists + pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) + if err != nil || pkg == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) + return + } + + var body struct { + Secrets map[string]string `json:"secrets" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + return + } + + // Store as GlobalConfig JSONMap + configKey := "ext_secrets:" + pkgID + configVal := models.JSONMap{} + for k, v := range body.Secrets { + configVal[k] = v + } + + if err := h.stores.GlobalConfig.Set(c.Request.Context(), configKey, configVal, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save secrets"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "saved", + "package_id": pkgID, + "key_count": len(body.Secrets), + }) +} + +// DeleteSecrets removes all secrets for a package. +// DELETE /api/v1/admin/extensions/:id/secrets +func (h *ExtSecretsHandler) DeleteSecrets(c *gin.Context) { + pkgID := c.Param("id") + userID := c.GetString("user_id") + + configKey := "ext_secrets:" + pkgID + if err := h.stores.GlobalConfig.Set(c.Request.Context(), configKey, models.JSONMap{}, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete secrets"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted", "package_id": pkgID}) +} diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index ee3a0dd..5b5cdeb 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -206,6 +206,10 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) { return } + // v0.29.0: Parse manifest permissions and declare them. + // If permissions are declared, package moves to pending_review. + SyncManifestPermissions(c, h.stores, pkg.ID, manifestMap) + c.JSON(201, gin.H{"data": pkg}) } diff --git a/server/handlers/files.go b/server/handlers/files.go index fde35ff..9578854 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -12,7 +12,6 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/extraction" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/storage" @@ -162,10 +161,8 @@ func (h *FileHandler) Upload(c *gin.Context) { return } - // Update storage_key in PG - database.DB.ExecContext(c.Request.Context(), - database.Q(`UPDATE files SET storage_key = $1 WHERE id = $2`), - att.StorageKey, att.ID) + // Update storage_key + h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey) // For images, mark extraction as not needed (complete immediately) if strings.HasPrefix(contentType, "image/") { @@ -463,14 +460,12 @@ func (h *FileHandler) CleanupChannelStorage(channelID string) { // verifyChannelAccess checks that the requesting user owns the channel. // Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission). func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool { - var ownerID string - err := database.DB.QueryRowContext(c.Request.Context(), - database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID) + owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return false } - if ownerID != userID { + if !owns { // Check if user is admin (admins can access any channel) role, _ := c.Get("role") if role != "admin" { @@ -516,16 +511,9 @@ func (h *FileHandler) UploadToProject(c *gin.Context) { // Verify project access (user owns or is team member; admins bypass) if c.GetString("role") != "admin" { - var exists bool - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT EXISTS( - SELECT 1 FROM projects - WHERE id = $1 AND (owner_id = $2 OR team_id IN ( - SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true - )) - ) - `), projectID, userID).Scan(&exists) - if err != nil || !exists { + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) + ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) + if err != nil || !ok { c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) return } @@ -597,16 +585,9 @@ func (h *FileHandler) ListByProject(c *gin.Context) { // Admins bypass project ownership/membership checks if c.GetString("role") != "admin" { - var exists bool - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT EXISTS( - SELECT 1 FROM projects - WHERE id = $1 AND (owner_id = $2 OR team_id IN ( - SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true - )) - ) - `), projectID, userID).Scan(&exists) - if err != nil || !exists { + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) + ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) + if err != nil || !ok { c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) return } diff --git a/server/handlers/folders.go b/server/handlers/folders.go index 9c965ad..85a1f81 100644 --- a/server/handlers/folders.go +++ b/server/handlers/folders.go @@ -2,61 +2,34 @@ package handlers // folders.go — Chat folder CRUD (v0.23.1) // -// Folders are user-scoped groupings for personal (direct) chats. -// Routes: -// GET /api/v1/folders -// POST /api/v1/folders -// PUT /api/v1/folders/:id -// DELETE /api/v1/folders/:id +// v0.29.0: Raw SQL replaced with FolderStore methods. import ( "net/http" "strings" - "time" "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" ) -type FolderHandler struct{} +type FolderHandler struct { + stores store.Stores +} -func NewFolderHandler() *FolderHandler { return &FolderHandler{} } - -type folderRow struct { - ID string `json:"id"` - Name string `json:"name"` - ParentID *string `json:"parent_id,omitempty"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +func NewFolderHandler(s store.Stores) *FolderHandler { + return &FolderHandler{stores: s} } func (h *FolderHandler) List(c *gin.Context) { userID := getUserID(c) - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT id, name, parent_id, sort_order, created_at, updated_at - FROM folders - WHERE user_id = $1 - ORDER BY sort_order, name - `), userID) + + folders, err := h.stores.Folders.List(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) return } - defer rows.Close() - - var folders []folderRow - for rows.Next() { - var f folderRow - if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil { - continue - } - folders = append(folders, f) - } - if folders == nil { - folders = []folderRow{} - } c.JSON(http.StatusOK, gin.H{"folders": folders}) } @@ -76,14 +49,12 @@ func (h *FolderHandler) Create(c *gin.Context) { return } - var f folderRow - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - INSERT INTO folders (user_id, name, sort_order) - VALUES ($1, $2, $3) - RETURNING id, name, parent_id, sort_order, created_at, updated_at - `), userID, req.Name, req.SortOrder).Scan( - &f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt) - if err != nil { + f := &models.Folder{ + UserID: userID, + Name: req.Name, + SortOrder: req.SortOrder, + } + if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"}) return } @@ -105,17 +76,12 @@ func (h *FolderHandler) Update(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) } - res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE folders - SET name = COALESCE(NULLIF($3, ''), name), - sort_order = COALESCE($4, sort_order) - WHERE id = $1 AND user_id = $2 - `), folderID, userID, req.Name, req.SortOrder) + n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"}) return } - if n, _ := res.RowsAffected(); n == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) return } @@ -127,18 +93,14 @@ func (h *FolderHandler) Delete(c *gin.Context) { folderID := c.Param("id") // Unassign chats before deleting folder - _, _ = database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2 - `), folderID, userID) + _ = h.stores.Folders.UnassignChannels(c.Request.Context(), folderID, userID) - res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - DELETE FROM folders WHERE id = $1 AND user_id = $2 - `), folderID, userID) + n, err := h.stores.Folders.Delete(c.Request.Context(), folderID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"}) return } - if n, _ := res.RowsAffected(); n == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) return } diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 696d63e..eecda50 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -24,6 +24,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" + "git.gobha.me/xcaliber/chat-switchboard/treepath" ) // ── Test Harness ──────────────────────────── @@ -128,6 +129,7 @@ func setupHarness(t *testing.T) *testHarness { } else { stores = postgres.NewStores(database.TestDB) } + treepath.Stores = &stores userCache := middleware.NewUserStatusCache() // Roles resolver (nil vault — test-fire won't work, but CRUD will) @@ -175,7 +177,7 @@ func setupHarness(t *testing.T) *testHarness { protected.GET("/teams/mine", teams.MyTeams) teamScoped := protected.Group("/teams/:teamId") - teamScoped.Use(middleware.RequireTeamAdmin()) + teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams)) { // Team members (team admin self-service) teamScoped.GET("/members", teams.ListMembers) @@ -235,7 +237,7 @@ func setupHarness(t *testing.T) *testHarness { // users bypass the DB query so these tests work on both dialects when // the caller is an admin. Non-admin member tests require Postgres. teamMemberRoutes := protected.Group("/teams/:teamId") - teamMemberRoutes.Use(middleware.RequireTeamMember()) + teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) { teamMemberTaskH := NewTaskHandler(stores) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) @@ -247,7 +249,7 @@ func setupHarness(t *testing.T) *testHarness { protected.GET("/usage", usage.PersonalUsage) // Profile / Settings - settings := NewSettingsHandler(nil) + settings := NewSettingsHandler(stores, nil) protected.GET("/profile", settings.GetProfile) protected.PUT("/profile", settings.UpdateProfile) protected.POST("/profile/password", settings.ChangePassword) @@ -270,7 +272,7 @@ func setupHarness(t *testing.T) *testHarness { protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 // Persona Groups - pgH := NewPersonaGroupHandler() + pgH := NewPersonaGroupHandler(stores) protected.GET("/persona-groups", pgH.List) protected.POST("/persona-groups", pgH.Create) protected.GET("/persona-groups/:id", pgH.Get) @@ -288,7 +290,7 @@ func setupHarness(t *testing.T) *testHarness { protected.DELETE("/notes/:id", notes.Delete) // Channels - channels := NewChannelHandler() + channels := NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) @@ -386,8 +388,8 @@ func setupHarness(t *testing.T) *testHarness { admin.POST("/personas", personas.CreateAdminPersona) admin.PUT("/personas/:id", personas.UpdateAdminPersona) admin.DELETE("/personas/:id", personas.DeleteAdminPersona) - admin.POST("/personas/:id/avatar", UploadPersonaAvatar) - admin.DELETE("/personas/:id/avatar", DeletePersonaAvatar) + admin.POST("/personas/:id/avatar", func(c *gin.Context) { UploadPersonaAvatar(stores.Personas, c) }) + admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { DeletePersonaAvatar(stores.Personas, c) }) admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 admin.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 diff --git a/server/handlers/knowledge_bases.go b/server/handlers/knowledge_bases.go index 7b72e0c..4f33f8c 100644 --- a/server/handlers/knowledge_bases.go +++ b/server/handlers/knowledge_bases.go @@ -847,6 +847,10 @@ func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID st // BuildKBHint returns a system prompt fragment listing active KBs for a // channel, or empty string if none are active. Called by the completion // handler to nudge the LLM to use kb_search. +// +// Deprecated: v0.29.0 — replaced by filters.KBInjectFilter in the +// pre-completion filter chain. Kept for backward compatibility with +// any callers outside the main completion path. Will be removed in v0.30.0. func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string { teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID) diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 2815f21..b2deb87 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -12,12 +12,13 @@ import ( capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" + "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" + "git.gobha.me/xcaliber/chat-switchboard/treepath" ) // ── Request / Response types ──────────────── @@ -50,12 +51,12 @@ type editRequest struct { } type regenerateRequest struct { - Model string `json:"model,omitempty"` - PersonaID string `json:"persona_id,omitempty"` - ProviderConfigID string `json:"provider_config_id,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - DisabledTools []string `json:"disabled_tools,omitempty"` + Model string `json:"model,omitempty"` + PersonaID string `json:"persona_id,omitempty"` + ProviderConfigID string `json:"provider_config_id,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + DisabledTools []string `json:"disabled_tools,omitempty"` } type cursorRequest struct { @@ -77,9 +78,6 @@ func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *even // ── List Messages (flat, all branches) ────── // GET /channels/:id/messages -// -// Returns all live messages across all branches (useful for admin/debug). -// Frontend should prefer /channels/:id/path for rendering. func (h *MessageHandler) ListMessages(c *gin.Context) { page, perPage, offset := parsePagination(c) @@ -93,62 +91,37 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) return } - } else if !userOwnsChannel(c, channelID, userID) { + } else if !userCanAccessChannel(c, h.stores, channelID, userID) { return } - var total int - err := database.DB.QueryRow( - database.Q(`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`), - channelID, - ).Scan(&total) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"}) - return - } - - rows, err := database.DB.Query(database.Q(` - SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, - m.sibling_index, m.participant_type, m.participant_id, - CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) - WHEN m.participant_type = 'persona' THEN p.name - ELSE NULL END AS sender_name, - CASE WHEN m.participant_type = 'user' THEN u.avatar_url - WHEN m.participant_type = 'persona' THEN p.avatar - ELSE NULL END AS sender_avatar, - m.created_at - FROM messages m - LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text - LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text - WHERE m.channel_id = $1 AND m.deleted_at IS NULL - ORDER BY m.created_at ASC - LIMIT $2 OFFSET $3 - `), channelID, perPage, offset) + ctx := c.Request.Context() + msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) return } - defer rows.Close() - messages := make([]messageResponse, 0) - for rows.Next() { - var msg messageResponse - err := rows.Scan( - &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID, - &msg.SenderName, &msg.SenderAvatar, - &msg.CreatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"}) - return - } - messages = append(messages, msg) + messages := make([]messageResponse, 0, len(msgs)) + for _, m := range msgs { + messages = append(messages, messageResponse{ + ID: m.ID, + ChannelID: m.ChannelID, + Role: m.Role, + Content: m.Content, + Model: m.Model, + TokensUsed: m.TokensUsed, + ParentID: m.ParentID, + SiblingIndex: m.SiblingIndex, + ParticipantType: m.ParticipantType, + ParticipantID: m.ParticipantID, + SenderName: m.SenderName, + SenderAvatar: m.SenderAvatar, + CreatedAt: m.CreatedAt, + }) } - rows.Close() // release connection before sibling queries - // Enrich with sibling counts (requires DB access, must happen after rows are closed) + // Enrich with sibling counts for i := range messages { messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID) } @@ -164,15 +137,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { // ── Active Path ───────────────────────────── // GET /channels/:id/path -// -// Returns the active branch from root → leaf with sibling metadata -// at each node. This is what the frontend renders. func (h *MessageHandler) GetActivePath(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - if !userOwnsChannel(c, channelID, userID) { + if !userCanAccessChannel(c, h.stores, channelID, userID) { return } @@ -205,12 +175,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) return } - } else if !userOwnsChannel(c, channelID, userID) { + } else if !userCanAccessChannel(c, h.stores, channelID, userID) { return } // Use cursor for parent, compute sibling_index - // Sessions use the same cursor logic (empty userID = channel-level latest) effectiveUserID := userID if isSessionAuth(c) { effectiveUserID = c.GetString("session_id") @@ -231,63 +200,43 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) { } } - var msg messageResponse - if database.IsSQLite() { - newID := store.NewID() - _, err := database.DB.Exec(` - INSERT INTO messages (id, channel_id, role, content, model, parent_id, - participant_type, participant_id, sibling_index) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, newID, channelID, req.Role, req.Content, req.Model, parentID, - participantType, participantID, siblingIdx) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) - return - } - err = database.DB.QueryRow(` - SELECT id, channel_id, role, content, model, tokens_used, parent_id, - sibling_index, created_at - FROM messages WHERE id = ?`, newID).Scan( - &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.CreatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created message"}) - return - } - } else { - err := database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, model, parent_id, - participant_type, participant_id, sibling_index) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, channel_id, role, content, model, tokens_used, parent_id, - sibling_index, created_at - `, channelID, req.Role, req.Content, req.Model, parentID, - participantType, participantID, siblingIdx, - ).Scan( - &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.CreatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) - return - } + msg := &models.Message{ + ChannelID: channelID, + Role: req.Role, + Content: req.Content, + Model: req.Model, + ParentID: parentID, + SiblingIndex: siblingIdx, + ParticipantType: participantType, + ParticipantID: participantID, + ToolCalls: models.JSONMap{}, + Metadata: models.JSONMap{}, } - msg.SiblingCount = getSiblingCount(channelID, msg.ParentID) - _ = updateCursor(channelID, userID, msg.ID) - _, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID) + if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) + return + } - c.JSON(http.StatusCreated, msg) + resp := messageResponse{ + ID: msg.ID, + ChannelID: msg.ChannelID, + Role: msg.Role, + Content: msg.Content, + SiblingIndex: msg.SiblingIndex, + CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"), + } + if msg.Model != "" { + resp.Model = &msg.Model + } + resp.ParentID = msg.ParentID + resp.SiblingCount = getSiblingCount(channelID, msg.ParentID) + + c.JSON(http.StatusCreated, resp) } // ── Edit Message (create sibling) ─────────── // POST /channels/:id/messages/:msgId/edit -// -// Creates a new user message as a sibling of the target (same parent_id). -// Does NOT auto-trigger completion — the frontend sends a separate request. func (h *MessageHandler) EditMessage(c *gin.Context) { var req editRequest @@ -300,18 +249,14 @@ func (h *MessageHandler) EditMessage(c *gin.Context) { channelID := c.Param("id") messageID := c.Param("msgId") - if !userOwnsChannel(c, channelID, userID) { + if !userCanAccessChannel(c, h.stores, channelID, userID) { return } - // Load target — must exist, belong to channel, be a user message - var targetParentID *string - var targetRole string - err := database.DB.QueryRow(database.Q(` - SELECT parent_id, role FROM messages - WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL - `), messageID, channelID).Scan(&targetParentID, &targetRole) + ctx := c.Request.Context() + // Load target — must exist, belong to channel, be a user message + targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) return @@ -328,65 +273,39 @@ func (h *MessageHandler) EditMessage(c *gin.Context) { // Create sibling: same parent_id as the target siblingIdx := nextSiblingIndex(channelID, targetParentID) - var msg messageResponse - if database.IsSQLite() { - newID := store.NewID() - _, err = database.DB.Exec(` - INSERT INTO messages (id, channel_id, role, content, parent_id, - participant_type, participant_id, sibling_index) - VALUES (?, ?, 'user', ?, ?, 'user', ?, ?) - `, newID, channelID, req.Content, targetParentID, userID, siblingIdx) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"}) - return - } - err = database.DB.QueryRow(` - SELECT id, channel_id, role, content, model, tokens_used, parent_id, - sibling_index, created_at - FROM messages WHERE id = ?`, newID).Scan( - &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.CreatedAt, - ) - } else { - err = database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, parent_id, - participant_type, participant_id, sibling_index) - VALUES ($1, 'user', $2, $3, 'user', $4, $5) - RETURNING id, channel_id, role, content, model, tokens_used, parent_id, - sibling_index, created_at - `, channelID, req.Content, targetParentID, userID, siblingIdx, - ).Scan( - &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.CreatedAt, - ) + msg := &models.Message{ + ChannelID: channelID, + Role: "user", + Content: req.Content, + ParentID: targetParentID, + SiblingIndex: siblingIdx, + ParticipantType: "user", + ParticipantID: userID, + ToolCalls: models.JSONMap{}, + Metadata: models.JSONMap{}, } - if err != nil { + + if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"}) return } - msg.SiblingCount = getSiblingCount(channelID, msg.ParentID) - // Cursor now points to the new sibling (it's a leaf — no children yet) - _ = updateCursor(channelID, userID, msg.ID) - _, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID) + resp := messageResponse{ + ID: msg.ID, + ChannelID: msg.ChannelID, + Role: msg.Role, + Content: msg.Content, + ParentID: msg.ParentID, + SiblingIndex: msg.SiblingIndex, + SiblingCount: getSiblingCount(channelID, msg.ParentID), + CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"), + } - c.JSON(http.StatusCreated, msg) + c.JSON(http.StatusCreated, resp) } // ── Regenerate / Complete ────────────────────── // POST /channels/:id/messages/:msgId/regenerate -// -// Two modes depending on target role: -// - assistant message → creates a NEW assistant sibling (same parent_id). -// Context is root → target's parent. ("Give me a different response.") -// - user message → creates a child assistant response. -// Context is root → target. ("Respond to this message.") -// -// The second mode is used after editing: the edit endpoint creates a -// sibling user message, then the frontend calls regenerate on it to -// get an assistant response without duplicating the user message. func (h *MessageHandler) Regenerate(c *gin.Context) { var req regenerateRequest @@ -396,18 +315,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { channelID := c.Param("id") messageID := c.Param("msgId") - if !userOwnsChannel(c, channelID, userID) { + if !userCanAccessChannel(c, h.stores, channelID, userID) { return } - // Load target message - var targetParentID *string - var targetRole string - err := database.DB.QueryRow(database.Q(` - SELECT parent_id, role FROM messages - WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL - `), messageID, channelID).Scan(&targetParentID, &targetRole) + ctx := c.Request.Context() + // Load target message + targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) return @@ -421,13 +336,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { return } - // Determine context path and new message's parent based on target role: - // - // assistant target: context = root → target's parent (exclude target) - // new parent = target's parent (sibling of target) - // - // user target: context = root → target (include target) - // new parent = target itself (child of target) + // Determine context path and new message's parent based on target role var contextPath []PathMessage var newParentID *string @@ -486,15 +395,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { // Fallback: channel's stored model if model == "" { - var channelModel *string - _ = database.DB.QueryRow(database.Q(`SELECT model FROM channels WHERE id = $1`), channelID).Scan(&channelModel) + channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID) if channelModel != nil { model = *channelModel } } providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{ - Model: model, + Model: model, ProviderConfigID: providerConfigID, }) if err != nil { @@ -514,8 +422,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { if personaSystemPrompt != "" { llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt}) } else { - var systemPrompt *string - _ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt) + systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID) if systemPrompt != nil && *systemPrompt != "" { llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt}) } @@ -544,14 +451,10 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { } // Attach tool definitions (same as normal completion) - workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) + workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID) - // Query channel metadata for tool context and execution context - var chType string - var chTeamID *string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1 - `), channelID).Scan(&chType, &chTeamID) + // Query channel metadata for tool context + chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID) msgTeamID := "" if chTeamID != nil { msgTeamID = *chTeamID @@ -559,7 +462,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - // v0.25.0: Build ToolContext with channel metadata tctx := tools.ToolContext{ ChannelType: chType, WorkspaceID: workspaceID, @@ -567,12 +469,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { PersonaID: personaID, IsVisitor: isSessionAuth(c), } - provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID) + provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID) } - // ── Stream the response (shared loop handles tools, reasoning, SSE) ── + // ── Stream the response ── - // Apply provider-specific request hooks (v0.22.1) if hooks := providers.GetHooks(providerID); hooks != nil { hooks.PreRequest(providerCfg, &provReq) } @@ -583,47 +484,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { if result.Content != "" { siblingIdx := nextSiblingIndex(channelID, newParentID) - // Match persistMessage pattern: nil interface{} → SQL NULL, - // non-nil string → valid JSONB. A nil json.RawMessage ([]byte) - // is sent by pq as empty bytes, not NULL, causing "invalid input - // syntax for type json". - var tcVal interface{} + var toolCalls models.JSONMap if len(result.ToolActivity) > 0 { b, _ := json.Marshal(result.ToolActivity) - tcVal = string(b) + _ = json.Unmarshal(b, &toolCalls) + } + if toolCalls == nil { + toolCalls = models.JSONMap{} } - var newID string - if database.IsSQLite() { - newID = store.NewID() - _, err := database.DB.Exec(` - INSERT INTO messages (id, channel_id, role, content, model, tool_calls, - parent_id, participant_type, participant_id, sibling_index) - VALUES (?, ?, 'assistant', ?, ?, ?, ?, 'model', ?, ?) - `, newID, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx) - if err != nil { - log.Printf("Failed to persist regenerated message: %v", err) - newID = "" - } + msg := &models.Message{ + ChannelID: channelID, + Role: "assistant", + Content: result.Content, + Model: model, + ToolCalls: toolCalls, + Metadata: models.JSONMap{}, + ParentID: newParentID, + SiblingIndex: siblingIdx, + ParticipantType: "model", + ParticipantID: model, + } + + if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil { + log.Printf("Failed to persist regenerated message: %v", err) } else { - err := database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, model, tool_calls, - parent_id, participant_type, participant_id, sibling_index) - VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7) - RETURNING id - `, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID) - if err != nil { - log.Printf("Failed to persist regenerated message: %v", err) - newID = "" - } - } - - if newID != "" { - _ = updateCursor(channelID, userID, newID) - flusher, _ := c.Writer.(http.Flusher) msgJSON, _ := json.Marshal(gin.H{ - "id": newID, + "id": msg.ID, "parent_id": newParentID, "sibling_index": siblingIdx, "sibling_count": getSiblingCount(channelID, newParentID), @@ -633,8 +521,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { flusher.Flush() } } - - _, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID) } // Log usage for regeneration @@ -645,9 +531,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { // ── Switch Branch (update cursor) ─────────── // PUT /channels/:id/cursor -// -// Navigates to a different branch. If the target message has children, -// follows the first-child chain to find the leaf. func (h *MessageHandler) UpdateCursor(c *gin.Context) { var req cursorRequest @@ -659,17 +542,15 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - if !userOwnsChannel(c, channelID, userID) { + if !userCanAccessChannel(c, h.stores, channelID, userID) { return } - // Verify the target message belongs to this channel - var msgChannelID string - err := database.DB.QueryRow(database.Q(` - SELECT channel_id FROM messages - WHERE id = $1 AND deleted_at IS NULL - `), req.ActiveLeafID).Scan(&msgChannelID) + ctx := c.Request.Context() + // Verify the target message belongs to this channel and is not deleted. + // GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL + _, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) return @@ -678,10 +559,6 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"}) return } - if msgChannelID != channelID { - c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"}) - return - } // Walk down to the leaf from the target leafID, err := findLeafFromMessage(req.ActiveLeafID) @@ -707,16 +584,13 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) { // ── List Siblings ─────────────────────────── // GET /channels/:id/messages/:msgId/siblings -// -// Returns all siblings of a message (messages sharing the same parent_id). -// Used by the branch navigator to populate the arrow navigation. func (h *MessageHandler) ListSiblings(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") messageID := c.Param("msgId") - if !userOwnsChannel(c, channelID, userID) { + if !userCanAccessChannel(c, h.stores, channelID, userID) { return } @@ -733,36 +607,34 @@ func (h *MessageHandler) ListSiblings(c *gin.Context) { }) } -// ── Ownership Check ───────────────────────── +// ── Ownership / Access Check ───────────────── -func userOwnsChannel(c *gin.Context, channelID, userID string) bool { - var ownerID string - err := database.DB.QueryRow( - database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID, - ).Scan(&ownerID) - - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return false - } +// userCanAccessChannel verifies channel ownership or participation, +// writing an error response if denied. Returns true if access is allowed. +func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool { + ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"}) return false } - if ownerID == userID { - return true + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return false } - - // v0.23.2: Also allow if user is a participant (multi-user DMs, channels) - var exists bool - _ = database.DB.QueryRow( - database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`), - channelID, userID, - ).Scan(&exists) - if exists { - return true - } - - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return false + return true +} + +// userOwnsChannel is the legacy wrapper used by other handlers (completion.go). +// Delegates to userCanAccessChannel using the treepath global stores. +func userOwnsChannel(c *gin.Context, channelID, userID string) bool { + ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"}) + return false + } + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return false + } + return true } diff --git a/server/handlers/notes.go b/server/handlers/notes.go index ed033cd..5b8211c 100644 --- a/server/handlers/notes.go +++ b/server/handlers/notes.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notelinks" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -405,69 +404,30 @@ func (h *NoteHandler) Search(c *gin.Context) { limit = 20 } - // Use Postgres full-text search when available, LIKE fallback on SQLite - if database.IsPostgres() { - h.searchPostgres(c, userID, q, limit) - return - } - - // SQLite: use store's LIKE-based search - notes, _, err := h.stores.Notes.Search(c.Request.Context(), userID, q, store.ListOptions{Limit: limit}) + // SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE. + storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } - results := make([]searchResult, 0, len(notes)) - for _, n := range notes { - results = append(results, searchResult{ - noteListItem: toNoteListItem(n), - Rank: 1.0, - Headline: "", - }) - } - - c.JSON(http.StatusOK, gin.H{ - "data": results, - "query": q, - "total": len(results), - }) -} - -// searchPostgres uses Postgres full-text search with ts_rank and ts_headline. -func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int) { - rows, err := database.DB.Query(` - SELECT id, title, folder_path, tags, LEFT(content, 200), - created_at::text, updated_at::text, - ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, - ts_headline('english', content, plainto_tsquery('english', $2), - 'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline - FROM notes - WHERE user_id = $1 - AND search_vector @@ plainto_tsquery('english', $2) - ORDER BY rank DESC - LIMIT $3 - `, userID, q, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) - return - } - defer rows.Close() - - // Import pq at call site to avoid pulling it in for SQLite builds - results := make([]searchResult, 0) - for rows.Next() { - var r searchResult - var tags []string - if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, pgScanStringArray(&tags), &r.Preview, - &r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil { - continue - } + results := make([]searchResult, 0, len(storeResults)) + for _, sr := range storeResults { + tags := sr.Tags if tags == nil { tags = []string{} } - r.Tags = tags - results = append(results, r) + results = append(results, searchResult{ + noteListItem: noteListItem{ + ID: sr.ID, + Title: sr.Title, + FolderPath: sr.FolderPath, + Tags: tags, + Preview: sr.Excerpt, + }, + Rank: sr.Rank, + Headline: sr.Headline, + }) } c.JSON(http.StatusOK, gin.H{ @@ -483,29 +443,19 @@ func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int func (h *NoteHandler) ListFolders(c *gin.Context) { userID := getUserID(c) - rows, err := database.DB.Query(database.Q(` - SELECT DISTINCT folder_path, COUNT(*) AS count - FROM notes WHERE user_id = $1 - GROUP BY folder_path - ORDER BY folder_path - `), userID) + storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) return } - defer rows.Close() type folderInfo struct { Path string `json:"path"` Count int `json:"count"` } - folders := make([]folderInfo, 0) - for rows.Next() { - var f folderInfo - if err := rows.Scan(&f.Path, &f.Count); err != nil { - continue - } - folders = append(folders, f) + folders := make([]folderInfo, 0, len(storeFolders)) + for _, f := range storeFolders { + folders = append(folders, folderInfo{Path: f.Path, Count: f.Count}) } c.JSON(http.StatusOK, gin.H{"folders": folders}) diff --git a/server/handlers/participants.go b/server/handlers/participants.go index 7b2b3ee..3dd07a7 100644 --- a/server/handlers/participants.go +++ b/server/handlers/participants.go @@ -6,7 +6,6 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -260,17 +259,10 @@ func (h *ParticipantHandler) Remove(c *gin.Context) { } // v0.23.2: DM guard — cannot drop below 2 human participants - var channelType string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1 - `), channelID).Scan(&channelType) + channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID) if channelType == "dm" && p.ParticipantType == "user" { - var userCount int - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COUNT(*) FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID).Scan(&userCount) + userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user") if userCount <= 2 { c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"}) return @@ -279,11 +271,7 @@ func (h *ParticipantHandler) Remove(c *gin.Context) { // v0.23.2: Group guard — cannot remove the last persona if channelType == "group" && p.ParticipantType == "persona" { - var personaCount int - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COUNT(*) FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'persona' - `), channelID).Scan(&personaCount) + personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona") if personaCount <= 1 { c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"}) return diff --git a/server/handlers/persona_groups.go b/server/handlers/persona_groups.go index df3acb2..a02bce8 100644 --- a/server/handlers/persona_groups.go +++ b/server/handlers/persona_groups.go @@ -2,64 +2,40 @@ package handlers // persona_groups.go — Persona group (roster template) CRUD (v0.23.2) // -// Persona groups are saved collections of personas used as templates -// for creating group chats. Each member has an is_leader flag. -// -// Routes: -// GET /api/v1/persona-groups -// POST /api/v1/persona-groups -// GET /api/v1/persona-groups/:id -// PUT /api/v1/persona-groups/:id -// DELETE /api/v1/persona-groups/:id -// POST /api/v1/persona-groups/:id/members -// DELETE /api/v1/persona-groups/:id/members/:memberId +// v0.29.0: Raw SQL replaced with PersonaGroupStore methods. import ( - "database/sql" "net/http" "strings" "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) -type PersonaGroupHandler struct{} +type PersonaGroupHandler struct { + stores store.Stores +} -func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} } +func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler { + return &PersonaGroupHandler{stores: s} +} // ── List ──────────────────────────────────────── func (h *PersonaGroupHandler) List(c *gin.Context) { userID := getUserID(c) - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at - FROM persona_groups - WHERE owner_id = $1 - ORDER BY name - `), userID) + groups, err := h.stores.PersonaGroups.List(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"}) return } - defer rows.Close() - groups := []models.PersonaGroup{} - for rows.Next() { - var g models.PersonaGroup - if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)); err != nil { - continue - } - groups = append(groups, g) - } - - // Load members for each group for i := range groups { - groups[i].Members = h.loadMembers(c, groups[i].ID) + members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID) + groups[i].Members = members } c.JSON(http.StatusOK, gin.H{"data": groups}) @@ -71,25 +47,22 @@ func (h *PersonaGroupHandler) Get(c *gin.Context) { userID := getUserID(c) id := c.Param("id") - var g models.PersonaGroup - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at - FROM persona_groups WHERE id = $1 AND owner_id = $2 - `), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) - return - } + g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"}) return } - - g.Members = h.loadMembers(c, g.ID) - if g.Members == nil { - g.Members = []models.PersonaGroupMember{} + if g == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return } + + members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID) + if members == nil { + members = []models.PersonaGroupMember{} + } + g.Members = members + c.JSON(http.StatusOK, g) } @@ -107,34 +80,16 @@ func (h *PersonaGroupHandler) Create(c *gin.Context) { return } - var g models.PersonaGroup - if database.IsSQLite() { - id := store.NewID() - _, err := database.DB.ExecContext(c.Request.Context(), ` - INSERT INTO persona_groups (id, name, description, owner_id, scope) - VALUES (?, ?, ?, ?, 'personal') - `, id, strings.TrimSpace(req.Name), req.Description, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) - return - } - database.DB.QueryRowContext(c.Request.Context(), ` - SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at - FROM persona_groups WHERE id = ? - `, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) - } else { - err := database.DB.QueryRowContext(c.Request.Context(), ` - INSERT INTO persona_groups (name, description, owner_id, scope) - VALUES ($1, $2, $3, 'personal') - RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at - `, strings.TrimSpace(req.Name), req.Description, userID).Scan( - &g.ID, &g.Name, &g.Description, &g.OwnerID, - &g.Scope, &g.TeamID, database.ST(&g.CreatedAt), database.ST(&g.UpdatedAt)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) - return - } + g := &models.PersonaGroup{ + Name: strings.TrimSpace(req.Name), + Description: req.Description, + OwnerID: userID, + Scope: "personal", + } + + if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) + return } g.Members = []models.PersonaGroupMember{} @@ -157,11 +112,8 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) { } // Verify ownership - var ownerID string - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT owner_id FROM persona_groups WHERE id = $1 - `), id).Scan(&ownerID) - if err == sql.ErrNoRows { + ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id) + if err != nil || ownerID == "" { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return } @@ -170,15 +122,15 @@ func (h *PersonaGroupHandler) Update(c *gin.Context) { return } + fields := map[string]interface{}{} if req.Name != nil { - database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2 - `), strings.TrimSpace(*req.Name), id) + fields["name"] = strings.TrimSpace(*req.Name) } if req.Description != nil { - database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2 - `), *req.Description, id) + fields["description"] = *req.Description + } + if len(fields) > 0 { + _ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields) } c.JSON(http.StatusOK, gin.H{"ok": true}) @@ -190,14 +142,11 @@ func (h *PersonaGroupHandler) Delete(c *gin.Context) { userID := getUserID(c) id := c.Param("id") - result, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2 - `), id, userID) + n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), id, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) return } - n, _ := result.RowsAffected() if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) return @@ -221,37 +170,13 @@ func (h *PersonaGroupHandler) AddMember(c *gin.Context) { } // Verify ownership - var ownerID string - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT owner_id FROM persona_groups WHERE id = $1 - `), groupID).Scan(&ownerID) + ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID) if err != nil || ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) return } - // If setting as leader, clear existing leader - if req.IsLeader { - database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE persona_group_members SET is_leader = false WHERE group_id = $1 - `), groupID) - } - - if database.IsSQLite() { - id := store.NewID() - _, err = database.DB.ExecContext(c.Request.Context(), ` - INSERT INTO persona_group_members (id, group_id, persona_id, is_leader) - VALUES (?, ?, ?, ?) - ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader - `, id, groupID, req.PersonaID, req.IsLeader) - } else { - _, err = database.DB.ExecContext(c.Request.Context(), ` - INSERT INTO persona_group_members (group_id, persona_id, is_leader) - VALUES ($1, $2, $3) - ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader - `, groupID, req.PersonaID, req.IsLeader) - } - if err != nil { + if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) return } @@ -266,49 +191,12 @@ func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) { groupID := c.Param("id") memberID := c.Param("memberId") - // Verify ownership - var ownerID string - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT owner_id FROM persona_groups WHERE id = $1 - `), groupID).Scan(&ownerID) + ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID) if err != nil || ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) return } - database.DB.ExecContext(c.Request.Context(), database.Q(` - DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2 - `), memberID, groupID) - + _ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), memberID, groupID) c.JSON(http.StatusOK, gin.H{"ok": true}) } - -// ── Helpers ───────────────────────────────────── - -func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember { - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, - COALESCE(p.name, '') AS persona_name, - COALESCE(p.handle, '') AS persona_handle, - COALESCE(p.avatar, '') AS persona_avatar - FROM persona_group_members pgm - LEFT JOIN personas p ON p.id = pgm.persona_id - WHERE pgm.group_id = $1 - ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id - `), groupID) - if err != nil { - return []models.PersonaGroupMember{} - } - defer rows.Close() - - members := []models.PersonaGroupMember{} - for rows.Next() { - var m models.PersonaGroupMember - if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, - &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { - continue - } - members = append(members, m) - } - return members -} diff --git a/server/handlers/personas.go b/server/handlers/personas.go index d1b3772..3020e41 100644 --- a/server/handlers/personas.go +++ b/server/handlers/personas.go @@ -450,7 +450,7 @@ func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) { } // Delegate to the shared avatar handler - UploadPersonaAvatar(c) + UploadPersonaAvatar(h.stores.Personas, c) } // DeleteUserPersonaAvatar deletes an avatar for a personal persona, @@ -471,7 +471,7 @@ func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) { } // Delegate to the shared avatar handler - DeletePersonaAvatar(c) + DeletePersonaAvatar(h.stores.Personas, c) } // ── Team-Scoped Helpers ───────────────────── @@ -556,7 +556,7 @@ func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } - UploadPersonaAvatar(c) + UploadPersonaAvatar(h.stores.Personas, c) } // DeleteTeamPersonaAvatar deletes an avatar for a team persona, @@ -565,5 +565,5 @@ func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) { if p := h.requireTeamPersona(c); p == nil { return } - DeletePersonaAvatar(c) + DeletePersonaAvatar(h.stores.Personas, c) } diff --git a/server/handlers/presence.go b/server/handlers/presence.go index 0e51b7b..994538c 100644 --- a/server/handlers/presence.go +++ b/server/handlers/presence.go @@ -5,6 +5,8 @@ package handlers // Clients POST /api/v1/presence/heartbeat every 30s while active. // GET /api/v1/presence?users=id1,id2 returns current status. // Online = last_seen within 90s. +// +// v0.29.0: Raw SQL replaced with PresenceStore + UserStore methods. import ( "net/http" @@ -13,21 +15,23 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) const presenceOnlineThreshold = 90 * time.Second +type PresenceHandler struct { + stores store.Stores +} + +func NewPresenceHandler(s store.Stores) *PresenceHandler { + return &PresenceHandler{stores: s} +} + // PresenceHeartbeat upserts the calling user's last_seen timestamp. -func PresenceHeartbeat(c *gin.Context) { +func (h *PresenceHandler) Heartbeat(c *gin.Context) { userID := getUserID(c) - _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - INSERT INTO user_presence (user_id, last_seen, status) - VALUES ($1, NOW(), 'online') - ON CONFLICT (user_id) DO UPDATE - SET last_seen = NOW(), status = 'online' - `), userID) - if err != nil { + if err := h.stores.Presence.Heartbeat(c.Request.Context(), userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"}) return } @@ -36,7 +40,7 @@ func PresenceHeartbeat(c *gin.Context) { // PresenceQuery returns online/offline status for a list of user IDs. // Query param: ?users=uuid1,uuid2,... -func PresenceQuery(c *gin.Context) { +func (h *PresenceHandler) Query(c *gin.Context) { raw := c.Query("users") if raw == "" { c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}}) @@ -47,71 +51,34 @@ func PresenceQuery(c *gin.Context) { ids = ids[:100] } - threshold := time.Now().Add(-presenceOnlineThreshold) - - result := make(map[string]string, len(ids)) + // Trim whitespace + cleaned := make([]string, 0, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) - if id == "" { - continue - } - var lastSeen time.Time - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT last_seen FROM user_presence WHERE user_id = $1 - `), id).Scan(&lastSeen) - if err != nil || lastSeen.Before(threshold) { - result[id] = "offline" - } else { - result[id] = "online" + if id != "" { + cleaned = append(cleaned, id) } } + + threshold := time.Now().Add(-presenceOnlineThreshold) + result, err := h.stores.Presence.GetStatuses(c.Request.Context(), cleaned, threshold) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "presence query failed"}) + return + } c.JSON(http.StatusOK, gin.H{"presence": result}) } // SearchUsers returns a lightweight list of approved users matching a query. -// GET /api/v1/users/search?q=alice — matches against username and display_name. -// Returns at most 20 results. Excludes the calling user. -func SearchUsers(c *gin.Context) { +// GET /api/v1/users/search?q=alice +func (h *PresenceHandler) SearchUsers(c *gin.Context) { userID := getUserID(c) q := strings.TrimSpace(c.Query("q")) - query := database.Q(` - SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle - FROM users - WHERE is_active = true AND id != $1 - `) - args := []interface{}{userID} - - if q != "" { - query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`) - pattern := "%" + strings.ToLower(q) + "%" - args = append(args, pattern, pattern, pattern) - } - - query += ` ORDER BY username LIMIT 20` - - rows, err := database.DB.QueryContext(c.Request.Context(), query, args...) + results, err := h.stores.Users.SearchActive(c.Request.Context(), userID, q) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } - defer rows.Close() - - type userResult struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Handle string `json:"handle"` - } - - results := []userResult{} - for rows.Next() { - var u userResult - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil { - continue - } - results = append(results, u) - } - c.JSON(http.StatusOK, gin.H{"users": results}) } diff --git a/server/handlers/profile_test.go b/server/handlers/profile_test.go index e538367..a7822cf 100644 --- a/server/handlers/profile_test.go +++ b/server/handlers/profile_test.go @@ -47,7 +47,7 @@ func setupProfileHarness(t *testing.T) *profileHarness { protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - settings := NewSettingsHandler(nil) + settings := NewSettingsHandler(stores, nil) protected.GET("/profile", settings.GetProfile) protected.PUT("/profile", settings.UpdateProfile) protected.POST("/profile/password", settings.ChangePassword) diff --git a/server/handlers/projects.go b/server/handlers/projects.go index f690fd7..2261878 100644 --- a/server/handlers/projects.go +++ b/server/handlers/projects.go @@ -6,7 +6,6 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -381,24 +380,11 @@ func (h *ProjectHandler) AdminList(c *gin.Context) { ctx := c.Request.Context() includeArchived := c.Query("include_archived") == "true" - // Admin sees everything — query with no user scope - rows, err := database.DB.QueryContext(ctx, database.Q(` - SELECT p.id, p.name, p.description, p.scope, - p.owner_id, p.team_id, p.is_archived, - p.created_at, p.updated_at, - (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), - (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), - u.username - FROM projects p - LEFT JOIN users u ON u.id = p.owner_id - WHERE ($1 OR p.is_archived = false) - ORDER BY p.updated_at DESC`), includeArchived) + projects, err := h.stores.Projects.AdminList(ctx, includeArchived) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) return } - defer rows.Close() type adminProject struct { ID string `json:"id"` @@ -416,25 +402,25 @@ func (h *ProjectHandler) AdminList(c *gin.Context) { OwnerName string `json:"owner_name"` } - var projects []adminProject - for rows.Next() { - var p adminProject - if err := rows.Scan( - &p.ID, &p.Name, &p.Description, &p.Scope, - &p.OwnerID, &p.TeamID, &p.IsArchived, - &p.CreatedAt, &p.UpdatedAt, - &p.ChannelCount, &p.KBCount, &p.NoteCount, - &p.OwnerName, - ); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "scan error"}) - return - } - projects = append(projects, p) + result := make([]adminProject, 0, len(projects)) + for _, p := range projects { + result = append(result, adminProject{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + Scope: p.Scope, + OwnerID: p.OwnerID, + TeamID: p.TeamID, + IsArchived: p.IsArchived, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), + ChannelCount: p.ChannelCount, + KBCount: p.KBCount, + NoteCount: p.NoteCount, + OwnerName: p.OwnerName, + }) } - if projects == nil { - projects = []adminProject{} - } - c.JSON(http.StatusOK, gin.H{"data": projects}) + c.JSON(http.StatusOK, gin.H{"data": result}) } // ── Auth Helper ───────────────────────────── diff --git a/server/handlers/resolve.go b/server/handlers/resolve.go index 3ccf704..0824155 100644 --- a/server/handlers/resolve.go +++ b/server/handlers/resolve.go @@ -6,6 +6,8 @@ // // The CompletionHandler methods (resolveConfig, buildToolDefs) remain as // thin wrappers for backward compat — existing callers are unchanged. +// +// v0.29.0-cs7a: Replaced raw SQL with store methods. package handlers import ( @@ -16,7 +18,6 @@ import ( "log" "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" @@ -41,17 +42,16 @@ type ProviderResolution struct { // // The vault is used to decrypt API keys. Pass nil for unencrypted fallback. func ResolveProviderConfig( + stores store.Stores, vault *crypto.KeyResolver, userID, channelID, providerConfigID, modelID string, ) (ProviderResolution, error) { + ctx := context.Background() configID := providerConfigID // 2. Config from channel if configID == "" && channelID != "" { - var channelConfigID *string - err := database.DB.QueryRow( - database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID, - ).Scan(&channelConfigID) + channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID) if err == nil && channelConfigID != nil { configID = *channelConfigID } @@ -59,47 +59,15 @@ func ResolveProviderConfig( // 3. User's first active config (personal first, then global — excludes team providers) if configID == "" { - err := database.DB.QueryRow(database.Q(` - SELECT id FROM provider_configs - WHERE is_active = true AND ( - (scope = 'personal' AND owner_id = $1) - OR scope = 'global' - ) - ORDER BY scope ASC, created_at ASC - LIMIT 1 - `), userID).Scan(&configID) + var err error + configID, err = stores.Providers.FindFirstForUser(ctx, userID) if err != nil { return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs") } } // Load the config — allow personal, global, OR team configs the user belongs to - var providerID, endpoint string - var providerScope string - var modelDefault *string - var apiKeyEnc, keyNonce []byte - var keyScope string - var customHeadersJSON, providerSettingsJSON []byte - var proxyMode string - var proxyURL *string - - // $2/userID appears twice in the query; Postgres reuses positional params, - // SQLite needs each ? bound separately. - configArgs := []interface{}{configID, userID} - if database.IsSQLite() { - configArgs = append(configArgs, userID) - } - err := database.DB.QueryRow(database.Q(` - SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope, - model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url - FROM provider_configs - WHERE id = $1 AND is_active = true - AND (scope = 'global' - OR (scope = 'personal' AND owner_id = $2) - OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) - `), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope, - &modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL) - + cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID) if err == sql.ErrNoRows { return ProviderResolution{}, fmt.Errorf("API config not found or not accessible") } @@ -109,8 +77,8 @@ func ResolveProviderConfig( // Resolve model: explicit > config default model := modelID - if model == "" && modelDefault != nil { - model = *modelDefault + if model == "" && cfg.ModelDefault != "" { + model = cfg.ModelDefault } if model == "" { return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config") @@ -118,10 +86,10 @@ func ResolveProviderConfig( // Decrypt API key using the appropriate tier key := "" - if len(apiKeyEnc) > 0 { + if len(cfg.APIKeyEnc) > 0 { if vault != nil { var err error - key, err = vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID) + key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID) if err != nil { if err == crypto.ErrVaultLocked { return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again") @@ -130,40 +98,47 @@ func ResolveProviderConfig( } } else { // No vault — key stored as raw bytes (unencrypted fallback) - key = string(apiKeyEnc) + key = string(cfg.APIKeyEnc) } } // Parse custom headers customHeaders := make(map[string]string) - if customHeadersJSON != nil { - _ = json.Unmarshal(customHeadersJSON, &customHeaders) + if cfg.Headers != nil { + b, _ := json.Marshal(cfg.Headers) + _ = json.Unmarshal(b, &customHeaders) } // Parse provider-specific settings providerSettings := make(map[string]interface{}) - if providerSettingsJSON != nil { - _ = json.Unmarshal(providerSettingsJSON, &providerSettings) + if cfg.Settings != nil { + for k, v := range cfg.Settings { + providerSettings[k] = v + } } + proxyMode := cfg.ProxyMode + if proxyMode == "" { + proxyMode = "system" + } proxyURLStr := "" - if proxyURL != nil { - proxyURLStr = *proxyURL + if cfg.ProxyURL != nil { + proxyURLStr = *cfg.ProxyURL } return ProviderResolution{ Config: providers.ProviderConfig{ - Endpoint: endpoint, + Endpoint: cfg.Endpoint, APIKey: key, CustomHeaders: customHeaders, Settings: providerSettings, ProxyMode: proxyMode, ProxyURL: proxyURLStr, }, - ProviderID: providerID, + ProviderID: cfg.Provider, Model: model, - ConfigID: configID, - ProviderScope: providerScope, + ConfigID: cfg.ID, + ProviderScope: cfg.Scope, }, nil } diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go index b1cef05..77cb5c9 100644 --- a/server/handlers/route_test.go +++ b/server/handlers/route_test.go @@ -59,7 +59,7 @@ func TestRouteRegistration(t *testing.T) { protected.Use(middleware.Auth(cfg, stores.Users, userCache)) // Channels (the route group that conflicts with workflow) - channels := NewChannelHandler() + channels := NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) @@ -93,7 +93,7 @@ func TestRouteRegistration(t *testing.T) { protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) // Workflow assignments (v0.26.4) - wfAssignH := NewWorkflowAssignmentHandler() + wfAssignH := NewWorkflowAssignmentHandler(stores) protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) diff --git a/server/handlers/settings.go b/server/handlers/settings.go index 9f63d06..64a1164 100644 --- a/server/handlers/settings.go +++ b/server/handlers/settings.go @@ -1,7 +1,11 @@ package handlers +// settings.go — User profile, preferences, and password management. +// +// v0.29.0: Raw SQL replaced with UserStore methods. + import ( - "database/sql" + "context" "encoding/json" "log" "net/http" @@ -12,7 +16,7 @@ import ( "golang.org/x/crypto/bcrypt" "git.gobha.me/xcaliber/chat-switchboard/crypto" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Request Types ─────────────────────────── @@ -41,12 +45,13 @@ type profileResponse struct { // SettingsHandler manages user profile and preferences. type SettingsHandler struct { + stores store.Stores uekCache *crypto.UEKCache } // NewSettingsHandler creates a new handler. -func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler { - return &SettingsHandler{uekCache: uekCache} +func NewSettingsHandler(stores store.Stores, uekCache *crypto.UEKCache) *SettingsHandler { + return &SettingsHandler{stores: stores, uekCache: uekCache} } // ── Get Profile ───────────────────────────── @@ -54,31 +59,37 @@ func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler { func (h *SettingsHandler) GetProfile(c *gin.Context) { userID := getUserID(c) - var p profileResponse - var settingsRaw string - err := database.DB.QueryRow(database.Q(` - SELECT id, username, email, display_name, role, avatar_url, - COALESCE(NULLIF(settings::text, 'null'), '{}'), - created_at, last_login_at - FROM users WHERE id = $1 - `), userID).Scan( - &p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role, - &p.Avatar, &settingsRaw, - database.ST(&p.CreatedAt), database.SNT(&p.LastLoginAt), - ) - if err == sql.ErrNoRows { + user, err := h.stores.Users.GetByID(c.Request.Context(), userID) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load profile"}) - return + + var dn *string + if user.DisplayName != "" { + dn = &user.DisplayName + } + var avatar *string + if user.AvatarURL != "" { + avatar = &user.AvatarURL } - p.Settings = make(map[string]interface{}) - _ = json.Unmarshal([]byte(settingsRaw), &p.Settings) + settings := make(map[string]interface{}) + if user.Settings != nil { + settings = user.Settings + } - c.JSON(http.StatusOK, p) + c.JSON(http.StatusOK, profileResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + DisplayName: dn, + Role: user.Role, + Avatar: avatar, + Settings: settings, + CreatedAt: user.CreatedAt, + LastLoginAt: user.LastLoginAt, + }) } // ── Update Profile ────────────────────────── @@ -93,11 +104,9 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) { } if req.DisplayName != nil { - _, err := database.DB.Exec( - database.Q(`UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2`), - *req.DisplayName, userID, - ) - if err != nil { + if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{ + "display_name": *req.DisplayName, + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update display name"}) return } @@ -105,12 +114,11 @@ func (h *SettingsHandler) UpdateProfile(c *gin.Context) { if req.Email != nil { email := strings.ToLower(strings.TrimSpace(*req.Email)) - _, err := database.DB.Exec( - database.Q(`UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2`), - email, userID, - ) + err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{ + "email": email, + }) if err != nil { - if database.IsUniqueViolation(err) { + if isDuplicateErr(err) { c.JSON(http.StatusConflict, gin.H{"error": "email already taken"}) return } @@ -133,40 +141,31 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) { return } - // Verify current password - var hash string - err := database.DB.QueryRow( - database.Q(`SELECT password_hash FROM users WHERE id = $1`), userID, - ).Scan(&hash) + user, err := h.stores.Users.GetByID(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify password"}) return } - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.CurrentPassword)); err != nil { + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"}) return } - // Hash new password newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcryptCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } - _, err = database.DB.Exec( - database.Q(`UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`), - string(newHash), userID, - ) - if err != nil { + if err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{ + "password_hash": string(newHash), + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"}) return } - // Re-wrap UEK with new password so personal BYOK keys remain accessible h.rewrapVault(userID, req.CurrentPassword, req.NewPassword) - c.JSON(http.StatusOK, gin.H{"message": "password updated"}) } @@ -175,20 +174,16 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) { func (h *SettingsHandler) GetSettings(c *gin.Context) { userID := getUserID(c) - var settingsRaw string - // Handle three states: SQL NULL, JSON null, or valid JSON object. - // NULLIF(settings, 'null') converts JSON null to SQL NULL, - // then COALESCE handles both SQL NULL cases. - err := database.DB.QueryRow( - database.Q(`SELECT COALESCE(NULLIF(settings::text, 'null'), '{}') FROM users WHERE id = $1`), userID, - ).Scan(&settingsRaw) + user, err := h.stores.Users.GetByID(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"}) return } settings := make(map[string]interface{}) - _ = json.Unmarshal([]byte(settingsRaw), &settings) + if user.Settings != nil { + settings = user.Settings + } c.JSON(http.StatusOK, gin.H{"settings": settings}) } @@ -210,28 +205,7 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) { return } - // JSONB merge — existing keys preserved, incoming keys overwrite. - // Must handle three column states: - // 1. SQL NULL (never saved) - // 2. JSON literal null (corrupted by earlier bug) - // 3. JSON array (corrupted by repeated appends to null) - // 4. Valid JSON object (normal) - // NULLIF converts JSON null → SQL NULL, then COALESCE → '{}', - // and we enforce jsonb_typeof = 'object' to catch array corruption. - var mergeQuery string - if database.IsSQLite() { - mergeQuery = `UPDATE users SET settings = json_patch( - CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object' - THEN '{}' ELSE settings END, - ?), updated_at = datetime('now') WHERE id = ?` - } else { - mergeQuery = `UPDATE users SET settings = ( - CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object' - THEN '{}'::jsonb ELSE settings END - ) || $1::jsonb, updated_at = NOW() WHERE id = $2` - } - _, err = database.DB.Exec(mergeQuery, string(patch), userID) - if err != nil { + if err := h.stores.Users.MergeSettings(c.Request.Context(), userID, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) return } @@ -241,25 +215,16 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) { // ── Vault Re-wrap ────────────────────────── -// rewrapVault decrypts the UEK with the old password and re-encrypts it with -// the new password. This keeps personal BYOK keys accessible after a password -// change. Uses a new salt for forward secrecy. func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) { if h.uekCache == nil { return } - var vaultSet bool - var encUEK, salt, nonce []byte - err := database.DB.QueryRow(database.Q(` - SELECT vault_set, encrypted_uek, uek_salt, uek_nonce - FROM users WHERE id = $1 - `), userID).Scan(&vaultSet, &encUEK, &salt, &nonce) + vaultSet, encUEK, salt, nonce, err := h.stores.Users.GetVaultKeys(c_bg(), userID) if err != nil || !vaultSet || len(encUEK) == 0 { - return // No vault to re-wrap + return } - // Decrypt UEK with old password oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt) uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK) if err != nil { @@ -267,7 +232,6 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) { return } - // Re-wrap with new password using fresh salt newSalt, err := crypto.GenerateSalt() if err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err) @@ -281,17 +245,15 @@ func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) { return } - _, err = database.DB.Exec(database.Q(` - UPDATE users - SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW() - WHERE id = $4 - `), newEncUEK, newSalt, newNonce, userID) - if err != nil { + if err := h.stores.Users.UpdateVaultKeys(c_bg(), userID, newEncUEK, newSalt, newNonce); err != nil { log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err) return } - // Session cache: UEK itself hasn't changed, just its wrapper h.uekCache.Store(userID, uek) log.Printf(" 🔐 Vault re-wrapped for user %s", userID) } + +// c_bg returns context.Background() — short alias for vault operations +// that run outside the request lifecycle. +func c_bg() context.Context { return context.Background() } diff --git a/server/handlers/summarize.go b/server/handlers/summarize.go index b6b01b0..372ea09 100644 --- a/server/handlers/summarize.go +++ b/server/handlers/summarize.go @@ -1,45 +1,44 @@ package handlers +// summarize.go — Manual conversation summarization via HTTP. +// +// v0.29.0: Raw SQL replaced with ChannelStore methods. + import ( "net/http" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/compaction" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/roles" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // SummarizeHandler handles manual conversation summarization via HTTP. type SummarizeHandler struct { + stores store.Stores compaction *compaction.Service } // NewSummarizeHandler creates a new handler backed by the compaction service. -func NewSummarizeHandler(svc *compaction.Service) *SummarizeHandler { - return &SummarizeHandler{compaction: svc} +func NewSummarizeHandler(stores store.Stores, svc *compaction.Service) *SummarizeHandler { + return &SummarizeHandler{stores: stores, compaction: svc} } // ── Summarize & Continue ────────────────── // POST /channels/:id/summarize -// -// User-triggered compaction: calls the utility role to summarize the -// conversation history, inserts the summary as a tree node, and returns it. func (h *SummarizeHandler) Summarize(c *gin.Context) { channelID := c.Param("id") userID := getUserID(c) // ── Verify channel ownership ── - var ownerID string - err := database.DB.QueryRow( - database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID, - ).Scan(&ownerID) - if err != nil { + ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID) + if err != nil || ch == nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } - if ownerID != userID { + if ch.UserID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) return } @@ -73,7 +72,6 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) { Trigger: "manual", }) if err != nil { - // Map specific errors to HTTP status codes switch err.Error() { case "conversation too short to summarize": c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/server/handlers/task_test.go b/server/handlers/task_test.go index 249f91b..ebf7a12 100644 --- a/server/handlers/task_test.go +++ b/server/handlers/task_test.go @@ -689,11 +689,11 @@ func TestTask_WorkflowTypeRejected(t *testing.T) { // Task Type RBAC (v0.28.7) // ═══════════════════════════════════════════════ -func TestTask_StarlarkTypeRejected(t *testing.T) { +func TestTask_StarlarkTypeRequiresPackageID(t *testing.T) { h := setupHarness(t) _, token := h.createAdminUser("staruser", "staruser@test.com") - // Even admin cannot create starlark tasks — executor doesn't exist yet + // Starlark tasks require system_function (package_id) w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ "name": "Starlark Task", "task_type": "starlark", @@ -702,11 +702,11 @@ func TestTask_StarlarkTypeRejected(t *testing.T) { "model_id": "m", }) if w.Code != http.StatusBadRequest { - t.Fatalf("starlark task_type: want 400, got %d: %s", w.Code, w.Body.String()) + t.Fatalf("starlark task_type without package_id: want 400, got %d: %s", w.Code, w.Body.String()) } body := w.Body.String() - if !strings.Contains(body, "v0.29.0") { - t.Fatalf("expected 'v0.29.0' in error, got: %s", body) + if !strings.Contains(body, "system_function") && !strings.Contains(body, "package_id") { + t.Fatalf("expected 'system_function' or 'package_id' in error, got: %s", body) } } diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go index 4c689e4..7fe5c89 100644 --- a/server/handlers/tasks.go +++ b/server/handlers/tasks.go @@ -133,12 +133,30 @@ func (h *TaskHandler) Create(c *gin.Context) { } } - // v0.28.7: Starlark tasks — pre-positioned gate for v0.29.0. - // The Starlark executor does not exist yet. Reject all creation with - // a clear message rather than a cryptic CHECK constraint failure. + // v0.29.0: Starlark tasks — run sandboxed extension scripts. + // Requires task.starlark permission. system_function holds package ID. if t.TaskType == "starlark" { - c.JSON(http.StatusBadRequest, gin.H{"error": "starlark task execution requires v0.29.0 — not yet available"}) - return + if c.GetString("role") != "admin" { + perms := middleware.GetResolvedPermissions(c) + if perms == nil || !perms[auth.PermTaskStarlark] { + c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskStarlark}) + return + } + } + if t.SystemFunction == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "system_function (package_id) is required for starlark tasks"}) + return + } + // Verify package exists and is starlark tier + pkg, err := h.stores.Packages.Get(c.Request.Context(), t.SystemFunction) + if err != nil || pkg == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "package not found: " + t.SystemFunction}) + return + } + if pkg.Tier != models.ExtTierStarlark { + c.JSON(http.StatusBadRequest, gin.H{"error": "package " + t.SystemFunction + " is not a starlark package"}) + return + } } // v0.28.0-audit: Workflow task execution is not yet implemented. diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go index 48e6daa..c5f29ac 100644 --- a/server/handlers/team_providers.go +++ b/server/handlers/team_providers.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "log" "net/http" @@ -8,9 +9,9 @@ import ( "github.com/gin-gonic/gin" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Team Provider Handlers ────────────────── @@ -18,19 +19,13 @@ import ( // ListTeamProviders returns API configs scoped to a team. func (h *TeamHandler) ListTeamProviders(c *gin.Context) { teamID := getTeamID(c) + ctx := c.Request.Context() - rows, err := database.DB.Query(database.Q(` - SELECT id, name, provider, endpoint, api_key_enc, - model_default, config, is_active, is_private, created_at, updated_at - FROM provider_configs - WHERE scope = 'team' AND owner_id = $1 - ORDER BY name ASC - `), teamID) + configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"}) return } - defer rows.Close() type teamProvider struct { ID string `json:"id"` @@ -46,23 +41,34 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) { UpdatedAt string `json:"updated_at"` } - configs := make([]teamProvider, 0) - for rows.Next() { - var p teamProvider - var apiKeyEnc []byte - var configRaw string - if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc, - &p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil { - continue + result := make([]teamProvider, 0, len(configs)) + for _, cfg := range configs { + var md *string + if cfg.ModelDefault != "" { + md = &cfg.ModelDefault } - p.HasKey = len(apiKeyEnc) > 0 - p.Config = parseJSONBConfig(configRaw) - configs = append(configs, p) + cfgMap := map[string]interface{}{} + if cfg.Config != nil { + cfgMap = cfg.Config + } + result = append(result, teamProvider{ + ID: cfg.ID, + Name: cfg.Name, + Provider: cfg.Provider, + Endpoint: cfg.Endpoint, + HasKey: cfg.HasKey(), + ModelDefault: md, + Config: cfgMap, + IsActive: cfg.IsActive, + IsPrivate: cfg.IsPrivate, + CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) } c.JSON(http.StatusOK, gin.H{ - "data": configs, - "allow_team_providers": isTeamProvidersAllowed(teamID), + "data": result, + "allow_team_providers": isTeamProvidersAllowed(h.stores, teamID), }) } @@ -70,7 +76,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) { func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { teamID := getTeamID(c) - if !isTeamProvidersAllowed(teamID) { + if !isTeamProvidersAllowed(h.stores, teamID) { c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"}) return } @@ -98,18 +104,6 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { return } - configJSON := "{}" - if req.Config != nil { - b, _ := json.Marshal(req.Config) - configJSON = string(b) - } - - headersJSON := "{}" - if req.Headers != nil { - b, _ := json.Marshal(req.Headers) - headersJSON = string(b) - } - // Encrypt the API key for team scope var apiKeyEnc, keyNonce []byte if req.APIKey != "" { @@ -125,27 +119,43 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { } } - id, err := database.InsertReturningID(` - INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, - api_key_enc, key_nonce, key_scope, model_default, config, is_private, headers) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb) - RETURNING id - `, "team", teamID, req.Name, req.Provider, req.Endpoint, - apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, headersJSON, - ) - if err != nil { + headersMap := models.JSONMap{} + if req.Headers != nil { + for k, v := range req.Headers { + headersMap[k] = v + } + } + + cfg := &models.ProviderConfig{ + Scope: models.ScopeTeam, + OwnerID: &teamID, + Name: req.Name, + Provider: req.Provider, + Endpoint: req.Endpoint, + APIKeyEnc: apiKeyEnc, + KeyNonce: keyNonce, + KeyScope: "team", + ModelDefault: req.ModelDefault, + Config: models.JSONMap(req.Config), + Headers: headersMap, + IsActive: true, + IsPrivate: req.IsPrivate, + } + + if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { log.Printf("[WARN] Failed to create team provider: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"}) return } - c.JSON(http.StatusCreated, gin.H{"id": id}) + c.JSON(http.StatusCreated, gin.H{"id": cfg.ID}) } // UpdateTeamProvider updates a team-scoped API config. func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") + ctx := c.Request.Context() var req struct { Name *string `json:"name,omitempty"` @@ -163,29 +173,23 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { } // Verify provider belongs to this team - var count int - database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`), providerID, teamID).Scan(&count) - if count == 0 { + existing, err := h.stores.Providers.GetByID(ctx, providerID) + if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) return } - // Build dynamic update using ? placeholders (works on both dialects) - setClauses := []string{"updated_at = " + database.Q("NOW()")} - args := []interface{}{} + // Build patch + patch := models.ProviderConfigPatch{} fieldCount := 0 - addSet := func(col string, val interface{}) { - setClauses = append(setClauses, col+" = ?") - args = append(args, val) + if req.Name != nil { + patch.Name = req.Name fieldCount++ } - - if req.Name != nil { - addSet("name", *req.Name) - } if req.Endpoint != nil { - addSet("endpoint", *req.Endpoint) + patch.Endpoint = req.Endpoint + fieldCount++ } if req.APIKey != nil && *req.APIKey != "" { if h.vault != nil { @@ -194,28 +198,36 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) return } - addSet("api_key_enc", enc) - addSet("key_nonce", nonce) + patch.APIKeyEnc = enc + patch.KeyNonce = nonce } else { - addSet("api_key_enc", []byte(*req.APIKey)) + patch.APIKeyEnc = []byte(*req.APIKey) } + fieldCount++ } if req.ModelDefault != nil { - addSet("model_default", *req.ModelDefault) + patch.ModelDefault = req.ModelDefault + fieldCount++ } if req.IsActive != nil { - addSet("is_active", *req.IsActive) + patch.IsActive = req.IsActive + fieldCount++ } if req.IsPrivate != nil { - addSet("is_private", *req.IsPrivate) + patch.IsPrivate = req.IsPrivate + fieldCount++ } if req.Config != nil { - b, _ := json.Marshal(req.Config) - addSet("config", string(b)) + patch.Config = models.JSONMap(req.Config) + fieldCount++ } if req.Headers != nil { - b, _ := json.Marshal(req.Headers) - addSet("headers", string(b)) + headersMap := models.JSONMap{} + for k, v := range req.Headers { + headersMap[k] = v + } + patch.Headers = headersMap + fieldCount++ } if fieldCount == 0 { @@ -223,24 +235,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { return } - args = append(args, providerID, teamID) - - // Build final query with ? placeholders, then convert to $N for Postgres - query := "UPDATE provider_configs SET " - for i, s := range setClauses { - if i > 0 { - query += ", " - } - query += s - } - query += " WHERE id = ? AND scope = 'team' AND owner_id = ?" - - if database.IsPostgres() { - query = convertPlaceholders(query) - } - - _, err := database.DB.Exec(query, args...) - if err != nil { + if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"}) return } @@ -253,16 +248,12 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") - result, err := database.DB.Exec(database.Q(` - DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2 - `), providerID, teamID) + n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"}) return } - - rows, _ := result.RowsAffected() - if rows == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) return } @@ -274,46 +265,46 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) { func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") + ctx := c.Request.Context() - var name, providerType, endpoint string - var apiKeyEnc, keyNonce []byte - var keyScope string - var headersJSON []byte - err := database.DB.QueryRow(database.Q(` - SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers - FROM provider_configs - WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true - `), providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON) - if err != nil { + cfg, err := h.stores.Providers.GetByID(ctx, providerID) + if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + if cfg.OwnerID == nil || *cfg.OwnerID != teamID { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) return } - provider, err := providers.Get(providerType) + provider, err := providers.Get(cfg.Provider) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"}) return } key := "" - if len(apiKeyEnc) > 0 { + if cfg.HasKey() { if h.vault != nil { var err error - key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "") + key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"}) return } } else { - key = string(apiKeyEnc) + key = string(cfg.APIKeyEnc) } } var customHeaders map[string]string - _ = json.Unmarshal(headersJSON, &customHeaders) + if cfg.Headers != nil { + b, _ := json.Marshal(cfg.Headers) + _ = json.Unmarshal(b, &customHeaders) + } - modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ - Endpoint: endpoint, + modelList, err := provider.ListModels(ctx, providers.ProviderConfig{ + Endpoint: cfg.Endpoint, APIKey: key, CustomHeaders: customHeaders, }) @@ -335,7 +326,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) } - c.JSON(http.StatusOK, gin.H{"models": out, "provider": name}) + c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name}) } // parseJSONBConfig parses a JSONB text string into a map. @@ -351,32 +342,30 @@ func parseJSONBConfig(raw string) map[string]interface{} { } // isTeamProvidersAllowed checks if team providers are enabled. -func isTeamProvidersAllowed(teamID string) bool { - if database.DB == nil { +func isTeamProvidersAllowed(stores store.Stores, teamID string) bool { + if stores.GlobalConfig == nil { return false } - var globalVal string - err := database.DB.QueryRow(` - SELECT value FROM global_settings WHERE key = 'allow_team_providers' - `).Scan(&globalVal) + ctx := context.Background() + + // Check global setting + globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers") if err == nil && globalVal == "false" { return false } - var settingsJSON []byte - err = database.DB.QueryRow(database.Q(`SELECT settings FROM teams WHERE id = $1`), teamID).Scan(&settingsJSON) + // Check team-level setting + team, err := stores.Teams.GetByID(ctx, teamID) if err != nil { - return true + return true // fail open } - var settings map[string]interface{} - if err := json.Unmarshal(settingsJSON, &settings); err != nil { - return true - } - if v, ok := settings["allow_team_providers"]; ok { - if b, ok := v.(bool); ok { - return b + if team.Settings != nil { + if v, ok := team.Settings["allow_team_providers"]; ok { + if bVal, ok := v.(bool); ok { + return bVal + } } } diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 2fce61c..ed6d1d3 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -1,14 +1,13 @@ package handlers import ( + "context" "database/sql" "encoding/json" "fmt" "log" "net/http" "strconv" - "strings" - "time" "github.com/gin-gonic/gin" @@ -152,68 +151,44 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) { return } - // Build dynamic update - sets := []string{} - args := []interface{}{} - argN := 1 - - addArg := func(col string, val interface{}) { - if database.IsSQLite() { - sets = append(sets, col+" = ?") - } else { - sets = append(sets, col+" = $"+strconv.Itoa(argN)) - } - args = append(args, val) - argN++ - } - + // Build fields map for store.Update (simple scalar fields) + fields := map[string]interface{}{} if req.Name != nil { - addArg("name", *req.Name) + fields["name"] = *req.Name } if req.Description != nil { - addArg("description", *req.Description) + fields["description"] = *req.Description } if req.IsActive != nil { - addArg("is_active", *req.IsActive) - } - if req.Settings != nil { - if database.IsSQLite() { - // SQLite: json_patch for merge - sets = append(sets, "settings = json_patch(COALESCE(settings, '{}'), ?)") - } else { - sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb") - } - args = append(args, *req.Settings) - argN++ + fields["is_active"] = *req.IsActive } - if len(sets) == 0 { + hasFields := len(fields) > 0 + hasSettings := req.Settings != nil + + if !hasFields && !hasSettings { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - var whereClause string - if database.IsSQLite() { - whereClause = " WHERE id = ?" - } else { - whereClause = " WHERE id = $" + strconv.Itoa(argN) - } - args = append(args, teamID) + ctx := c.Request.Context() - query := "UPDATE teams SET " + strings.Join(sets, ", ") + whereClause - - res, err := database.DB.Exec(query, args...) - if err != nil { - if database.IsUniqueViolation(err) { - c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) + if hasFields { + if err := h.stores.Teams.Update(ctx, teamID, fields); err != nil { + if database.IsUniqueViolation(err) { + c.JSON(http.StatusConflict, gin.H{"error": "team name already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) - return } - if n, _ := res.RowsAffected(); n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return + + if hasSettings { + if err := h.stores.Teams.MergeSettings(ctx, teamID, *req.Settings); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "settings update failed"}) + return + } } c.JSON(http.StatusOK, gin.H{"ok": true}) @@ -266,6 +241,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) { func (h *TeamHandler) AddMember(c *gin.Context) { teamID := getTeamID(c) + ctx := c.Request.Context() var req addMemberRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -274,25 +250,20 @@ func (h *TeamHandler) AddMember(c *gin.Context) { } // Verify team exists - var exists bool - database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`), teamID).Scan(&exists) + exists, _ := h.stores.Teams.Exists(ctx, teamID) if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) return } // Verify user exists - database.DB.QueryRow(database.Q(`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`), req.UserID).Scan(&exists) - if !exists { + userExists, _ := h.stores.Users.Exists(ctx, req.UserID) + if !userExists { c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"}) return } - id, err := database.InsertReturningID(` - INSERT INTO team_members (team_id, user_id, role) - VALUES ($1, $2, $3) - RETURNING id - `, teamID, req.UserID, req.Role) + id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role) if err != nil { if database.IsUniqueViolation(err) { c.JSON(http.StatusConflict, gin.H{"error": "user is already a member"}) @@ -320,14 +291,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) { return } - res, err := database.DB.Exec(database.Q(` - UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3 - `), req.Role, memberID, teamID) + n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"}) return } - if n, _ := res.RowsAffected(); n == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) return } @@ -344,12 +313,12 @@ func (h *TeamHandler) RemoveMember(c *gin.Context) { teamID := getTeamID(c) memberID := c.Param("memberId") - res, err := database.DB.Exec(database.Q(`DELETE FROM team_members WHERE id = $1 AND team_id = $2`), memberID, teamID) + n, err := h.stores.Teams.DeleteMemberByID(c.Request.Context(), memberID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"}) return } - if n, _ := res.RowsAffected(); n == 0 { + if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "member not found"}) return } @@ -384,6 +353,7 @@ func (h *TeamHandler) MyTeams(c *gin.Context) { // GET /api/v1/teams/:teamId/models func (h *TeamHandler) ListAvailableModels(c *gin.Context) { teamID := getTeamID(c) + ctx := c.Request.Context() type availableModel struct { ID string `json:"id"` @@ -395,79 +365,62 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) { Source string `json:"source"` } - models := make([]availableModel, 0) + result := make([]availableModel, 0) // ── 1. Global admin models (synced in model_catalog) ── - rows, err := database.DB.Query(database.Q(` - SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, - ac.provider, ac.name as provider_name - FROM model_catalog mc - JOIN provider_configs ac ON mc.provider_config_id = ac.id - WHERE mc.visibility IN ('enabled', 'team') - AND ac.is_active = true AND ac.scope = 'global' - ORDER BY ac.name, mc.model_id - `)) + catalogModels, err := h.stores.Catalog.ListTeamAvailable(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - defer rows.Close() - - for rows.Next() { - var m availableModel - if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, - &m.Provider, &m.ProviderName); err != nil { - continue - } - m.Source = "global" - models = append(models, m) + for _, cm := range catalogModels { + result = append(result, availableModel{ + ID: cm.ID, + ModelID: cm.ModelID, + DisplayName: cm.DisplayName, + Visibility: cm.Visibility, + Provider: cm.Provider, + ProviderName: cm.ProviderName, + Source: "global", + }) } // ── 2. Team provider models (live query) ── - teamRows, err := database.DB.Query(database.Q(` - SELECT id, name, provider, endpoint, api_key_enc, headers - FROM provider_configs - WHERE scope = 'team' AND owner_id = $1 AND is_active = true - `), teamID) + teamConfigs, err := h.stores.Providers.ListForTeam(ctx, teamID) if err == nil { - defer teamRows.Close() - for teamRows.Next() { - var cfgID, name, providerID, endpoint string - var apiKey *string - var headersJSON []byte - if err := teamRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil { - continue - } - - provider, pErr := providers.Get(providerID) + for _, cfg := range teamConfigs { + provider, pErr := providers.Get(cfg.Provider) if pErr != nil { continue } key := "" - if apiKey != nil { - key = *apiKey + if cfg.HasKey() { + key = string(cfg.APIKeyEnc) } var customHeaders map[string]string - _ = json.Unmarshal(headersJSON, &customHeaders) + if cfg.Headers != nil { + b, _ := json.Marshal(cfg.Headers) + _ = json.Unmarshal(b, &customHeaders) + } - provModels, lErr := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ - Endpoint: endpoint, + provModels, lErr := provider.ListModels(ctx, providers.ProviderConfig{ + Endpoint: cfg.Endpoint, APIKey: key, CustomHeaders: customHeaders, }) if lErr != nil { - log.Printf("[models] team provider %q list failed: %v", name, lErr) + log.Printf("[models] team provider %q list failed: %v", cfg.Name, lErr) continue } for _, pm := range provModels { - models = append(models, availableModel{ + result = append(result, availableModel{ ID: pm.ID, ModelID: pm.ID, - Provider: providerID, - ProviderName: name, + Provider: cfg.Provider, + ProviderName: cfg.Name, Visibility: "enabled", Source: "team", }) @@ -475,7 +428,7 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{"models": models}) + c.JSON(http.StatusOK, gin.H{"models": result}) } // ── Helpers ───────────────────────────────── @@ -488,68 +441,25 @@ func getTeamID(c *gin.Context) string { return c.Param("id") } -// IsTeamAdmin checks if a user is an admin of the given team. -func IsTeamAdmin(userID, teamID string) bool { - var role string - err := database.DB.QueryRow(database.Q(` - SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2 - `), teamID, userID).Scan(&role) - return err == nil && role == "admin" -} - -// IsTeamMember checks if a user belongs to the given team (any role). -func IsTeamMember(userID, teamID string) bool { - var exists bool - database.DB.QueryRow(database.Q(` - SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2) - `), teamID, userID).Scan(&exists) - return exists -} - // enforcePrivateProviderPolicy checks if a user belongs to any team that // requires private providers, and if so, verifies the resolved config is // marked as private. Returns nil if allowed, error if blocked. -func enforcePrivateProviderPolicy(userID, configID string) error { +func enforcePrivateProviderPolicy(ctx context.Context, stores store.Stores, userID, configID string) error { if configID == "" { return nil } - // Check if user belongs to any team with require_private_providers policy - var requiresPrivate bool - var query string - if database.IsSQLite() { - query = ` - SELECT EXISTS( - SELECT 1 FROM team_members tm - JOIN teams t ON t.id = tm.team_id - WHERE tm.user_id = ? - AND t.is_active = 1 - AND json_extract(t.settings, '$.require_private_providers') = 'true' - )` - } else { - query = ` - SELECT EXISTS( - SELECT 1 FROM team_members tm - JOIN teams t ON t.id = tm.team_id - WHERE tm.user_id = $1 - AND t.is_active = true - AND t.settings->>'require_private_providers' = 'true' - )` - } - err := database.DB.QueryRow(query, userID).Scan(&requiresPrivate) + requiresPrivate, err := stores.Teams.HasPrivateProviderRequirement(ctx, userID) if err != nil || !requiresPrivate { return nil } // User is in a restricted team — verify the config is private - var isPrivate bool - err = database.DB.QueryRow(database.Q(` - SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1 - `), configID).Scan(&isPrivate) + cfg, err := stores.Providers.GetByID(ctx, configID) if err != nil { return nil // config lookup failed, allow (fail open) } - if !isPrivate { + if !cfg.IsPrivate { return fmt.Errorf("your team requires private providers — this provider sends data externally") } return nil @@ -561,84 +471,31 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { teamID := c.Param("teamId") page, perPage, offset := parsePagination(c) - // Build filter clauses — always scoped to team members. - // Use ? placeholders and convert for Postgres if needed. - clauses := []string{"al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)"} - args := []interface{}{teamID} + opts := store.AuditListOptions{ + ListOptions: store.ListOptions{ + Limit: perPage, + Offset: offset, + }, + TeamID: teamID, + } if action := c.Query("action"); action != "" { - clauses = append(clauses, "al.action = ?") - args = append(args, action) + opts.Action = action } if actorID := c.Query("actor_id"); actorID != "" { - clauses = append(clauses, "al.actor_id = ?") - args = append(args, actorID) + opts.ActorID = actorID } if rt := c.Query("resource_type"); rt != "" { - clauses = append(clauses, "al.resource_type = ?") - args = append(args, rt) + opts.ResourceType = rt } - where := "WHERE " + strings.Join(clauses, " AND ") - - // For Postgres, convert ? to $N - if database.IsPostgres() { - where = convertPlaceholders(where) - } - - // Count - var total int - countArgs := make([]interface{}, len(args)) - copy(countArgs, args) - err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"}) - return - } - - // Query with actor name join - limitOffset := fmt.Sprintf("LIMIT %d OFFSET %d", perPage, offset) - query := ` - SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name, - al.action, al.resource_type, al.resource_id, - COALESCE(al.metadata, '{}'), al.ip_address, al.created_at - FROM audit_log al - LEFT JOIN users u ON al.actor_id = u.id - ` + where + ` - ORDER BY al.created_at DESC - ` + limitOffset - - rows, err := database.DB.Query(query, args...) + entries, total, err := h.stores.Audit.List(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) return } - defer rows.Close() - - type entry struct { - ID string `json:"id"` - ActorID *string `json:"actor_id"` - ActorName *string `json:"actor_name"` - Action string `json:"action"` - ResourceType string `json:"resource_type"` - ResourceID *string `json:"resource_id"` - Metadata string `json:"metadata"` - IPAddress *string `json:"ip_address"` - CreatedAt time.Time `json:"created_at"` - } - - entries := make([]entry, 0) - for rows.Next() { - var e entry - var actorName sql.NullString - if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action, - &e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, database.ST(&e.CreatedAt)); err != nil { - continue - } - if actorName.Valid { - e.ActorName = &actorName.String - } - entries = append(entries, e) + if entries == nil { + entries = []models.AuditEntry{} } c.JSON(http.StatusOK, gin.H{ @@ -652,39 +509,11 @@ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) { teamID := c.Param("teamId") - rows, err := database.DB.Query(database.Q(` - SELECT DISTINCT al.action - FROM audit_log al - WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1) - ORDER BY al.action ASC - `), teamID) + actions, err := h.stores.Teams.ListTeamAuditActions(c.Request.Context(), teamID) if err != nil { c.JSON(http.StatusOK, gin.H{"actions": []string{}}) return } - defer rows.Close() - actions := make([]string, 0) - for rows.Next() { - var a string - if rows.Scan(&a) == nil { - actions = append(actions, a) - } - } c.JSON(http.StatusOK, gin.H{"actions": actions}) } - -// convertPlaceholders converts ? placeholders to $1, $2, etc. for Postgres. -func convertPlaceholders(q string) string { - n := 1 - var result strings.Builder - for _, ch := range q { - if ch == '?' { - result.WriteString(fmt.Sprintf("$%d", n)) - n++ - } else { - result.WriteRune(ch) - } - } - return result.String() -} diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go index 5bf5271..ca971e3 100644 --- a/server/handlers/workflow_assignments.go +++ b/server/handlers/workflow_assignments.go @@ -1,5 +1,9 @@ package handlers +// workflow_assignments.go — Assignment queue for human review stages. +// +// v0.29.0: Raw SQL replaced with WorkflowStore + ChannelStore methods. + import ( "encoding/json" "net/http" @@ -7,104 +11,51 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/notifications" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Workflow Assignment Handler ───────────── -// Manages the assignment queue for human review stages. type WorkflowAssignmentHandler struct { - hub *events.Hub + stores store.Stores + hub *events.Hub } -func NewWorkflowAssignmentHandler(hub ...*events.Hub) *WorkflowAssignmentHandler { - h := &WorkflowAssignmentHandler{} +func NewWorkflowAssignmentHandler(stores store.Stores, hub ...*events.Hub) *WorkflowAssignmentHandler { + h := &WorkflowAssignmentHandler{stores: stores} if len(hub) > 0 { h.hub = hub[0] } return h } -type assignmentRow struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Stage int `json:"stage"` - TeamID string `json:"team_id"` - AssignedTo *string `json:"assigned_to"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - ClaimedAt *time.Time `json:"claimed_at"` - CompletedAt *time.Time `json:"completed_at"` -} - // ListForTeam returns unassigned + claimed assignments for a team. // GET /api/v1/teams/:teamId/assignments func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) { teamID := c.Param("teamId") status := c.DefaultQuery("status", "unassigned") - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT id, channel_id, stage, team_id, assigned_to, status, - created_at, claimed_at, completed_at - FROM workflow_assignments - WHERE team_id = $1 AND status = $2 - ORDER BY created_at ASC - `), teamID, status) + result, err := h.stores.Workflows.ListAssignmentsForTeam(c.Request.Context(), teamID, status) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } - defer rows.Close() - - var result []assignmentRow - for rows.Next() { - var a assignmentRow - if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, - &a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil { - continue - } - result = append(result, a) - } - if result == nil { - result = []assignmentRow{} - } c.JSON(http.StatusOK, gin.H{"data": result}) } -// ListForUser returns assignments claimed by the current user plus +// ListMine returns assignments claimed by the current user plus // unassigned assignments for teams the user belongs to. // GET /api/v1/workflow-assignments/mine func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { userID := c.GetString("user_id") - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status, - wa.created_at, wa.claimed_at, wa.completed_at - FROM workflow_assignments wa - LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1 - WHERE (wa.assigned_to = $2 AND wa.status = 'claimed') - OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL) - ORDER BY wa.created_at DESC - `), userID, userID) + + result, err := h.stores.Workflows.ListAssignmentsMine(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) return } - defer rows.Close() - - var result []assignmentRow - for rows.Next() { - var a assignmentRow - if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, - &a.AssignedTo, &a.Status, database.ST(&a.CreatedAt), database.SNT(&a.ClaimedAt), database.SNT(&a.CompletedAt)); err != nil { - continue - } - result = append(result, a) - } - if result == nil { - result = []assignmentRow{} - } c.JSON(http.StatusOK, gin.H{"data": result}) } @@ -115,30 +66,20 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { userID := c.GetString("user_id") now := time.Now().UTC() - res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE workflow_assignments - SET assigned_to = $1, status = 'claimed', claimed_at = $2 - WHERE id = $3 AND status = 'unassigned' - `), userID, now, assignmentID) + n, err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"}) return } - n, _ := res.RowsAffected() if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"}) return } - // Look up channel for this assignment (needed for WS delivery + notification) - var channelID string - _ = database.DB.QueryRowContext(c.Request.Context(), - database.Q(`SELECT channel_id FROM workflow_assignments WHERE id = $1`), - assignmentID).Scan(&channelID) + // Look up channel for WS delivery + notification + channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) - // Emit workflow.claimed WS event to all user participants in the channel. - // Uses SendToUser (not room-scoped Bus.Publish) because room subscriptions - // are not yet wired on the client side. See websocket.md § Room Model. + // Emit workflow.claimed WS event to all user participants in the channel if h.hub != nil && channelID != "" { payload, _ := json.Marshal(map[string]any{ "assignment_id": assignmentID, @@ -150,22 +91,13 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { Payload: payload, Ts: now.UnixMilli(), } - rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID) - if err == nil { - defer rows.Close() - for rows.Next() { - var uid string - if rows.Scan(&uid) == nil { - h.hub.SendToUser(uid, evt) - } - } + pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") + for _, uid := range pids { + h.hub.SendToUser(uid, evt) } } - // Persist notification for bell/inbox (v0.28.2) + // Persist notification for bell/inbox if svc := notifications.Default(); svc != nil && channelID != "" { notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID) } @@ -177,18 +109,12 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { // POST /api/v1/workflow-assignments/:id/complete func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) { assignmentID := c.Param("id") - now := time.Now().UTC() - res, err := database.DB.ExecContext(c.Request.Context(), database.Q(` - UPDATE workflow_assignments - SET status = 'completed', completed_at = $1 - WHERE id = $2 AND status = 'claimed' - `), now, assignmentID) + n, err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"}) return } - n, _ := res.RowsAffected() if n == 0 { c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) return diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go index b186960..119b809 100644 --- a/server/handlers/workflow_entry.go +++ b/server/handlers/workflow_entry.go @@ -4,11 +4,9 @@ import ( "fmt" "log" "net/http" - "time" "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -77,20 +75,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { } // Set workflow columns - allowAnonVal := interface{}(true) - if database.CurrentDialect == database.DialectSQLite { - allowAnonVal = 1 - } - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET workflow_id = $1, workflow_version = $2, current_stage = 0, - stage_data = '{}', workflow_status = 'active', - last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto' - WHERE id = $5 - `), wf.ID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID) + err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wf.ID, ver.VersionNumber, []byte("{}"), "active") if err != nil { log.Printf("Failed to set workflow columns: %v", err) } + // Enable anonymous access + auto AI mode for visitor entry + _ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{ + "allow_anonymous": true, + "ai_mode": "auto", + }) // Create anonymous session sessionToken := store.NewID() diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go index 37e8aa7..0df9eab 100644 --- a/server/handlers/workflow_instance_test.go +++ b/server/handlers/workflow_instance_test.go @@ -79,7 +79,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) // Assignments - wfAssignH := NewWorkflowAssignmentHandler() + wfAssignH := NewWorkflowAssignmentHandler(stores) protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) @@ -93,7 +93,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { // Team-scoped assignment listing (mirrors main.go teamScoped) teamScoped := protected.Group("/teams/:teamId") - teamAssignH := NewWorkflowAssignmentHandler() + teamAssignH := NewWorkflowAssignmentHandler(stores) teamScoped.GET("/assignments", teamAssignH.ListForTeam) // Visitor entry diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index f0ae1ac..d73c88f 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -3,13 +3,13 @@ package handlers import ( "context" "encoding/json" + "fmt" "log" "net/http" "time" "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" @@ -77,24 +77,14 @@ func (h *WorkflowInstanceHandler) Start(c *gin.Context) { // Set workflow-specific columns (not part of base Channel.Create) allowAnon := wf.EntryMode == "public_link" - var allowAnonVal interface{} = allowAnon - if database.CurrentDialect == database.DialectSQLite { - if allowAnon { - allowAnonVal = 1 - } else { - allowAnonVal = 0 - } - } - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET workflow_id = $1, workflow_version = $2, current_stage = 0, - stage_data = '{}', workflow_status = 'active', - last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto' - WHERE id = $5 - `), wfID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID) + err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wfID, ver.VersionNumber, []byte("{}"), "active") if err != nil { log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err) } + _ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{ + "allow_anonymous": allowAnon, + "ai_mode": "auto", + }) // Add caller as channel owner _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ @@ -126,34 +116,15 @@ func (h *WorkflowInstanceHandler) Start(c *gin.Context) { // ── Status ────────────────────────────────── -// WorkflowChannelStatus holds the runtime state of a workflow instance. -type WorkflowChannelStatus struct { - WorkflowID *string `json:"workflow_id"` - WorkflowVersion *int `json:"workflow_version"` - CurrentStage int `json:"current_stage"` - StageData json.RawMessage `json:"stage_data"` - Status string `json:"status"` - LastActivityAt *string `json:"last_activity_at"` -} - // GetStatus returns the workflow state for a channel. // GET /api/v1/channels/:id/workflow/status func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) { channelID := c.Param("id") - var ws WorkflowChannelStatus - var stageData []byte - err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT workflow_id, workflow_version, current_stage, - COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), - last_activity_at - FROM channels WHERE id = $1 AND type = 'workflow' - `), channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, - &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt) - if err != nil { + ws, err := h.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) + if err != nil || ws == nil { c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) return } - ws.StageData = stageData c.JSON(http.StatusOK, ws) } @@ -186,17 +157,12 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { } _ = c.ShouldBindJSON(&body) - mergedData := tools.MergeWorkflowStageData(ctx, channelID, body.Data) + mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data) nextStage := currentStage + 1 if nextStage >= len(stages) { // Workflow complete - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET current_stage = $1, workflow_status = 'completed', - stage_data = $2, last_activity_at = $3, ai_mode = 'off' - WHERE id = $4 - `), nextStage, mergedData, time.Now().UTC(), channelID) + err = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"}) return @@ -216,11 +182,7 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { } // Advance to next stage - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET current_stage = $1, stage_data = $2, last_activity_at = $3 - WHERE id = $4 - `), nextStage, mergedData, time.Now().UTC(), channelID) + err = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"}) return @@ -245,7 +207,7 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { // v0.27.0: Assignment + round-robin + WS notifications if nextStageDef.AssignmentTeamID != nil { - assignmentID := tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID) + assignmentID := tools.CreateWorkflowAssignment(ctx, h.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID) // Round-robin auto-assignment if configured assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID) @@ -298,9 +260,7 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) { } prevStage := currentStage - 1 - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3 - `), prevStage, time.Now().UTC(), channelID) + err = h.stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"}) return @@ -327,15 +287,14 @@ func (h *WorkflowInstanceHandler) Reject(c *gin.Context) { // ── Helpers ───────────────────────────────── func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) { - var wfID *string - err = database.DB.QueryRowContext(ctx, database.Q(` - SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active') - FROM channels WHERE id = $1 AND type = 'workflow' - `), channelID).Scan(&wfID, ¤tStage, &status) - if wfID != nil { - workflowID = *wfID + ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil { + return "", 0, "", fmt.Errorf("workflow channel not found") } - return + if ws.WorkflowID != nil { + workflowID = *ws.WorkflowID + } + return workflowID, ws.CurrentStage, ws.Status, nil } // emitWorkflowEvent pushes a workflow event to all user participants in the channel. @@ -353,20 +312,13 @@ func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, dat } // Send to all user participants in the channel - rows, err := database.DB.Query(database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID) + pids, err := h.stores.Channels.ListUserParticipantIDs(context.Background(), channelID, "") if err != nil { log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err) return } - defer rows.Close() - for rows.Next() { - var uid string - if rows.Scan(&uid) == nil { - h.hub.SendToUser(uid, evt) - } + for _, uid := range pids { + h.hub.SendToUser(uid, evt) } } @@ -396,52 +348,15 @@ func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage model return "" } - // Get team members - members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID) - if err != nil || len(members) == 0 { - return "" - } - - // Find the least-recently-assigned member. - // Query: for each member, find their most recent claimed_at in workflow_assignments. - // Pick the member with the oldest (or null) claimed_at. - var bestUserID string - bestUserID = members[0].UserID // fallback to first member - - rows, err := database.DB.QueryContext(ctx, database.Q(` - SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim - FROM team_members m - LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1 - WHERE m.team_id = $2 - GROUP BY m.user_id - ORDER BY last_claim ASC - LIMIT 1 - `), *stage.AssignmentTeamID, *stage.AssignmentTeamID) - if err == nil { - defer rows.Close() - if rows.Next() { - var uid string - var lastClaim string // COALESCE returns TEXT on both dialects - if err := rows.Scan(&uid, &lastClaim); err == nil { - bestUserID = uid - } - } - } - - // Claim the assignment for this user - now := time.Now().UTC() - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE workflow_assignments - SET assigned_to = $1, status = 'claimed', claimed_at = $2 - WHERE id = $3 AND status = 'unassigned' - `), bestUserID, now, assignmentID) + assignedTo, err := h.stores.Workflows.TryRoundRobin(ctx, *stage.AssignmentTeamID, assignmentID) if err != nil { - log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err) + log.Printf("[workflow] round-robin: failed to auto-assign %s: %v", assignmentID, err) return "" } - - log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID) - return bestUserID + if assignedTo != "" { + log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, assignedTo) + } + return assignedTo } // notifyAssignment sends notifications to team members about a new workflow assignment. diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go index b8b585d..16fde78 100644 --- a/server/handlers/workflow_test.go +++ b/server/handlers/workflow_test.go @@ -319,7 +319,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness { protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) // Channels (needed for workflow instance creation) - channels := NewChannelHandler() + channels := NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) diff --git a/server/main.go b/server/main.go index d18af00..6e82487 100644 --- a/server/main.go +++ b/server/main.go @@ -20,6 +20,8 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/extraction" + "git.gobha.me/xcaliber/chat-switchboard/filters" + "git.gobha.me/xcaliber/chat-switchboard/sandbox" "git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/knowledge" @@ -37,6 +39,7 @@ import ( sqliteStore "git.gobha.me/xcaliber/chat-switchboard/store/sqlite" "git.gobha.me/xcaliber/chat-switchboard/tools" "git.gobha.me/xcaliber/chat-switchboard/tools/search" + "git.gobha.me/xcaliber/chat-switchboard/treepath" "git.gobha.me/xcaliber/chat-switchboard/workspace" ) @@ -103,6 +106,10 @@ func main() { stores = postgres.NewStores(database.DB) } + // v0.29.0: Wire store layer into treepath for backward compat. + // New code should call stores.Messages.* directly. + treepath.Stores = &stores + // Provider health accumulator (v0.22.0) if database.IsSQLite() { healthStore = sqliteStore.NewHealthStore() @@ -171,43 +178,24 @@ func main() { // Staleness: mark idle active instances as stale cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour) - res, err := database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET workflow_status = 'stale' - WHERE type = 'workflow' - AND workflow_status = 'active' - AND last_activity_at < $1 - `), cutoff) + n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff) if err != nil { log.Printf("⚠ workflow staleness sweep failed: %v", err) - } else if n, _ := res.RowsAffected(); n > 0 { + } else if n > 0 { log.Printf("🧹 workflows: marked %d instances as stale", n) } // v0.27.0: Retention enforcement — delete completed workflow channels // where the parent workflow has retention.mode="delete" and // retention.delete_after_days has elapsed since completion. - res2, err := database.DB.ExecContext(ctx, database.Q(` - DELETE FROM channels - WHERE type = 'workflow' - AND workflow_status IN ('completed', 'archived') - AND workflow_id IS NOT NULL - AND last_activity_at < $1 - AND workflow_id IN ( - SELECT id FROM workflows - WHERE retention IS NOT NULL - AND retention->>'mode' = 'delete' - AND (retention->>'delete_after_days')::int > 0 - AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval - ) - `), cutoff) + n2, err := stores.Channels.EnforceWorkflowRetention(ctx) if err != nil { // SQLite doesn't support JSON operators — skip retention on SQLite if !database.IsSQLite() { log.Printf("⚠ workflow retention enforcement failed: %v", err) } - } else if n, _ := res2.RowsAffected(); n > 0 { - log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n) + } else if n2 > 0 { + log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2) } cancel() @@ -367,6 +355,26 @@ func main() { memScanner.Start() defer memScanner.Stop() + // ── Pre-completion filter chain (v0.29.0) ── + // Built-in filters register here. Starlark extension filters will + // register at package install time (CS3). + filterChain := filters.NewChain() + filterChain.Register(filters.NewKBInjectFilter(stores)) + + // ── Starlark Runner (v0.29.0 CS3) ── + // Sandboxed interpreter for extension scripts. Runner assembles + // modules based on granted permissions. Notifier attached below + // after notification service init. + starlarkRunner := sandbox.NewRunner( + sandbox.New(sandbox.DefaultConfig()), + stores, + ) + + // Discover and register active Starlark pre-completion filters + filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner) + + log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len()) + r := gin.Default() userCache := middleware.NewUserStatusCache() r.Use(middleware.CORS(cfg)) @@ -404,6 +412,7 @@ func main() { } notifSvc.StartCleanup() notifications.SetDefault(notifSvc) + starlarkRunner.SetNotifier(notifSvc) // Subscribe to role.fallback events → generate notifications for admins bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores)) @@ -416,6 +425,7 @@ func main() { scheduler.RegisterBuiltins() exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum) + exec.SetRunner(starlarkRunner) taskSched := scheduler.New(stores, exec) go taskSched.Run() log.Println(" ⏰ Task scheduler started (with executor)") @@ -533,7 +543,7 @@ func main() { }) // Channels - channels := handlers.NewChannelHandler() + channels := handlers.NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) @@ -547,19 +557,19 @@ func main() { channelID := c.Param("id") // Resolve display name var displayName string - _ = database.DB.QueryRow(database.Q(` - SELECT COALESCE(display_name, username) FROM users WHERE id = $1 - `), userID).Scan(&displayName) + user, err := stores.Users.GetByID(c.Request.Context(), userID) + if err == nil && user != nil { + displayName = user.DisplayName + if displayName == "" { + displayName = user.Username + } + } if displayName == "" { displayName = userID[:8] } // Broadcast to other user participants - pRows, err := database.DB.Query(database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 - `), channelID, userID) + pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID) if err == nil { - defer pRows.Close() payload, _ := json.Marshal(map[string]any{ "channel_id": channelID, "user_id": userID, @@ -570,32 +580,30 @@ func main() { Payload: payload, Ts: time.Now().UnixMilli(), } - for pRows.Next() { - var pid string - if pRows.Scan(&pid) == nil { - hub.SendToUser(pid, evt) - } + for _, pid := range pids { + hub.SendToUser(pid, evt) } } c.JSON(200, gin.H{"ok": true}) }) // Chat Folders (v0.23.1) - folders := handlers.NewFolderHandler() + folders := handlers.NewFolderHandler(stores) protected.GET("/folders", folders.List) protected.POST("/folders", folders.Create) protected.PUT("/folders/:id", folders.Update) protected.DELETE("/folders/:id", folders.Delete) // Presence (v0.23.1) - protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat) - protected.GET("/presence", handlers.PresenceQuery) + presence := handlers.NewPresenceHandler(stores) + protected.POST("/presence/heartbeat", presence.Heartbeat) + protected.GET("/presence", presence.Query) // User search (v0.23.2 — DM user picker) - protected.GET("/users/search", handlers.SearchUsers) + protected.GET("/users/search", presence.SearchUsers) // Persona groups (v0.23.2 — roster templates for group chats) - pgH := handlers.NewPersonaGroupHandler() + pgH := handlers.NewPersonaGroupHandler(stores) protected.GET("/persona-groups", pgH.List) protected.POST("/persona-groups", pgH.Create) protected.GET("/persona-groups/:id", pgH.Get) @@ -627,7 +635,7 @@ func main() { protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) // Workflow assignments (v0.26.4 — team assignment queue) - wfAssignH := handlers.NewWorkflowAssignmentHandler(hub) + wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) @@ -676,6 +684,7 @@ func main() { comp.SetHealthStore(healthStore) } comp.SetRoutingEvaluator(routing.NewEvaluator()) + comp.SetFilterChain(filterChain) protected.POST("/chat/completions", comp.Complete) protected.GET("/tools", comp.ListTools) @@ -685,7 +694,7 @@ func main() { // Summarize & Continue (backed by compaction service) compactionSvc := compaction.NewService(stores, roleResolver) - summarize := handlers.NewSummarizeHandler(compactionSvc) + summarize := handlers.NewSummarizeHandler(stores, compactionSvc) protected.POST("/channels/:id/summarize", summarize.Summarize) // Auto-title generation (utility role) @@ -716,7 +725,7 @@ func main() { protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) // User Settings & Profile - settings := handlers.NewSettingsHandler(uekCache) + settings := handlers.NewSettingsHandler(stores, uekCache) protected.GET("/profile", settings.GetProfile) protected.PUT("/profile", settings.UpdateProfile) protected.POST("/profile/password", settings.ChangePassword) @@ -892,7 +901,7 @@ func main() { // Team admin self-service teamScoped := protected.Group("/teams/:teamId") - teamScoped.Use(middleware.RequireTeamAdmin()) + teamScoped.Use(middleware.RequireTeamAdmin(stores.Teams)) { teamScoped.GET("/members", teams.ListMembers) teamScoped.POST("/members", teams.AddMember) @@ -938,7 +947,7 @@ func main() { teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) // Team workflow assignments (v0.26.4) - teamAssignH := handlers.NewWorkflowAssignmentHandler(hub) + teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) teamScoped.GET("/assignments", teamAssignH.ListForTeam) // Team tasks — admin CRUD (v0.27.5) @@ -952,7 +961,7 @@ func main() { // Team task viewing for all members (v0.27.5) teamMemberRoutes := protected.Group("/teams/:teamId") - teamMemberRoutes.Use(middleware.RequireTeamMember()) + teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) { teamMemberTaskH := handlers.NewTaskHandler(stores) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) @@ -1015,8 +1024,8 @@ func main() { admin.POST("/personas", personaAdm.CreateAdminPersona) admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona) admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona) - admin.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar) - admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar) + admin.POST("/personas/:id/avatar", func(c *gin.Context) { handlers.UploadPersonaAvatar(stores.Personas, c) }) + admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { handlers.DeletePersonaAvatar(stores.Personas, c) }) admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0 admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0 @@ -1116,6 +1125,20 @@ func main() { admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension) admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension) + // Extension permissions (admin — v0.29.0) + extPermH := handlers.NewExtPermHandler(stores) + admin.GET("/extensions/:id/permissions", extPermH.ListPackagePermissions) + admin.GET("/extensions/:id/review", extPermH.ReviewPackage) + admin.POST("/extensions/:id/permissions/:perm/grant", extPermH.GrantPermission) + admin.POST("/extensions/:id/permissions/:perm/revoke", extPermH.RevokePermission) + admin.POST("/extensions/:id/permissions/grant-all", extPermH.GrantAllPermissions) + + // Extension secrets (admin — v0.29.0 CS3) + extSecH := handlers.NewExtSecretsHandler(stores) + admin.GET("/extensions/:id/secrets", extSecH.GetSecrets) + admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets) + admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets) + // Provider Health (admin — v0.22.0) healthAdm := handlers.NewHealthAdminHandler(healthStore, stores) admin.GET("/providers/health", healthAdm.GetAllProviderHealth) @@ -1222,6 +1245,7 @@ func main() { wfAPI.GET("/:id/messages", wfMsgs.ListMessages) wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) + wfComp.SetFilterChain(filterChain) wfAPI.POST("/:id/completions", wfComp.Complete) } diff --git a/server/memory/extractor.go b/server/memory/extractor.go index f373d88..3176ef7 100644 --- a/server/memory/extractor.go +++ b/server/memory/extractor.go @@ -7,7 +7,6 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" @@ -70,11 +69,7 @@ func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, pers } // Check extraction log for last processed message - var lastMessageID string - row := database.DB.QueryRowContext(ctx, - database.Q(`SELECT last_message_id FROM memory_extraction_log WHERE channel_id = $1 AND user_id = $2`), - channelID, userID) - row.Scan(&lastMessageID) // ignore error — may not exist yet + lastMessageID, _ := e.stores.Memories.GetLastExtractionMessageID(ctx, channelID, userID) // Load recent messages for this channel messages, err := e.stores.Messages.ListForChannel(ctx, channelID, store.ListOptions{Limit: 200}) @@ -197,25 +192,7 @@ func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, pers // Update extraction log latestID := newMessages[len(newMessages)-1].ID - if database.IsSQLite() { - _, err = database.DB.ExecContext(ctx, ` - INSERT INTO memory_extraction_log (id, channel_id, user_id, last_message_id, memory_count) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(channel_id, user_id) DO UPDATE SET - last_message_id = excluded.last_message_id, - extracted_at = datetime('now'), - memory_count = memory_extraction_log.memory_count + excluded.memory_count - `, store.NewID(), channelID, userID, latestID, saved) - } else { - _, err = database.DB.ExecContext(ctx, ` - INSERT INTO memory_extraction_log (channel_id, user_id, last_message_id, memory_count) - VALUES ($1, $2, $3, $4) - ON CONFLICT(channel_id, user_id) DO UPDATE SET - last_message_id = EXCLUDED.last_message_id, - extracted_at = now(), - memory_count = memory_extraction_log.memory_count + EXCLUDED.memory_count - `, channelID, userID, latestID, saved) - } + err = e.stores.Memories.UpsertExtractionLog(ctx, channelID, userID, latestID, saved) if saved > 0 { log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved) @@ -241,15 +218,7 @@ func (e *Extractor) embedMemory(ctx context.Context, m *models.Memory, userID st } vecJSON, _ := json.Marshal(result.Vectors[0]) - if database.IsSQLite() { - database.DB.ExecContext(ctx, - `UPDATE memories SET embedding = ? WHERE id = ?`, - string(vecJSON), m.ID) - } else { - database.DB.ExecContext(ctx, - `UPDATE memories SET embedding = $1::vector WHERE id = $2`, - string(vecJSON), m.ID) - } + _ = e.stores.Memories.SetEmbedding(ctx, m.ID, string(vecJSON)) } // parseExtractionResponse parses the utility model's JSON response. diff --git a/server/middleware/session_auth.go b/server/middleware/session_auth.go index 0f99380..2c01cd9 100644 --- a/server/middleware/session_auth.go +++ b/server/middleware/session_auth.go @@ -70,12 +70,7 @@ func AuthOrSession(cfg *config.Config, stores store.Stores, cache *UserStatusCac } // Verify channel exists, is workflow type, and allows anonymous - var chType string - var allowAnon bool - err := database.DB.QueryRowContext(c.Request.Context(), - database.Q(`SELECT type, allow_anonymous FROM channels WHERE id = $1`), - channelID, - ).Scan(&chType, &allowAnon) + chType, allowAnon, err := stores.Channels.GetTypeAndAllowAnonymous(c.Request.Context(), channelID) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return diff --git a/server/middleware/team.go b/server/middleware/team.go index bf58e9d..57417a2 100644 --- a/server/middleware/team.go +++ b/server/middleware/team.go @@ -5,16 +5,14 @@ import ( "github.com/gin-gonic/gin" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) -// RequireTeamAdmin returns middleware that restricts access to users who are -// admins of the team identified by :teamId in the URL path. -// System admins (role=admin) are always allowed through. -// Must be used after Auth() middleware. -func RequireTeamAdmin() gin.HandlerFunc { +// RequireTeamAdmin returns middleware that restricts access to team admins. +// System admins are always allowed through. +// v0.29.0: accepts TeamStore instead of using database.DB directly. +func RequireTeamAdmin(teams store.TeamStore) gin.HandlerFunc { return func(c *gin.Context) { - // System admins bypass team check role, _ := c.Get("role") if role == "admin" { c.Next() @@ -30,13 +28,8 @@ func RequireTeamAdmin() gin.HandlerFunc { return } - var teamRole string - err := database.DB.QueryRow(` - SELECT role FROM team_members - WHERE team_id = $1 AND user_id = $2 - `, teamID, userID).Scan(&teamRole) - - if err != nil || teamRole != "admin" { + isAdmin, err := teams.IsTeamAdmin(c.Request.Context(), teamID, userID.(string)) + if err != nil || !isAdmin { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "team admin access required", }) @@ -50,7 +43,8 @@ func RequireTeamAdmin() gin.HandlerFunc { // RequireTeamMember returns middleware that restricts access to users who // belong to the team identified by :teamId (any role). // System admins are always allowed through. -func RequireTeamMember() gin.HandlerFunc { +// v0.29.0: accepts TeamStore instead of using database.DB directly. +func RequireTeamMember(teams store.TeamStore) gin.HandlerFunc { return func(c *gin.Context) { role, _ := c.Get("role") if role == "admin" { @@ -67,12 +61,8 @@ func RequireTeamMember() gin.HandlerFunc { return } - var exists bool - database.DB.QueryRow(` - SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2) - `, teamID, userID).Scan(&exists) - - if !exists { + isMember, _ := teams.IsMember(c.Request.Context(), teamID, userID.(string)) + if !isMember { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "team membership required", }) diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go new file mode 100644 index 0000000..466914b --- /dev/null +++ b/server/models/models_extension_perm.go @@ -0,0 +1,58 @@ +package models + +import "time" + +// ── Package Status Constants ───────────────── + +const ( + // PackageStatusActive means the package is running normally. + PackageStatusActive = "active" + + // PackageStatusPendingReview means the package declared permissions + // that require admin approval before activation. + PackageStatusPendingReview = "pending_review" + + // PackageStatusSuspended means an admin has suspended the package. + PackageStatusSuspended = "suspended" +) + +// ValidPackageStatuses is the set of valid package status values. +var ValidPackageStatuses = map[string]bool{ + PackageStatusActive: true, + PackageStatusPendingReview: true, + PackageStatusSuspended: true, +} + +// ── Extension Permission Constants ─────────── + +const ( + ExtPermSecretsRead = "secrets.read" + ExtPermNotificationsSend = "notifications.send" + ExtPermFiltersPreCompletion = "filters.pre_completion" + ExtPermDBRead = "db.read" + ExtPermDBWrite = "db.write" + ExtPermAPIHTTP = "api.http" +) + +// ValidExtensionPermissions is the set of recognized permission keys. +var ValidExtensionPermissions = map[string]bool{ + ExtPermSecretsRead: true, + ExtPermNotificationsSend: true, + ExtPermFiltersPreCompletion: true, + ExtPermDBRead: true, + ExtPermDBWrite: true, + ExtPermAPIHTTP: true, +} + +// ── Extension Permission Model ─────────────── + +// ExtensionPermission represents a declared/granted capability for a package. +type ExtensionPermission struct { + ID string `json:"id" db:"id"` + PackageID string `json:"package_id" db:"package_id"` + Permission string `json:"permission" db:"permission"` + Granted bool `json:"granted" db:"granted"` + GrantedBy *string `json:"granted_by,omitempty" db:"granted_by"` + GrantedAt *time.Time `json:"granted_at,omitempty" db:"granted_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/server/sandbox/modules.go b/server/sandbox/modules.go new file mode 100644 index 0000000..bc7bf44 --- /dev/null +++ b/server/sandbox/modules.go @@ -0,0 +1,136 @@ +// Package sandbox — modules.go +// +// v0.29.0 CS3: Module factories for Starlark sandbox. +// Each factory returns a starlarkstruct.Module that the runner +// injects into the script namespace based on granted permissions. +package sandbox + +import ( + "context" + "fmt" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ─── Secrets Module ────────────────────────── +// +// Requires permission: secrets.read +// Starlark API: +// val = secrets.get("api_key") → string or None +// all = secrets.list() → list of key names +// +// Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}" +// as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."} +// Admin sets them via PUT /admin/extensions/:id/secrets. + +// BuildSecretsModule creates the "secrets" module scoped to a package. +func BuildSecretsModule(ctx context.Context, stores store.Stores, packageID string) *starlarkstruct.Module { + // Pre-load secrets for this package (single query, not per-call) + secretMap := loadExtensionSecrets(ctx, stores, packageID) + + return MakeModule("secrets", starlark.StringDict{ + "get": starlark.NewBuiltin("secrets.get", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var key string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil { + return nil, err + } + if val, ok := secretMap[key]; ok { + return starlark.String(val), nil + } + return starlark.None, nil + }), + "list": starlark.NewBuiltin("secrets.list", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + keys := make([]starlark.Value, 0, len(secretMap)) + for k := range secretMap { + keys = append(keys, starlark.String(k)) + } + return starlark.NewList(keys), nil + }), + }) +} + +func loadExtensionSecrets(ctx context.Context, stores store.Stores, packageID string) map[string]string { + result := make(map[string]string) + configKey := "ext_secrets:" + packageID + configVal, err := stores.GlobalConfig.Get(ctx, configKey) + if err != nil || configVal == nil { + return result + } + for k, v := range configVal { + if s, ok := v.(string); ok { + result[k] = s + } + } + return result +} + +// ─── Notifications Module ──────────────────── +// +// Requires permission: notifications.send +// Starlark API: +// notifications.send(user_id, title, body="", type="extension.notify") +// +// Creates an in-app notification via the notification store. +// The extension cannot send email or bypass user preferences — +// that's handled by the notification service layer. + +// NotificationSender is the interface the notifications module needs. +// Matches notifications.Service.Notify without importing the package. +type NotificationSender interface { + Notify(ctx context.Context, n *models.Notification) error +} + +// BuildNotificationsModule creates the "notifications" module scoped to a package. +func BuildNotificationsModule(ctx context.Context, sender NotificationSender, packageID string) *starlarkstruct.Module { + return MakeModule("notifications", starlark.StringDict{ + "send": starlark.NewBuiltin("notifications.send", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var userID, title string + var body string + var notifType string = "extension.notify" + + if err := starlark.UnpackArgs(b.Name(), args, kwargs, + "user_id", &userID, + "title", &title, + "body?", &body, + "type?", ¬ifType, + ); err != nil { + return nil, err + } + + if userID == "" { + return nil, fmt.Errorf("notifications.send: user_id is required") + } + if title == "" { + return nil, fmt.Errorf("notifications.send: title is required") + } + + n := &models.Notification{ + UserID: userID, + Type: notifType, + Title: title, + Body: body, + ResourceType: "extension", + ResourceID: packageID, + } + + if err := sender.Notify(ctx, n); err != nil { + return nil, fmt.Errorf("notifications.send failed: %w", err) + } + + return starlark.True, nil + }), + }) +} diff --git a/server/sandbox/modules_test.go b/server/sandbox/modules_test.go new file mode 100644 index 0000000..2da5c14 --- /dev/null +++ b/server/sandbox/modules_test.go @@ -0,0 +1,171 @@ +package sandbox + +import ( + "context" + "testing" + + "go.starlark.net/starlark" +) + +func TestBuildSecretsModule_Get(t *testing.T) { + mod := MakeModule("secrets", starlark.StringDict{ + "get": starlark.NewBuiltin("secrets.get", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var key string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil { + return nil, err + } + if key == "api_key" { + return starlark.String("sk-test123"), nil + } + return starlark.None, nil + }), + }) + + sb := New(DefaultConfig()) + res, err := sb.Exec(context.Background(), "test.star", ` +val = secrets.get("api_key") +missing = secrets.get("nope") +`, map[string]starlark.Value{"secrets": mod}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + val := res.Globals["val"] + if val.(starlark.String).GoString() != "sk-test123" { + t.Fatalf("expected 'sk-test123', got %s", val.String()) + } + missing := res.Globals["missing"] + if missing != starlark.None { + t.Fatalf("expected None, got %s", missing.String()) + } +} + +func TestBuildNotificationsModule_Send(t *testing.T) { + var sentTitle string + var sentUserID string + + sendFn := starlark.NewBuiltin("notifications.send", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var userID, title string + if err := starlark.UnpackArgs(b.Name(), args, kwargs, + "user_id", &userID, "title", &title); err != nil { + return nil, err + } + sentUserID = userID + sentTitle = title + return starlark.True, nil + }) + + mod := MakeModule("notifications", starlark.StringDict{ + "send": sendFn, + }) + + sb := New(DefaultConfig()) + _, err := sb.Exec(context.Background(), "test.star", ` +result = notifications.send(user_id="u-1", title="Hello World") +`, map[string]starlark.Value{"notifications": mod}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if sentUserID != "u-1" { + t.Fatalf("expected user_id 'u-1', got %q", sentUserID) + } + if sentTitle != "Hello World" { + t.Fatalf("expected title 'Hello World', got %q", sentTitle) + } +} + +func TestModuleIsolation(t *testing.T) { + // Script should not be able to access modules it wasn't given + sb := New(DefaultConfig()) + _, err := sb.Exec(context.Background(), "test.star", ` +val = secrets.get("key") +`, nil) // no modules injected + + if err == nil { + t.Fatal("expected error when accessing undefined module") + } +} + +func TestMakeModuleName(t *testing.T) { + mod := MakeModule("test_mod", starlark.StringDict{ + "version": starlark.String("1.0"), + }) + if mod.Name != "test_mod" { + t.Fatalf("expected 'test_mod', got %q", mod.Name) + } +} + +func TestEntryPointCall(t *testing.T) { + sb := New(DefaultConfig()) + + // Simulate a package script with on_pre_completion entry point + res, err := sb.Exec(context.Background(), "ext.star", ` +def on_pre_completion(ctx): + channel = ctx["channel_id"] + return [{"role": "system", "content": "context for " + channel}] +`, nil) + if err != nil { + t.Fatalf("exec error: %v", err) + } + + fn := res.Globals["on_pre_completion"].(starlark.Callable) + + ctxDict := starlark.NewDict(1) + ctxDict.SetKey(starlark.String("channel_id"), starlark.String("ch-abc")) + + val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil) + if err != nil { + t.Fatalf("call error: %v", err) + } + + list, ok := val.(*starlark.List) + if !ok { + t.Fatalf("expected list, got %s", val.Type()) + } + if list.Len() != 1 { + t.Fatalf("expected 1 message, got %d", list.Len()) + } + + item := list.Index(0) + dict, ok := item.(*starlark.Dict) + if !ok { + t.Fatalf("expected dict, got %s", item.Type()) + } + + contentVal, _, _ := dict.Get(starlark.String("content")) + content, _ := starlark.AsString(contentVal) + if content != "context for ch-abc" { + t.Fatalf("expected 'context for ch-abc', got %q", content) + } +} + +func TestEntryPointNoneReturn(t *testing.T) { + sb := New(DefaultConfig()) + + res, err := sb.Exec(context.Background(), "ext.star", ` +def on_pre_completion(ctx): + return None +`, nil) + if err != nil { + t.Fatalf("exec error: %v", err) + } + + fn := res.Globals["on_pre_completion"].(starlark.Callable) + ctxDict := starlark.NewDict(0) + + val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil) + if err != nil { + t.Fatalf("call error: %v", err) + } + + if val != starlark.None { + t.Fatalf("expected None, got %s", val.Type()) + } +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go new file mode 100644 index 0000000..05ca7d8 --- /dev/null +++ b/server/sandbox/runner.go @@ -0,0 +1,131 @@ +// Package sandbox — runner.go +// +// v0.29.0 CS3: Runner loads a package's Starlark script, assembles +// the module set based on granted permissions, and executes it. +// +// The runner is the bridge between the package/permission system +// and the sandboxed Starlark interpreter. +package sandbox + +import ( + "context" + "fmt" + "log" + + "go.starlark.net/starlark" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// Runner executes Starlark package scripts with permission-gated modules. +type Runner struct { + sandbox *Sandbox + stores store.Stores + notifier NotificationSender // nil = notifications module unavailable +} + +// NewRunner creates a runner with the given sandbox and dependencies. +func NewRunner(sb *Sandbox, stores store.Stores) *Runner { + return &Runner{ + sandbox: sb, + stores: stores, + } +} + +// SetNotifier attaches the notification sender for the notifications module. +func (r *Runner) SetNotifier(n NotificationSender) { + r.notifier = n +} + +// ExecPackage loads a package's script from its manifest and executes it +// with modules gated by the package's granted permissions. +// +// Returns the script's globals (which may contain callable entry points +// like on_pre_completion, on_run, etc.) and any captured print output. +func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration) (*Result, error) { + // Only active starlark packages can execute + if pkg.Status != models.PackageStatusActive { + return nil, fmt.Errorf("package %q is %s, not active", pkg.ID, pkg.Status) + } + if pkg.Tier != models.ExtTierStarlark { + return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier) + } + + // Extract script from manifest + script, ok := pkg.Manifest["_starlark_script"].(string) + if !ok || script == "" { + return nil, fmt.Errorf("package %q has no _starlark_script in manifest", pkg.ID) + } + + // Build modules based on granted permissions + modules, err := r.buildModules(ctx, pkg.ID) + if err != nil { + return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err) + } + + log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules)) + + return r.sandbox.Exec(ctx, pkg.ID+".star", script, modules) +} + +// CallEntryPoint executes a package script and calls a named function. +// This is the standard pattern for event-driven extensions: +// 1. Exec the script (defines functions) +// 2. Find the named entry point in globals +// 3. Call it with the provided arguments +// +// Returns the function's return value and captured output. +func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistration, entryPoint string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, string, error) { + result, err := r.ExecPackage(ctx, pkg) + if err != nil { + return nil, "", err + } + + fn, ok := result.Globals[entryPoint] + if !ok { + return nil, result.Output, fmt.Errorf("package %q has no %s function", pkg.ID, entryPoint) + } + + callable, ok := fn.(starlark.Callable) + if !ok { + return nil, result.Output, fmt.Errorf("package %q: %s is not callable", pkg.ID, entryPoint) + } + + val, callOutput, err := r.sandbox.Call(ctx, callable, args, kwargs) + return val, result.Output + callOutput, err +} + +// buildModules assembles the module map based on granted permissions. +func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string]starlark.Value, error) { + if r.stores.ExtPermissions == nil { + return nil, nil + } + + granted, err := r.stores.ExtPermissions.GrantedForPackage(ctx, packageID) + if err != nil { + return nil, err + } + + modules := make(map[string]starlark.Value) + + for _, perm := range granted { + switch perm { + case models.ExtPermSecretsRead: + modules["secrets"] = BuildSecretsModule(ctx, r.stores, packageID) + + case models.ExtPermNotificationsSend: + if r.notifier != nil { + modules["notifications"] = BuildNotificationsModule(ctx, r.notifier, packageID) + } + + // Future permissions (CS4+): + // case models.ExtPermDBRead, models.ExtPermDBWrite: + // modules["db"] = BuildDBModule(...) + // case models.ExtPermAPIHTTP: + // modules["http"] = BuildHTTPModule(...) + } + } + + return modules, nil +} diff --git a/server/sandbox/sandbox.go b/server/sandbox/sandbox.go new file mode 100644 index 0000000..65e302a --- /dev/null +++ b/server/sandbox/sandbox.go @@ -0,0 +1,229 @@ +// Package sandbox — sandboxed Starlark interpreter. +// +// v0.29.0 CS1: Provides execution with timeout, step limits, +// captured output, and injectable predeclared modules. +// +// The sandbox is intentionally restrictive: +// - No file I/O +// - No network access +// - No os/env access +// - No load() statements (modules injected via predeclared) +// - Step limit prevents infinite loops +// - Context-based timeout prevents runaway wall-clock +// +// Extensions interact with the platform through injected Go builtins +// (e.g., secrets.get, notifications.send) — never through Starlark's +// own I/O. This is the eval loop; modules are added in CS3. +package sandbox + +import ( + "context" + "fmt" + "strings" + "sync" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +// ─── Configuration ─────────────────────────── + +// Config controls sandbox resource limits. +type Config struct { + // MaxSteps limits total bytecode operations. 0 = unlimited. + // Default: 1_000_000 (~1M ops, covers most scripts in <100ms). + MaxSteps uint64 + + // Name identifies this sandbox in logs and error messages. + Name string +} + +// DefaultConfig returns production defaults. +func DefaultConfig() Config { + return Config{ + MaxSteps: 1_000_000, + Name: "extension", + } +} + +// ─── Result ────────────────────────────────── + +// Result holds the output of a sandbox execution. +type Result struct { + // Globals contains the top-level names defined by the script. + Globals map[string]starlark.Value + + // Output captures all print() calls made during execution. + Output string + + // Steps is the number of bytecode operations executed. + Steps uint64 +} + +// ─── Sandbox ───────────────────────────────── + +// Sandbox executes Starlark scripts with resource limits and +// injected modules. Safe for concurrent use — each Exec call +// gets its own thread. +type Sandbox struct { + config Config +} + +// New creates a sandbox with the given configuration. +// Set MaxSteps to 0 for unlimited (not recommended in production). +func New(cfg Config) *Sandbox { + if cfg.Name == "" { + cfg.Name = "extension" + } + return &Sandbox{config: cfg} +} + +// Exec executes a Starlark script within the sandbox. +// +// Parameters: +// - ctx: context for timeout/cancellation +// - filename: used in error messages and stack traces +// - source: Starlark source code +// - modules: predeclared modules injected into the script's namespace +// (e.g., {"secrets": secretsModule, "notifications": notifModule}) +// +// The script cannot use load() — all dependencies must be provided +// via the modules parameter. +func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map[string]starlark.Value) (*Result, error) { + var output strings.Builder + var outputMu sync.Mutex + + predeclared := make(starlark.StringDict, len(modules)) + for k, v := range modules { + predeclared[k] = v + } + + thread := &starlark.Thread{ + Name: s.config.Name, + Print: func(_ *starlark.Thread, msg string) { + outputMu.Lock() + defer outputMu.Unlock() + output.WriteString(msg) + output.WriteByte('\n') + }, + Load: func(_ *starlark.Thread, module string) (starlark.StringDict, error) { + return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module) + }, + } + + if s.config.MaxSteps > 0 { + thread.SetMaxExecutionSteps(s.config.MaxSteps) + } + + // Wire context cancellation into the thread. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + thread.Cancel(ctx.Err().Error()) + case <-done: + } + }() + + globals, err := starlark.ExecFile(thread, filename, source, predeclared) + if err != nil { + return nil, wrapError(err) + } + + result := &Result{ + Globals: make(map[string]starlark.Value, len(globals)), + Output: output.String(), + Steps: thread.ExecutionSteps(), + } + for k, v := range globals { + result.Globals[k] = v + } + return result, nil +} + +// Call invokes a Starlark function within the sandbox. +// Used by the task executor and filter runner to call specific +// entry points (e.g., "on_pre_completion", "on_run"). +func (s *Sandbox) Call(ctx context.Context, fn starlark.Callable, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, string, error) { + var output strings.Builder + var outputMu sync.Mutex + + thread := &starlark.Thread{ + Name: s.config.Name + "/call", + Print: func(_ *starlark.Thread, msg string) { + outputMu.Lock() + defer outputMu.Unlock() + output.WriteString(msg) + output.WriteByte('\n') + }, + Load: func(_ *starlark.Thread, module string) (starlark.StringDict, error) { + return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module) + }, + } + + if s.config.MaxSteps > 0 { + thread.SetMaxExecutionSteps(s.config.MaxSteps) + } + + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + thread.Cancel(ctx.Err().Error()) + case <-done: + } + }() + + val, err := starlark.Call(thread, fn, args, kwargs) + if err != nil { + return nil, output.String(), wrapError(err) + } + return val, output.String(), nil +} + +// ─── Module Helper ─────────────────────────── + +// MakeModule creates a starlarkstruct.Module with the given name and +// members. Standard way to expose Go functions to Starlark. +// +// Usage: +// +// mod := sandbox.MakeModule("secrets", starlark.StringDict{ +// "get": starlark.NewBuiltin("secrets.get", secretsGet), +// }) +func MakeModule(name string, members starlark.StringDict) *starlarkstruct.Module { + return &starlarkstruct.Module{ + Name: name, + Members: members, + } +} + +// ─── Error Wrapping ────────────────────────── + +// SandboxError wraps a Starlark execution error with context. +type SandboxError struct { + Err error + Backtrace string +} + +func (e *SandboxError) Error() string { + if e.Backtrace != "" { + return fmt.Sprintf("%s\n%s", e.Err.Error(), e.Backtrace) + } + return e.Err.Error() +} + +func (e *SandboxError) Unwrap() error { return e.Err } + +func wrapError(err error) error { + if err == nil { + return nil + } + se := &SandboxError{Err: err} + if evalErr, ok := err.(*starlark.EvalError); ok { + se.Backtrace = evalErr.Backtrace() + } + return se +} diff --git a/server/sandbox/sandbox_test.go b/server/sandbox/sandbox_test.go new file mode 100644 index 0000000..46822fe --- /dev/null +++ b/server/sandbox/sandbox_test.go @@ -0,0 +1,306 @@ +package sandbox + +import ( + "context" + "strings" + "testing" + "time" + + "go.starlark.net/starlark" +) + +func TestExecBasic(t *testing.T) { + sb := New(DefaultConfig()) + res, err := sb.Exec(context.Background(), "test.star", ` +x = 1 + 2 +name = "hello" +`, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v, ok := res.Globals["x"]; !ok { + t.Fatal("missing global 'x'") + } else if v.String() != "3" { + t.Fatalf("expected x=3, got %s", v.String()) + } + if v, ok := res.Globals["name"]; !ok { + t.Fatal("missing global 'name'") + } else if v.(starlark.String).GoString() != "hello" { + t.Fatalf("expected name='hello', got %s", v.String()) + } +} + +func TestExecPrintCapture(t *testing.T) { + sb := New(DefaultConfig()) + res, err := sb.Exec(context.Background(), "test.star", ` +print("line one") +print("line two") +`, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "line one\nline two\n" + if res.Output != expected { + t.Fatalf("expected output %q, got %q", expected, res.Output) + } +} + +func TestExecStepsTracked(t *testing.T) { + sb := New(DefaultConfig()) + res, err := sb.Exec(context.Background(), "test.star", ` +def work(): + x = 0 + for i in range(100): + x += i + return x +result = work() +`, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Steps == 0 { + t.Fatal("expected steps > 0") + } +} + +func TestExecStepLimitExceeded(t *testing.T) { + sb := New(Config{MaxSteps: 100, Name: "limited"}) + _, err := sb.Exec(context.Background(), "test.star", ` +def work(): + x = 0 + for i in range(10000): + x += i + return x +result = work() +`, nil) + if err == nil { + t.Fatal("expected step limit error") + } + // Error message varies: "Starlark computation cancelled: exceeded ..." + // or "context canceled" — just verify it failed. +} + +func TestExecContextTimeout(t *testing.T) { + // Use step limit of 0 (unlimited) so only context timeout triggers + sb := New(Config{MaxSteps: 0, Name: "timeout"}) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := sb.Exec(ctx, "test.star", ` +def work(): + x = 0 + for i in range(100000000): + x += i + return x +result = work() +`, nil) + if err == nil { + t.Fatal("expected timeout error") + } +} + +func TestExecLoadDisabled(t *testing.T) { + sb := New(DefaultConfig()) + _, err := sb.Exec(context.Background(), "test.star", ` +load("other.star", "foo") +`, nil) + if err == nil { + t.Fatal("expected load error") + } + if !strings.Contains(err.Error(), "disabled") { + t.Fatalf("expected 'disabled' in error, got: %v", err) + } +} + +func TestExecModuleInjection(t *testing.T) { + getFn := starlark.NewBuiltin("test.get", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + return starlark.String("secret_value"), nil + }) + + mod := MakeModule("test", starlark.StringDict{ + "get": getFn, + }) + + sb := New(DefaultConfig()) + res, err := sb.Exec(context.Background(), "test.star", ` +result = test.get() +`, map[string]starlark.Value{"test": mod}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + v, ok := res.Globals["result"] + if !ok { + t.Fatal("missing global 'result'") + } + if v.(starlark.String).GoString() != "secret_value" { + t.Fatalf("expected 'secret_value', got %s", v.String()) + } +} + +func TestExecSyntaxError(t *testing.T) { + sb := New(DefaultConfig()) + _, err := sb.Exec(context.Background(), "bad.star", ` +def broken( +`, nil) + if err == nil { + t.Fatal("expected syntax error") + } + se, ok := err.(*SandboxError) + if !ok { + t.Fatalf("expected *SandboxError, got %T", err) + } + _ = se // syntax errors may not have backtrace +} + +func TestExecRuntimeError(t *testing.T) { + sb := New(DefaultConfig()) + _, err := sb.Exec(context.Background(), "runtime.star", ` +x = 1 / 0 +`, nil) + if err == nil { + t.Fatal("expected runtime error") + } + se, ok := err.(*SandboxError) + if !ok { + t.Fatalf("expected *SandboxError, got %T", err) + } + if se.Backtrace == "" { + t.Fatal("expected backtrace for runtime error") + } +} + +func TestCallFunction(t *testing.T) { + sb := New(DefaultConfig()) + + res, err := sb.Exec(context.Background(), "funcs.star", ` +def greet(name): + return "hello, " + name +`, nil) + if err != nil { + t.Fatalf("exec error: %v", err) + } + + fn := res.Globals["greet"].(starlark.Callable) + val, _, err := sb.Call(context.Background(), fn, + starlark.Tuple{starlark.String("world")}, nil) + if err != nil { + t.Fatalf("call error: %v", err) + } + if val.(starlark.String).GoString() != "hello, world" { + t.Fatalf("expected 'hello, world', got %s", val.String()) + } +} + +func TestCallWithPrint(t *testing.T) { + sb := New(DefaultConfig()) + + res, err := sb.Exec(context.Background(), "funcs.star", ` +def run(): + print("running") + return 42 +`, nil) + if err != nil { + t.Fatalf("exec error: %v", err) + } + + fn := res.Globals["run"].(starlark.Callable) + val, output, err := sb.Call(context.Background(), fn, nil, nil) + if err != nil { + t.Fatalf("call error: %v", err) + } + if val.String() != "42" { + t.Fatalf("expected 42, got %s", val.String()) + } + if !strings.Contains(output, "running") { + t.Fatalf("expected 'running' in output, got %q", output) + } +} + +func TestCallRuntimeError(t *testing.T) { + sb := New(DefaultConfig()) + + res, err := sb.Exec(context.Background(), "funcs.star", ` +def boom(): + return 1 / 0 +`, nil) + if err != nil { + t.Fatalf("exec error: %v", err) + } + + fn := res.Globals["boom"].(starlark.Callable) + _, _, err = sb.Call(context.Background(), fn, nil, nil) + if err == nil { + t.Fatal("expected runtime error from Call") + } + se, ok := err.(*SandboxError) + if !ok { + t.Fatalf("expected *SandboxError, got %T", err) + } + if se.Backtrace == "" { + t.Fatal("expected backtrace") + } +} + +func TestNewDefaults(t *testing.T) { + sb := New(Config{}) + if sb.config.MaxSteps != 0 { + t.Fatalf("expected MaxSteps=0 (unlimited), got %d", sb.config.MaxSteps) + } + if sb.config.Name != "extension" { + t.Fatalf("expected default Name='extension', got %q", sb.config.Name) + } +} + +func TestNewWithDefaultConfig(t *testing.T) { + sb := New(DefaultConfig()) + if sb.config.MaxSteps != 1_000_000 { + t.Fatalf("expected MaxSteps=1000000, got %d", sb.config.MaxSteps) + } +} + +func TestMakeModule(t *testing.T) { + mod := MakeModule("mymod", starlark.StringDict{ + "version": starlark.String("1.0"), + }) + if mod.Name != "mymod" { + t.Fatalf("expected name 'mymod', got %q", mod.Name) + } + v, ok := mod.Members["version"] + if !ok { + t.Fatal("missing 'version' member") + } + if v.(starlark.String).GoString() != "1.0" { + t.Fatalf("expected '1.0', got %s", v.String()) + } +} + +func TestSandboxErrorUnwrap(t *testing.T) { + inner := starlark.String("test").Truth() // just need any value + _ = inner + se := &SandboxError{ + Err: context.DeadlineExceeded, + Backtrace: "", + } + if se.Unwrap() != context.DeadlineExceeded { + t.Fatal("Unwrap should return inner error") + } + if se.Error() != "context deadline exceeded" { + t.Fatalf("unexpected error string: %q", se.Error()) + } +} + +func TestSandboxErrorWithBacktrace(t *testing.T) { + se := &SandboxError{ + Err: context.DeadlineExceeded, + Backtrace: " funcs.star:3:5: in boom", + } + if !strings.Contains(se.Error(), "context deadline exceeded") { + t.Fatal("should contain original error") + } + if !strings.Contains(se.Error(), "funcs.star:3:5") { + t.Fatal("should contain backtrace") + } +} diff --git a/server/scheduler/executor.go b/server/scheduler/executor.go index f8599c1..d0c9049 100644 --- a/server/scheduler/executor.go +++ b/server/scheduler/executor.go @@ -22,6 +22,7 @@ import ( "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/providers" + "git.gobha.me/xcaliber/chat-switchboard/sandbox" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/taskutil" "git.gobha.me/xcaliber/chat-switchboard/tools" @@ -34,6 +35,7 @@ type Executor struct { vault *crypto.KeyResolver hub *events.Hub health handlers.HealthRecorder + runner *sandbox.Runner // v0.29.0: Starlark task execution } // NewExecutor creates a task executor. All fields are optional except stores. @@ -46,6 +48,11 @@ func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub } } +// SetRunner attaches the Starlark sandbox runner for starlark task execution. +func (e *Executor) SetRunner(r *sandbox.Runner) { + e.runner = r +} + // Execute runs a single task to completion. // Called from the scheduler's execute() goroutine. func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) { @@ -63,12 +70,18 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta return } + // v0.29.0: Starlark tasks run a sandboxed extension script. + if task.TaskType == "starlark" { + e.executeStarlark(ctx, task, run, channelID, startTime) + return + } + // ── 1. Resolve provider ──────────────────── providerConfigID := "" if task.ProviderConfigID != nil { providerConfigID = *task.ProviderConfigID } - res, err := handlers.ResolveProviderConfig(e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID) + res, err := handlers.ResolveProviderConfig(e.stores, e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID) if err != nil { e.failRun(ctx, task, run, "provider resolution failed: "+err.Error()) return @@ -265,6 +278,83 @@ func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.Ta } } +// executeStarlark runs a sandboxed Starlark extension script. +// The task's system_function field holds the package ID. +// The script's on_run(ctx) entry point is called with task context. +func (e *Executor) executeStarlark(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) { + if e.runner == nil { + e.failRun(ctx, task, run, "starlark runner not configured") + return + } + + packageID := task.SystemFunction + if packageID == "" { + e.failRun(ctx, task, run, "starlark task missing package_id (system_function field)") + return + } + + // Load the package + pkg, err := e.stores.Packages.Get(ctx, packageID) + if err != nil || pkg == nil { + e.failRun(ctx, task, run, "package not found: "+packageID) + return + } + + // Run the script and call on_run entry point + // Build a context dict with task info + val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil) + + wallClock := int(time.Since(startTime).Seconds()) + status := "completed" + errMsg := "" + result := "" + + if err != nil { + status = "failed" + errMsg = err.Error() + } else if val != nil { + result = val.String() + } + + // Append print output to result + if output != "" { + if result != "" { + result = result + "\n---\n" + output + } else { + result = output + } + } + + // Persist to channel if output_mode == "channel" + if status == "completed" && task.OutputMode == "channel" && e.stores.Messages != nil && result != "" { + _ = e.stores.Messages.Create(ctx, &models.Message{ + ChannelID: channelID, + Role: "system", + Content: "Starlark task output:\n```\n" + result + "\n```", + }) + } + + _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg) + _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) + + log.Printf("[executor] Starlark task %s (%s → %s) → %s (wall=%ds)", task.ID, task.Name, packageID, status, wallClock) + + e.notifyOwner(ctx, task, status, errMsg) + + if task.WebhookURL != "" { + go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ + TaskID: task.ID, + RunID: run.ID, + TaskName: task.Name, + ChannelID: channelID, + Status: status, + CompletedAt: time.Now().UTC(), + Output: result, + Error: errMsg, + }) + } +} + // executeSystem runs a built-in Go function from the system registry. // No LLM, no provider resolution, no channel needed. func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) { diff --git a/server/scheduler/system_builtins.go b/server/scheduler/system_builtins.go index c2b5dc6..59b37ee 100644 --- a/server/scheduler/system_builtins.go +++ b/server/scheduler/system_builtins.go @@ -1,9 +1,7 @@ // Package scheduler — system_builtins.go // // v0.28.6: Built-in system functions registered at startup. -// These mirror the background goroutines in main.go but run as visible, -// scheduled, auditable tasks. The goroutines remain as fallback until -// system tasks are validated in production. +// v0.29.0: Raw SQL replaced with store methods. package scheduler import ( @@ -12,13 +10,11 @@ import ( "log" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/taskutil" ) // RegisterBuiltins registers all built-in system functions. -// Called once from main.go at startup. func RegisterBuiltins() { taskutil.RegisterSystemFunc("session_cleanup", "Remove expired anonymous visitor sessions", @@ -43,7 +39,6 @@ func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) { if stores.Sessions == nil { return "skipped: session store not available", nil } - // Default: expire sessions older than 7 days cutoff := time.Now().UTC().AddDate(0, 0, -7) n, err := stores.Sessions.DeleteExpired(ctx, cutoff) if err != nil { @@ -59,19 +54,11 @@ func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) { // ── staleness_check ───────────────────────── func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) { - // Default: 48 hours idle → stale cutoff := time.Now().UTC().Add(-48 * time.Hour) - res, err := database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET workflow_status = 'stale' - WHERE type = 'workflow' - AND workflow_status = 'active' - AND last_activity_at < $1 - `), cutoff) + n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff) if err != nil { return "", fmt.Errorf("staleness sweep failed: %w", err) } - n, _ := res.RowsAffected() msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339)) if n > 0 { log.Printf("[system_task] %s", msg) @@ -82,28 +69,10 @@ func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) { // ── retention_sweep ───────────────────────── func retentionSweep(ctx context.Context, stores store.Stores) (string, error) { - if database.IsSQLite() { - return "skipped: retention enforcement requires PostgreSQL (JSON operators)", nil - } - cutoff := time.Now().UTC().Add(-48 * time.Hour) - res, err := database.DB.ExecContext(ctx, database.Q(` - DELETE FROM channels - WHERE type = 'workflow' - AND workflow_status IN ('completed', 'archived') - AND workflow_id IS NOT NULL - AND last_activity_at < $1 - AND workflow_id IN ( - SELECT id FROM workflows - WHERE retention IS NOT NULL - AND retention->>'mode' = 'delete' - AND (retention->>'delete_after_days')::int > 0 - AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval - ) - `), cutoff) + n, err := stores.Channels.EnforceWorkflowRetention(ctx) if err != nil { return "", fmt.Errorf("retention enforcement failed: %w", err) } - n, _ := res.RowsAffected() msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n) if n > 0 { log.Printf("[system_task] %s", msg) @@ -114,14 +83,14 @@ func retentionSweep(ctx context.Context, stores store.Stores) (string, error) { // ── health_prune ──────────────────────────── func healthPrune(ctx context.Context, stores store.Stores) (string, error) { + if stores.Health == nil { + return "skipped: health store not available", nil + } cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) - res, err := database.DB.ExecContext(ctx, database.Q(` - DELETE FROM health_windows WHERE window_start < $1 - `), cutoff) + n, err := stores.Health.Prune(ctx, cutoff) if err != nil { return "", fmt.Errorf("health prune failed: %w", err) } - n, _ := res.RowsAffected() msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339)) if n > 0 { log.Printf("[system_task] %s", msg) diff --git a/server/store/extension_perm_iface.go b/server/store/extension_perm_iface.go new file mode 100644 index 0000000..1956976 --- /dev/null +++ b/server/store/extension_perm_iface.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// ExtensionPermissionStore manages declared and granted permissions for packages. +type ExtensionPermissionStore interface { + // DeclareForPackage upserts the declared permissions from a manifest. + // Any permissions in the DB for this package that are NOT in the + // provided list are deleted (manifest is the source of truth). + // Existing grants are preserved on upsert. + DeclareForPackage(ctx context.Context, packageID string, permissions []string) error + + // ListForPackage returns all declared permissions for a package. + ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) + + // GrantedForPackage returns only granted permissions for a package. + // Used at runtime to determine which modules to inject. + GrantedForPackage(ctx context.Context, packageID string) ([]string, error) + + // Grant marks a permission as granted by an admin. + Grant(ctx context.Context, packageID, permission, grantedBy string) error + + // Revoke removes a grant (sets granted=false). + Revoke(ctx context.Context, packageID, permission string) error + + // GrantAll grants all declared permissions for a package. + GrantAll(ctx context.Context, packageID, grantedBy string) error + + // DeleteForPackage removes all permission rows for a package. + // Called when a package is uninstalled. + DeleteForPackage(ctx context.Context, packageID string) error +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 95ac9d6..55e26cc 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "time" @@ -55,6 +56,11 @@ type Stores struct { Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions) Workflows WorkflowStore // v0.26.1: Workflow definitions + stages Tasks TaskStore // v0.27.1: Task scheduling + run history + Presence PresenceStore // v0.29.0: User online/offline status + PersonaGroups PersonaGroupStore // v0.29.0: Persona group (roster template) CRUD + Folders FolderStore // v0.29.0: Chat folder CRUD + Health HealthStore // v0.29.0: Provider health window management + ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities } // ========================================= @@ -75,6 +81,31 @@ type ProviderStore interface { // Access check UserCanAccess(ctx context.Context, userID, configID string) (bool, error) + + // ── CS4 additions (v0.29.0) ── + + // DeletePersonalByOwner removes all personal-scope provider configs for a user. + DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) + + // ── CS6 additions (v0.29.0) ── + + // ListAllForTeam returns all provider configs (including inactive) for a team. + ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) + + // DeleteByIDAndTeam deletes a team-scoped provider by ID and team. Returns rows affected. + DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) + + // ── CS7a additions (v0.29.0) ── + + // FindFirstForUser returns the ID of the first active provider config + // accessible to a user (personal first, then global; excludes team). + // Returns sql.ErrNoRows if none found. + FindFirstForUser(ctx context.Context, userID string) (string, error) + + // LoadAccessible loads a provider config by ID, verifying the user has access + // (global, personal owned by user, or team the user belongs to). + // Returns sql.ErrNoRows if not found or not accessible. + LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) } // ========================================= @@ -103,6 +134,41 @@ type CatalogStore interface { // Delete Delete(ctx context.Context, id string) error DeleteForProvider(ctx context.Context, providerConfigID string) error + + // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── + + // FindEnabledByModelID finds an enabled catalog entry by exact model_id + // (case-insensitive). Returns (modelID, providerConfigID, nil). + // Prefers global > team > personal scope providers. + FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) + + // FindEnabledByModelIDPrefix finds by unambiguous model_id prefix. + // Returns (modelID, configID, matchCount, nil). + FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) + + // ── CS2 additions (v0.29.0) ── + + // GetCapabilities returns the raw capabilities JSON for a specific provider+model. + GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) + + // GetCapabilitiesAny returns capabilities for a model from any provider (most recent sync). + GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) + + // ── CS6 additions (v0.29.0) ── + + // ListTeamAvailable returns catalog entries with visibility 'enabled' or 'team' + // from active global providers. Used by team model selector. + ListTeamAvailable(ctx context.Context) ([]TeamAvailableModel, error) +} + +// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable. +type TeamAvailableModel struct { + ID string + ModelID string + DisplayName *string + Visibility string + Provider string + ProviderName string } // CatalogSyncEntry is the input format from provider FetchModels. @@ -141,6 +207,25 @@ type PersonaStore interface { SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) GetKBIDs(ctx context.Context, personaID string) ([]string, error) + + // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── + + // FindActiveByHandle returns an active persona's ID by exact handle match (case-insensitive). + // Returns "" if not found. + FindActiveByHandle(ctx context.Context, handle string) (string, error) + + // FindActiveByHandlePrefix returns a persona ID by unambiguous handle prefix. + // Returns ("", count, nil) if ambiguous or no match. + FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) + + // GetNameByID returns the persona's display name. Returns "" if not found. + GetNameByID(ctx context.Context, id string) (string, error) + + // GetNamesByIDs returns a map of persona ID → name for batch resolution. + GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) + + // GetDisplayInfoByIDs returns name + avatar for batch sender resolution. + GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error) } // ========================================= @@ -190,6 +275,61 @@ type UserStore interface { RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeAllRefreshTokens(ctx context.Context, userID string) error CleanExpiredTokens(ctx context.Context) error + + // ── Mention resolution (v0.29.0 — moved from handler raw SQL) ── + + // FindActiveByHandle returns a user ID by exact handle match (case-insensitive). + // excludeUserID prevents self-mention resolution. + FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) + + // FindActiveByHandlePrefix returns a user ID by unambiguous handle prefix. + // Returns ("", count, nil) if ambiguous or no match. + FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) + + // GetDisplayInfoByIDs returns name + avatar for batch sender resolution. + GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]UserDisplayInfo, error) + + // ── CS1 additions (v0.29.0) ── + + // Exists returns true if a user with the given ID exists and is active. + Exists(ctx context.Context, userID string) (bool, error) + + // SearchActive returns users matching a query (username, display_name, handle). + // Excludes the calling user. Max 20 results. + SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error) + + // ── CS2 additions (v0.29.0) ── + + // CountByRole returns the number of users with a given role. + CountByRole(ctx context.Context, role string) (int, error) + + // CountAll returns the total number of users. + CountAll(ctx context.Context) (int, error) + + // MergeSettings performs a dialect-aware JSON merge into the user's settings column. + MergeSettings(ctx context.Context, userID string, patch []byte) error + + // GetVaultKeys returns the user's vault encryption state. + GetVaultKeys(ctx context.Context, userID string) (vaultSet bool, encUEK, salt, nonce []byte, err error) + + // UpdateVaultKeys re-wraps the user's vault keys (password change). + UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error + + // ── CS4 additions (v0.29.0) ── + + // ClearVaultKeys nulls out vault columns and sets vault_set = false. + ClearVaultKeys(ctx context.Context, userID string) error + + // InitVaultKeys stores vault keys and sets vault_set = true (first-time init). + InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error +} + +// UserSearchResult is a lightweight result for user search. +type UserSearchResult struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Handle string `json:"handle"` } // ========================================= @@ -213,6 +353,37 @@ type TeamStore interface { GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) IsMember(ctx context.Context, teamID, userID string) (bool, error) + + // ── CS1 additions (v0.29.0) ── + + // Exists returns true if a team with the given ID exists. + Exists(ctx context.Context, teamID string) (bool, error) + + // UpdateMemberRoleByID updates a member's role using the member row ID. + UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) + + // DeleteMemberByID deletes a member using the member row ID. + DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) + + // ListTeamAuditActions returns distinct audit actions for a team's members. + ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) + + // ── CS5b additions (v0.29.0) ── + + // GetFirstTeamIDForUser returns the first team_id the user belongs to, or "" if none. + GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) + + // ── CS6 additions (v0.29.0) ── + + // AddMemberReturningID inserts a team member and returns the row ID. + AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) + + // HasPrivateProviderRequirement checks if a user belongs to any active team + // with the require_private_providers setting enabled. + HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) + + // MergeSettings merges a JSON string into the team's settings column. + MergeSettings(ctx context.Context, teamID, settingsJSON string) error } // ========================================= @@ -260,6 +431,169 @@ type ChannelStore interface { // Admin: archived channel management (v0.23.2) ListArchived(ctx context.Context, opts ListOptions) ([]ArchivedChannel, int, error) Purge(ctx context.Context, id string) error // hard-delete; channel must be archived + + // ── Single-field helpers (v0.29.0 — moved from handler raw SQL) ── + + // GetAIMode returns the channel's ai_mode ("auto", "off", "mention_only"). + GetAIMode(ctx context.Context, channelID string) (string, error) + + // GetTypeAndTeamID returns (channel_type, team_id) in one query. + GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) + + // GetSystemPrompt returns the channel's system_prompt (may be nil). + GetSystemPrompt(ctx context.Context, channelID string) (*string, error) + + // GetDefaultModel returns the channel's model field (may be nil). + GetDefaultModel(ctx context.Context, channelID string) (*string, error) + + // TouchUpdatedAt bumps the channel's updated_at to now. + TouchUpdatedAt(ctx context.Context, channelID string) error + + // ListUserParticipantIDs returns user participant IDs, excluding excludeUserID. + ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) + + // ListPersonaParticipantIDs returns persona participant IDs ordered by created_at. + ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) + + // GetLeaderPersonaID returns the persona group leader for a channel. + // Falls back to the first persona participant if no leader flag is set. + // Returns empty string if no persona participants exist. + GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) + + // GetWorkflowInfo returns (workflow_id, current_stage) for workflow-type channels. + // Returns nil workflow_id if the channel is not a workflow channel. + GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) + + // ── DM + lifecycle helpers (v0.29.0-cs1) ── + + // FindExistingDM returns the channel ID of an existing DM between two users. + // Returns "" if no DM exists. + FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) + + // GetUnreadCount returns the number of unread messages for a user in a channel. + GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) + + // DeleteByOwner deletes a channel if the user is the owner. + // Returns rows affected (0 = not found or not owner). + DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) + + // MarkRead updates last_read_at and last_read_message_id for a user. + MarkRead(ctx context.Context, channelID, userID string) error + + // CountParticipantsByType returns count of participants of a given type. + CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) + + // CountAll returns the total number of channels. + CountAll(ctx context.Context) (int, error) + + // ── Workflow instance state (v0.29.0-cs3) ── + + // SetWorkflowInstance initializes workflow columns on a channel. + SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error + + // GetWorkflowStatus returns the full workflow state for a workflow channel. + GetWorkflowStatus(ctx context.Context, channelID string) (*WorkflowChannelStatus, error) + + // AdvanceWorkflowStage updates current_stage + stage_data + last_activity_at. + AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error + + // CompleteWorkflow sets workflow_status='completed' and ai_mode='off'. + CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error + + // RejectWorkflowToStage resets current_stage (no stage_data change). + RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error + + // GetStageData returns the current stage_data JSON from a channel. + GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) + + // ── Background job helpers (v0.29.0-cs4) ── + + // MarkStaleWorkflows sets workflow_status='stale' on active workflow channels + // with last_activity_at before cutoff. Returns rows affected. + MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) + + // EnforceWorkflowRetention deletes completed workflow channels whose parent + // workflow has retention.mode="delete" and the retention period has elapsed. + // PG-only (uses JSON operators). Returns rows affected. No-op on SQLite. + EnforceWorkflowRetention(ctx context.Context) (int64, error) + + // GetTypeAndAllowAnonymous returns (type, allow_anonymous) for session auth. + GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) + + // ── CS5b additions (v0.29.0) ── + + // FindCompactionCandidates returns direct channels eligible for auto-compaction. + // Filters by activity gap, max age, minimum message count, and minimum total chars. + // PG-only (uses settings::text cast). Returns empty slice on SQLite. + FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) + + // ── CS7a additions (v0.29.0) ── + + // GetProviderConfigID returns the channel's provider_config_id (may be nil). + GetProviderConfigID(ctx context.Context, channelID string) (*string, error) + + // UserCanAccess checks if a user is the channel owner or a participant. + UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) + + // ── CS7b additions (v0.29.0) ── + + // ListFiltered returns paginated channels with message counts, applying + // complex filters (types, folder, search, project). Includes channels + // owned by the user or where the user is a participant. + ListFiltered(ctx context.Context, userID string, filter ChannelListFilter) ([]ChannelListItem, int, error) + + // GetForUser loads a single channel with message count, verifying the + // user is the owner or a participant. Returns sql.ErrNoRows if not found. + GetForUser(ctx context.Context, channelID, userID string) (*ChannelListItem, error) + + // CreateFull atomically creates a channel, adds the owner as a participant, + // optionally adds DM partner participants, and creates a default channel_model. + // The channel model (ch) is populated with ID, CreatedAt, UpdatedAt on return. + // folder, tags, and aiMode are passed separately as they're not in models.Channel. + CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, + ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error + + // MergeSettings merges a JSON object into the channel's settings column. + MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error +} + +// ChannelListFilter holds filter options for ChannelStore.ListFiltered. +type ChannelListFilter struct { + ListOptions + Archived bool + Types []string // empty = all + Folder string + FolderID string + Search string // title ILIKE/LIKE + ProjectID string // "none" = NULL, uuid = specific, "" = no filter +} + +// ChannelListItem is returned by ListFiltered and GetForUser. +type ChannelListItem struct { + ID string + UserID string + Title string + Type string + AiMode string + Topic *string + Description *string + Model *string + ProviderConfigID *string + SystemPrompt *string + IsArchived bool + IsPinned bool + Folder *string + FolderID *string + ProjectID *string + WorkspaceID *string + Tags []string + Settings json.RawMessage + MessageCount int + UnreadCount int + CreatedAt string + UpdatedAt string + CreatedAtTime time.Time `json:"-"` // SQLite scan target + UpdatedAtTime time.Time `json:"-"` // SQLite scan target } // ArchivedChannel is a view model for the admin archived channels list. @@ -291,6 +625,89 @@ type MessageStore interface { // Count CountForChannel(ctx context.Context, channelID string) (int, error) + + // ── Tree operations (v0.29.0 — moved from treepath package) ── + + // GetActiveLeaf returns the cursor's active_leaf_id for a user in a channel. + // Falls back to the chronologically latest live message if no cursor exists. + GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) + + // GetPathToLeaf walks from a leaf up to root via parent_id, returning + // root-first order with sibling counts and sender info resolved. + GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]PathMessage, error) + + // GetActivePath is GetActiveLeaf + GetPathToLeaf combined. + GetActivePath(ctx context.Context, channelID, userID string) ([]PathMessage, error) + + // GetSiblingsList returns all live siblings of a message with the current index. + GetSiblingsList(ctx context.Context, messageID string) ([]SiblingInfo, int, error) + + // GetSiblingCount returns how many live siblings share the same parent. + GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) + + // FindLeafFromMessage walks down from a message to find the deepest descendant. + FindLeafFromMessage(ctx context.Context, messageID string) (string, error) + + // NextSiblingIndexForParent returns the next sibling_index for children of parentID. + NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) + + // HasPersonaMessages checks if any assistant message in the channel was + // generated by a persona. + HasPersonaMessages(ctx context.Context, channelID string) (bool, error) + + // CreateWithCursor inserts a message, updates the cursor, and touches + // channel.updated_at. Handles dialect-specific ID generation internally. + CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error + + // ResolveSenderInfo batch-resolves sender_name and sender_avatar for + // user and persona participants. Updates the PathMessage slice in-place. + ResolveSenderInfo(ctx context.Context, path []PathMessage) error + + // CountAll returns the total number of messages (for admin stats). + CountAll(ctx context.Context) (int, error) + + // ── CS5c additions (v0.29.0) ── + + // SearchInChannel performs full-text search within a single channel's messages. + // PG uses to_tsvector/ts_headline; SQLite falls back to LIKE. + // roleFilter is "user", "assistant", or "" for all. + SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]ChannelSearchResult, error) + + // ── CS7a additions (v0.29.0) ── + + // ListWithSenderInfo returns paginated messages with sender name/avatar + // resolved via JOINs to users and personas tables. + ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]MessageWithSender, int, error) + + // GetParentAndRole loads just the parent_id and role of a message, + // verifying it belongs to the given channel and is not deleted. + GetParentAndRole(ctx context.Context, messageID, channelID string) (parentID *string, role string, err error) +} + +// MessageWithSender is returned by MessageStore.ListWithSenderInfo. +type MessageWithSender struct { + ID string + ChannelID string + Role string + Content string + Model *string + TokensUsed *int + ParentID *string + SiblingIndex int + ParticipantType *string + ParticipantID *string + SenderName *string + SenderAvatar *string + CreatedAt string +} + +// ChannelSearchResult is returned by MessageStore.SearchInChannel. +type ChannelSearchResult struct { + MessageID string + Role string + Excerpt string // ts_headline or content substring + Rank float64 + Timestamp time.Time } // ========================================= @@ -311,6 +728,7 @@ type AuditListOptions struct { ResourceID string Since *time.Time Until *time.Time + TeamID string // CS6: scope to team members } // ========================================= @@ -326,6 +744,22 @@ type NoteStore interface { Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) + + // ── CS5c additions (v0.29.0) ── + + // SetEmbedding stores a pgvector embedding on a note. PG-only; SQLite no-ops. + SetEmbedding(ctx context.Context, noteID, vecStr string) error + + // SearchKeyword performs full-text keyword search with ranking and headlines. + // PG uses ts_rank/ts_headline; SQLite falls back to LIKE. + SearchKeyword(ctx context.Context, userID, query string, limit int) ([]NoteSearchResult, error) + + // SearchSemantic performs vector similarity search against note embeddings. + // PG-only; SQLite returns empty results. + SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]NoteSearchResult, error) + + // ListFolders returns distinct folder paths and note counts for a user. + ListFolders(ctx context.Context, userID string) ([]FolderInfo, error) } type NoteListOptions struct { @@ -335,6 +769,23 @@ type NoteListOptions struct { TeamID string } +// NoteSearchResult is returned by NoteStore.SearchKeyword and SearchSemantic. +type NoteSearchResult struct { + ID string + Title string + FolderPath string + Tags []string + Excerpt string // truncated content preview + Headline string // ts_headline markup (empty on SQLite) + Rank float64 // ts_rank or similarity score +} + +// FolderInfo is returned by NoteStore.ListFolders. +type FolderInfo struct { + Path string + Count int +} + // ========================================= // NOTE LINK STORE // ========================================= @@ -362,6 +813,24 @@ type GlobalConfigStore interface { Get(ctx context.Context, key string) (models.JSONMap, error) Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error GetAll(ctx context.Context) (map[string]models.JSONMap, error) + + // ── OIDC state (v0.29.0-cs4) ── + + // SaveOIDCState stores a state+nonce pair for OIDC callback verification. + SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error + + // ConsumeOIDCState retrieves and deletes an OIDC state (one-time use). + // Returns (nonce, redirectTo, error). Returns sql.ErrNoRows if not found. + ConsumeOIDCState(ctx context.Context, state string) (nonce, redirectTo string, err error) + + // CleanupOIDCState removes stale OIDC states older than 10 minutes. + CleanupOIDCState(ctx context.Context) error + + // ── CS6 additions (v0.29.0) ── + + // GetString returns the raw value column as a string for simple settings + // (e.g. bare JSON booleans like "true"/"false"). + GetString(ctx context.Context, key string) (string, error) } // ========================================= @@ -419,6 +888,11 @@ type FileStore interface { DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys UserUsageBytes(ctx context.Context, userID string) (int64, error) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.File, error) + + // ── CS5c additions (v0.29.0) ── + + // UpdateStorageKey sets the storage key after initial file creation. + UpdateStorageKey(ctx context.Context, id, key string) error } @@ -652,6 +1126,70 @@ type SessionStore interface { DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) } +// ========================================= +// PERSONA GROUP STORE (v0.29.0) +// ========================================= + +// PersonaGroupStore manages persona_groups and their members. +type PersonaGroupStore interface { + // CRUD + List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) + Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) + Create(ctx context.Context, g *models.PersonaGroup) error + Update(ctx context.Context, id string, fields map[string]interface{}) error + Delete(ctx context.Context, id, ownerID string) (int64, error) + + // Ownership check + GetOwnerID(ctx context.Context, id string) (string, error) + + // Members + AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error + RemoveMember(ctx context.Context, memberID, groupID string) error + ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) +} + +// ========================================= +// FOLDER STORE (v0.29.0) +// ========================================= + +// FolderStore manages user chat folders. +type FolderStore interface { + List(ctx context.Context, userID string) ([]models.Folder, error) + Create(ctx context.Context, f *models.Folder) error + Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) + Delete(ctx context.Context, folderID, userID string) (int64, error) + // UnassignChannels removes folder_id from all channels in this folder. + UnassignChannels(ctx context.Context, folderID, userID string) error +} + +// ========================================= +// HEALTH STORE (v0.29.0) +// ========================================= + +// HealthStore manages provider health windows. The full interface is used +// by the health accumulator and status querier; the scheduler only needs Prune. +type HealthStore interface { + // Prune deletes health windows older than the given time. Returns rows affected. + Prune(ctx context.Context, before time.Time) (int64, error) +} + +// ========================================= +// PRESENCE STORE (v0.29.0) +// ========================================= + +// PresenceStore manages user online/offline status. +type PresenceStore interface { + // Heartbeat upserts the user's last_seen timestamp to now. + Heartbeat(ctx context.Context, userID string) error + + // GetLastSeen returns the user's last heartbeat time, or nil if never seen. + GetLastSeen(ctx context.Context, userID string) (*time.Time, error) + + // GetStatuses returns online/offline status for a list of user IDs. + // Users with last_seen after threshold are "online", otherwise "offline". + GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) +} + // ========================================= // SHARED TYPES // ========================================= diff --git a/server/store/package_iface.go b/server/store/package_iface.go index 145f128..84aa4f4 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -25,6 +25,10 @@ type PackageStore interface { // SetEnabled toggles a package's enabled state. SetEnabled(ctx context.Context, id string, enabled bool) error + // SetStatus transitions a package's lifecycle status. + // Valid statuses: active, pending_review, suspended. + SetStatus(ctx context.Context, id string, status string) error + // Delete removes a non-core package. Core packages cannot be deleted. Delete(ctx context.Context, id string) error @@ -79,6 +83,7 @@ type PackageRegistration struct { InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"` Manifest map[string]any `json:"manifest" db:"manifest"` Enabled bool `json:"enabled" db:"enabled"` + Status string `json:"status" db:"status"` Source string `json:"source" db:"source"` InstalledAt string `json:"installed_at" db:"installed_at"` UpdatedAt string `json:"updated_at" db:"updated_at"` diff --git a/server/store/postgres/audit.go b/server/store/postgres/audit.go index 4656759..f2ce027 100644 --- a/server/store/postgres/audit.go +++ b/server/store/postgres/audit.go @@ -30,6 +30,9 @@ func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]m al.ip_address, al.user_agent, al.created_at`, "audit_log al") b.Join("LEFT JOIN users u ON al.actor_id = u.id") + if opts.TeamID != "" { + b.Where("al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)", opts.TeamID) + } if opts.ActorID != "" { b.Where("al.actor_id = ?", opts.ActorID) } diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go index ff9f0fc..34a79ec 100644 --- a/server/store/postgres/catalog.go +++ b/server/store/postgres/catalog.go @@ -281,3 +281,58 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) { } return result, rows.Err() } + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) { + var capsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT capabilities FROM model_catalog + WHERE model_id = $1 AND provider_config_id = $2 + `, modelID, configID).Scan(&capsJSON) + if err != nil { + return nil, err + } + return capsJSON, nil +} + +func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) { + var capsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT capabilities FROM model_catalog + WHERE model_id = $1 ORDER BY last_synced_at DESC LIMIT 1 + `, modelID).Scan(&capsJSON) + if err != nil { + return nil, err + } + return capsJSON, nil +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, + ac.provider, ac.name AS provider_name + FROM model_catalog mc + JOIN provider_configs ac ON mc.provider_config_id = ac.id + WHERE mc.visibility IN ('enabled', 'team') + AND ac.is_active = true AND ac.scope = 'global' + ORDER BY ac.name, mc.model_id + `) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.TeamAvailableModel, 0) + for rows.Next() { + var m store.TeamAvailableModel + if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, + &m.Provider, &m.ProviderName); err != nil { + continue + } + results = append(results, m) + } + return results, rows.Err() +} diff --git a/server/store/postgres/catalog_mention.go b/server/store/postgres/catalog_mention.go new file mode 100644 index 0000000..726f381 --- /dev/null +++ b/server/store/postgres/catalog_mention.go @@ -0,0 +1,57 @@ +package postgres + +import ( + "context" + "database/sql" +) + +// ── Mention resolution (v0.29.0) ──────────────────────────────────────── + +func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) { + var foundModelID, configID string + err := DB.QueryRowContext(ctx, ` + SELECT mc.model_id, mc.provider_config_id + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) = LOWER($1) + AND mc.visibility = 'enabled' + AND pc.is_active = true + ORDER BY + CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END + LIMIT 1 + `, modelID).Scan(&foundModelID, &configID) + if err == sql.ErrNoRows { + return "", "", nil + } + return foundModelID, configID, err +} + +func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT mc.model_id) + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) LIKE LOWER($1) + AND mc.visibility = 'enabled' + AND pc.is_active = true + `, prefix+"%").Scan(&count) + if err != nil { + return "", "", 0, err + } + if count != 1 { + return "", "", count, nil + } + var modelID, configID string + err = DB.QueryRowContext(ctx, ` + SELECT mc.model_id, mc.provider_config_id + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) LIKE LOWER($1) + AND mc.visibility = 'enabled' + AND pc.is_active = true + ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END + LIMIT 1 + `, prefix+"%").Scan(&modelID, &configID) + return modelID, configID, 1, err +} diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go index 0b9e183..4ef53bd 100644 --- a/server/store/postgres/channel.go +++ b/server/store/postgres/channel.go @@ -6,6 +6,9 @@ import ( "encoding/json" "fmt" "strings" + "time" + + "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -471,3 +474,492 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error { _, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = $1`, id) return err } +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) { + var channelID string + err := DB.QueryRowContext(ctx, ` + SELECT cp1.channel_id FROM channel_participants cp1 + JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id + JOIN channels c ON c.id = cp1.channel_id + WHERE c.type = 'dm' + AND cp1.participant_type = 'user' AND cp1.participant_id = $1 + AND cp2.participant_type = 'user' AND cp2.participant_id = $2 + LIMIT 1 + `, userID1, userID2).Scan(&channelID) + if err == sql.ErrNoRows { + return "", nil + } + return channelID, err +} + +func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages m + JOIN channel_participants cp ON cp.channel_id = m.channel_id + WHERE cp.channel_id = $1 + AND cp.participant_type = 'user' AND cp.participant_id = $2 + AND m.created_at > cp.last_read_at + `, channelID, userID).Scan(&count) + return count, err +} + +func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM channels WHERE id = $1 AND user_id = $2`, + channelID, userID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { + // Update last_read_at + _, err := DB.ExecContext(ctx, ` + UPDATE channel_participants + SET last_read_at = NOW() + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 + `, channelID, userID) + if err != nil { + return nil // participant may not exist for legacy chats + } + + // Best-effort: update last_read_message_id + var latestMsgID *string + _ = DB.QueryRowContext(ctx, ` + SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 + `, channelID).Scan(&latestMsgID) + if latestMsgID != nil { + _, _ = DB.ExecContext(ctx, ` + UPDATE channel_participants + SET last_read_message_id = $1 + WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 + `, *latestMsgID, channelID, userID) + } + + return nil +} + +func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM channel_participants + WHERE channel_id = $1 AND participant_type = $2 + `, channelID, pType).Scan(&count) + return count, err +} + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ChannelStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count) + return count, err +} + +// ── Workflow instance state (v0.29.0-cs3) ─────────────────────────────── + +func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET workflow_id = $1, workflow_version = $2, current_stage = 0, + stage_data = $3, workflow_status = $4, last_activity_at = $5 + WHERE id = $6 + `, workflowID, version, stageData, status, time.Now().UTC(), channelID) + return err +} + +func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) { + var ws store.WorkflowChannelStatus + var stageData []byte + err := DB.QueryRowContext(ctx, ` + SELECT workflow_id, workflow_version, current_stage, + COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), + last_activity_at + FROM channels WHERE id = $1 AND type = 'workflow' + `, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, + &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + ws.StageData = stageData + return &ws, nil +} + +func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET current_stage = $1, stage_data = $2, last_activity_at = $3 + WHERE id = $4 + `, nextStage, stageData, time.Now().UTC(), channelID) + return err +} + +func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET current_stage = $1, workflow_status = 'completed', + stage_data = $2, last_activity_at = $3, ai_mode = 'off' + WHERE id = $4 + `, finalStage, stageData, time.Now().UTC(), channelID) + return err +} + +func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3 + `, stage, time.Now().UTC(), channelID) + return err +} + +func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) { + var data json.RawMessage + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1 + `, channelID).Scan(&data) + return data, err +} + +// ── Background job helpers (v0.29.0-cs4) ──────────────────────────────── + +func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) { + result, err := DB.ExecContext(ctx, ` + UPDATE channels + SET workflow_status = 'stale' + WHERE type = 'workflow' + AND workflow_status = 'active' + AND last_activity_at < $1 + `, cutoff) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) { + result, err := DB.ExecContext(ctx, ` + DELETE FROM channels + WHERE type = 'workflow' + AND workflow_status IN ('completed', 'archived') + AND workflow_id IS NOT NULL + AND workflow_id IN ( + SELECT id FROM workflows + WHERE retention IS NOT NULL + AND retention->>'mode' = 'delete' + AND (retention->>'delete_after_days')::int > 0 + AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval + ) + `) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) { + var chType string + var allowAnon bool + err := DB.QueryRowContext(ctx, + `SELECT type, allow_anonymous FROM channels WHERE id = $1`, channelID).Scan(&chType, &allowAnon) + return chType, allowAnon, err +} + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'), + COUNT(m.id) AS msg_count, + COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars + FROM channels c + JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL + WHERE c.type = 'direct' + AND c.is_archived = false + AND c.updated_at < $1 + AND c.updated_at > $2 + GROUP BY c.id + HAVING COUNT(m.id) >= $3 + AND COALESCE(SUM(LENGTH(m.content)), 0) > $4 + ORDER BY c.updated_at DESC + LIMIT $5 + `, activityBefore, createdAfter, minMessages, minChars, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.Channel + for rows.Next() { + var ch models.Channel + var settingsRaw string + var msgCount, totalChars int + if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Model, &settingsRaw, &msgCount, &totalChars); err != nil { + continue + } + ch.Settings = models.JSONMap{} + _ = json.Unmarshal([]byte(settingsRaw), &ch.Settings) + result = append(result, ch) + } + if result == nil { + result = []models.Channel{} + } + return result, rows.Err() +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) { + var configID sql.NullString + err := DB.QueryRowContext(ctx, + `SELECT provider_config_id FROM channels WHERE id = $1`, channelID).Scan(&configID) + if err != nil { + return nil, err + } + return NullableStringPtr(configID), nil +} + +func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) { + var ok bool + err := DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM channels WHERE id = $1 AND user_id = $2 + UNION ALL + SELECT 1 FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 + LIMIT 1 + )`, channelID, userID).Scan(&ok) + return ok, err +} + +// ── CS7b additions (v0.29.0) ──────────────────────────────────────────── + +const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, + c.description, c.model, c.provider_config_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, + c.tags, c.settings, + COALESCE(mc.cnt, 0) AS message_count, + c.created_at, c.updated_at` + +const channelListFrom = `channels c + LEFT JOIN ( + SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id + ) mc ON mc.channel_id = c.id` + +func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) { + b := NewSelect(channelListCols, channelListFrom) + b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID) + b.Where("c.is_archived = ?", f.Archived) + + if len(f.Types) == 1 { + b.Where("c.type = ?", f.Types[0]) + } else if len(f.Types) > 1 { + placeholders := make([]string, len(f.Types)) + for i, t := range f.Types { + b.argIdx++ + placeholders[i] = fmt.Sprintf("$%d", b.argIdx) + b.args = append(b.args, t) + } + b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")") + } + if f.Folder != "" { + b.Where("c.folder = ?", f.Folder) + } + if f.FolderID != "" { + b.Where("c.folder_id = ?", f.FolderID) + } + if f.Search != "" { + b.Where("c.title ILIKE ?", "%"+f.Search+"%") + } + if f.ProjectID == "none" { + b.WhereRaw("c.project_id IS NULL") + } else if f.ProjectID != "" { + b.Where("c.project_id = ?", f.ProjectID) + } + + // Count + countQ, countArgs := b.CountBuild() + var total int + DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) + + b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC") + b.Paginate(f.ListOptions) + + q, args := b.Build() + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items, err := scanChannelListItems(rows) + if err != nil { + return nil, 0, err + } + + // Compute unread counts + for i := range items { + DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages m + JOIN channel_participants cp ON cp.channel_id = m.channel_id + WHERE cp.channel_id = $1 + AND cp.participant_type = 'user' AND cp.participant_id = $2 + AND m.created_at > cp.last_read_at + AND m.deleted_at IS NULL + `, items[i].ID, userID).Scan(&items[i].UnreadCount) + } + + return items, total, nil +} + +func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) { + row := DB.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM %s + WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN ( + SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2 + )) + `, channelListCols, channelListFrom), channelID, userID) + + var item store.ChannelListItem + var tags []byte + var settings []byte + err := row.Scan( + &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, + &item.Description, &item.Model, &item.ProviderConfigID, + &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, + &tags, &settings, + &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, + ) + if err != nil { + return nil, err + } + item.Tags = scanTagsBytes(tags) + item.Settings = safeJSONBytes(settings) + item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") + item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") + return &item, nil +} + +func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, + ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if tags == nil { + tags = []string{} + } + + // Insert channel + err = tx.QueryRowContext(ctx, ` + INSERT INTO channels (user_id, title, type, description, model, system_prompt, + provider_config_id, folder, folder_id, tags, ai_mode) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, created_at, updated_at`, + ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt, + models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID), + pq.Array(tags), aiMode, + ).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt) + if err != nil { + return fmt.Errorf("CreateFull insert channel: %w", err) + } + + // Add owner participant + _, err = tx.ExecContext(ctx, ` + INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) + VALUES ($1, 'user', $2, 'owner') + ON CONFLICT DO NOTHING`, ch.ID, ownerUserID) + if err != nil { + return fmt.Errorf("CreateFull add owner: %w", err) + } + + // Add DM partner participants + for _, pid := range dmPartnerIDs { + if pid == ownerUserID { + continue + } + _, _ = tx.ExecContext(ctx, ` + INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) + VALUES ($1, 'user', $2, 'member') + ON CONFLICT DO NOTHING`, ch.ID, pid) + } + + // Auto-create channel_model if model specified + if defaultModel != "" { + _, _ = tx.ExecContext(ctx, ` + INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default) + VALUES ($1, $2, $3, true) + ON CONFLICT DO NOTHING`, ch.ID, defaultModel, models.NullString(&defaultConfigID)) + } + + return tx.Commit() +} + +func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error { + _, err := DB.ExecContext(ctx, + `UPDATE channels SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`, + []byte(settingsJSON), channelID) + return err +} + +// ── CS7b helpers ──────────────────────────── + +func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { + var result []store.ChannelListItem + for rows.Next() { + var item store.ChannelListItem + var tags []byte + var settings []byte + err := rows.Scan( + &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, + &item.Description, &item.Model, &item.ProviderConfigID, + &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, + &tags, &settings, + &item.MessageCount, &item.CreatedAtTime, &item.UpdatedAtTime, + ) + if err != nil { + return nil, err + } + item.Tags = scanTagsBytes(tags) + item.Settings = safeJSONBytes(settings) + item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") + item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") + result = append(result, item) + } + return result, rows.Err() +} + +func scanTagsBytes(b []byte) []string { + if len(b) == 0 { + return []string{} + } + // Try JSON array first (SQLite path), then PG text[] format + var arr []string + if json.Unmarshal(b, &arr) == nil { + if arr == nil { + return []string{} + } + return arr + } + // PG text[] format: {tag1,tag2} + s := strings.TrimPrefix(strings.TrimSuffix(string(b), "}"), "{") + if s == "" { + return []string{} + } + return strings.Split(s, ",") +} + +func safeJSONBytes(b []byte) json.RawMessage { + if len(b) == 0 || !json.Valid(b) { + return json.RawMessage("{}") + } + cp := make([]byte, len(b)) + copy(cp, b) + return json.RawMessage(cp) +} diff --git a/server/store/postgres/channel_helpers.go b/server/store/postgres/channel_helpers.go new file mode 100644 index 0000000..214a6ab --- /dev/null +++ b/server/store/postgres/channel_helpers.go @@ -0,0 +1,146 @@ +package postgres + +import ( + "context" + "database/sql" +) + +// ── Single-field helpers (v0.29.0) ────────────────────────────────────── +// Moved from handlers/completion.go and handlers/messages.go raw SQL. + +func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) { + var aiMode string + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 + `, channelID).Scan(&aiMode) + if err != nil { + return "auto", err + } + return aiMode, nil +} + +func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) { + var channelType string + var teamID *string + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1 + `, channelID).Scan(&channelType, &teamID) + return channelType, teamID, err +} + +func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) { + var prompt *string + err := DB.QueryRowContext(ctx, ` + SELECT system_prompt FROM channels WHERE id = $1 + `, channelID).Scan(&prompt) + if err == sql.ErrNoRows { + return nil, nil + } + return prompt, err +} + +func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) { + var model *string + err := DB.QueryRowContext(ctx, ` + SELECT model FROM channels WHERE id = $1 + `, channelID).Scan(&model) + if err == sql.ErrNoRows { + return nil, nil + } + return model, err +} + +func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error { + _, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) + return err +} + +func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 + `, channelID, excludeUserID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if ids == nil { + ids = []string{} + } + return ids, rows.Err() +} + +func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'persona' + ORDER BY created_at + `, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if ids == nil { + ids = []string{} + } + return ids, rows.Err() +} + +func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) { + // Try group leader first + var leaderID string + err := DB.QueryRowContext(ctx, ` + SELECT cp.participant_id + FROM channel_participants cp + JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id + WHERE cp.channel_id = $1 + AND cp.participant_type = 'persona' + AND pgm.is_leader = true + LIMIT 1 + `, channelID).Scan(&leaderID) + if err == nil && leaderID != "" { + return leaderID, nil + } + + // Fallback: first persona participant + err = DB.QueryRowContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'persona' + ORDER BY created_at LIMIT 1 + `, channelID).Scan(&leaderID) + if err == sql.ErrNoRows { + return "", nil + } + return leaderID, err +} + +func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) { + var workflowID *string + var currentStage int + err := DB.QueryRowContext(ctx, ` + SELECT workflow_id, COALESCE(current_stage, 0) + FROM channels WHERE id = $1 AND type = 'workflow' + `, channelID).Scan(&workflowID, ¤tStage) + if err == sql.ErrNoRows { + return nil, 0, nil + } + return workflowID, currentStage, err +} diff --git a/server/store/postgres/extension_permissions.go b/server/store/postgres/extension_permissions.go new file mode 100644 index 0000000..ab5e22f --- /dev/null +++ b/server/store/postgres/extension_permissions.go @@ -0,0 +1,169 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type ExtensionPermissionStore struct { + db *sql.DB +} + +func NewExtensionPermissionStore(db *sql.DB) *ExtensionPermissionStore { + return &ExtensionPermissionStore{db: db} +} + +func (s *ExtensionPermissionStore) DeclareForPackage(ctx context.Context, packageID string, permissions []string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Remove permissions no longer in manifest + if len(permissions) == 0 { + _, err = tx.ExecContext(ctx, `DELETE FROM extension_permissions WHERE package_id = $1`, packageID) + if err != nil { + return err + } + return tx.Commit() + } + + // Build set of current declared + declared := make(map[string]bool, len(permissions)) + for _, p := range permissions { + declared[p] = true + } + + // Get existing + rows, err := tx.QueryContext(ctx, + `SELECT permission FROM extension_permissions WHERE package_id = $1`, packageID) + if err != nil { + return err + } + existing := make(map[string]bool) + for rows.Next() { + var perm string + if err := rows.Scan(&perm); err != nil { + rows.Close() + return err + } + existing[perm] = true + } + rows.Close() + + // Delete removed + for perm := range existing { + if !declared[perm] { + _, err = tx.ExecContext(ctx, + `DELETE FROM extension_permissions WHERE package_id = $1 AND permission = $2`, + packageID, perm) + if err != nil { + return err + } + } + } + + // Upsert new (preserving existing grants) + for _, perm := range permissions { + if !existing[perm] { + _, err = tx.ExecContext(ctx, + `INSERT INTO extension_permissions (id, package_id, permission) + VALUES (gen_random_uuid(), $1, $2) + ON CONFLICT (package_id, permission) DO NOTHING`, + packageID, perm) + if err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (s *ExtensionPermissionStore) ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, package_id, permission, granted, granted_by, granted_at, created_at + FROM extension_permissions + WHERE package_id = $1 + ORDER BY permission`, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var perms []models.ExtensionPermission + for rows.Next() { + var p models.ExtensionPermission + if err := rows.Scan(&p.ID, &p.PackageID, &p.Permission, &p.Granted, &p.GrantedBy, &p.GrantedAt, &p.CreatedAt); err != nil { + return nil, err + } + perms = append(perms, p) + } + if perms == nil { + perms = []models.ExtensionPermission{} + } + return perms, nil +} + +func (s *ExtensionPermissionStore) GrantedForPackage(ctx context.Context, packageID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT permission FROM extension_permissions + WHERE package_id = $1 AND granted = true + ORDER BY permission`, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var perms []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + perms = append(perms, p) + } + if perms == nil { + perms = []string{} + } + return perms, nil +} + +func (s *ExtensionPermissionStore) Grant(ctx context.Context, packageID, permission, grantedBy string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = true, granted_by = $1, granted_at = $2 + WHERE package_id = $3 AND permission = $4`, + grantedBy, now, packageID, permission) + return err +} + +func (s *ExtensionPermissionStore) Revoke(ctx context.Context, packageID, permission string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = false, granted_by = NULL, granted_at = NULL + WHERE package_id = $1 AND permission = $2`, + packageID, permission) + return err +} + +func (s *ExtensionPermissionStore) GrantAll(ctx context.Context, packageID, grantedBy string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = true, granted_by = $1, granted_at = $2 + WHERE package_id = $3 AND granted = false`, + grantedBy, now, packageID) + return err +} + +func (s *ExtensionPermissionStore) DeleteForPackage(ctx context.Context, packageID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM extension_permissions WHERE package_id = $1`, packageID) + return err +} diff --git a/server/store/postgres/file.go b/server/store/postgres/file.go index dbee68b..2fe9cd2 100644 --- a/server/store/postgres/file.go +++ b/server/store/postgres/file.go @@ -249,3 +249,12 @@ func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([ } return out, rows.Err() } + +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error { + _, err := DB.ExecContext(ctx, + `UPDATE files SET storage_key = $1, updated_at = NOW() WHERE id = $2`, + key, id) + return err +} diff --git a/server/store/postgres/folder.go b/server/store/postgres/folder.go new file mode 100644 index 0000000..d7c3903 --- /dev/null +++ b/server/store/postgres/folder.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type FolderStore struct{} + +func NewFolderStore() *FolderStore { return &FolderStore{} } + +func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, name, parent_id, sort_order, created_at, updated_at + FROM folders WHERE user_id = $1 + ORDER BY sort_order, name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.Folder + for rows.Next() { + var f models.Folder + if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, + &f.CreatedAt, &f.UpdatedAt); err != nil { + continue + } + f.UserID = userID + result = append(result, f) + } + if result == nil { + result = []models.Folder{} + } + return result, rows.Err() +} + +func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO folders (user_id, name, sort_order) + VALUES ($1, $2, $3) + RETURNING id, name, parent_id, sort_order, created_at, updated_at + `, f.UserID, f.Name, f.SortOrder).Scan( + &f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt) +} + +func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE folders + SET name = COALESCE(NULLIF($3, ''), name), + sort_order = COALESCE($4, sort_order) + WHERE id = $1 AND user_id = $2 + `, folderID, userID, name, sortOrder) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM folders WHERE id = $1 AND user_id = $2`, folderID, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error { + _, err := DB.ExecContext(ctx, + `UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2`, folderID, userID) + return err +} diff --git a/server/store/postgres/global_config.go b/server/store/postgres/global_config.go index 12bc0b8..3d3ee1c 100644 --- a/server/store/postgres/global_config.go +++ b/server/store/postgres/global_config.go @@ -57,3 +57,40 @@ func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONM } return result, rows.Err() } + +// ── OIDC state (v0.29.0-cs4) ──────────────────────────────────────────── + +func (s *GlobalConfigStore) SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES ($1, $2, $3) + `, state, nonce, redirectTo) + return err +} + +func (s *GlobalConfigStore) ConsumeOIDCState(ctx context.Context, state string) (string, string, error) { + var nonce, redirectTo string + err := DB.QueryRowContext(ctx, ` + SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = $1 + `, state).Scan(&nonce, &redirectTo) + if err != nil { + return "", "", err + } + // Delete (one-time use) + _, _ = DB.ExecContext(ctx, `DELETE FROM oidc_auth_state WHERE state = $1`, state) + return nonce, redirectTo, nil +} + +func (s *GlobalConfigStore) CleanupOIDCState(ctx context.Context) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM oidc_auth_state WHERE created_at < NOW() - INTERVAL '10 minutes'`) + return err +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *GlobalConfigStore) GetString(ctx context.Context, key string) (string, error) { + var val string + err := DB.QueryRowContext(ctx, + "SELECT value FROM global_settings WHERE key = $1", key).Scan(&val) + return val, err +} diff --git a/server/store/postgres/memory.go b/server/store/postgres/memory.go index bcb39b5..c8224a3 100644 --- a/server/store/postgres/memory.go +++ b/server/store/postgres/memory.go @@ -270,3 +270,34 @@ func scanMemories(rows *sql.Rows) ([]models.Memory, error) { // ensure compile-time interface satisfaction var _ store.MemoryStore = (*MemoryStore)(nil) + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error { + _, err := DB.ExecContext(ctx, + `UPDATE memories SET embedding = $1::vector WHERE id = $2`, embedding, id) + return err +} + +func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) { + var lastID string + err := DB.QueryRowContext(ctx, + `SELECT last_message_id FROM memory_extraction_log WHERE channel_id = $1 AND user_id = $2`, + channelID, userID).Scan(&lastID) + if err != nil { + return "", nil // no entry yet + } + return lastID, nil +} + +func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO memory_extraction_log (channel_id, user_id, last_message_id, memory_count) + VALUES ($1, $2, $3, $4) + ON CONFLICT(channel_id, user_id) DO UPDATE SET + last_message_id = EXCLUDED.last_message_id, + extracted_at = now(), + memory_count = memory_extraction_log.memory_count + EXCLUDED.memory_count + `, channelID, userID, lastMessageID, count) + return err +} diff --git a/server/store/postgres/message.go b/server/store/postgres/message.go index 9f9049f..9e0c300 100644 --- a/server/store/postgres/message.go +++ b/server/store/postgres/message.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -136,7 +137,9 @@ func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]m m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at FROM messages m JOIN path p ON m.id = p.parent_id ) - SELECT * FROM path ORDER BY created_at ASC`, messageID) + SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, + parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at + FROM path ORDER BY created_at ASC`, messageID) if err != nil { return nil, err } @@ -189,3 +192,115 @@ func scanMessages(rows *sql.Rows) ([]models.Message, error) { } return result, rows.Err() } + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *MessageStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count) + return count, err +} + +// ── CS5c additions (v0.29.0) ───────────────────────────────────────────── + +func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) { + roleClause := "" + queryArgs := []interface{}{channelID, query, limit} + if roleFilter == "user" || roleFilter == "assistant" { + roleClause = "AND m.role = $4" + queryArgs = append(queryArgs, roleFilter) + } + + rows, err := DB.QueryContext(ctx, fmt.Sprintf(` + SELECT m.id, m.role, + ts_headline('english', m.content, plainto_tsquery('english', $2), + 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline, + ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank, + m.created_at + FROM messages m + WHERE m.channel_id = $1 + AND m.deleted_at IS NULL + AND m.role IN ('user', 'assistant') + AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2) + %s + ORDER BY rank DESC, m.created_at DESC + LIMIT $3 + `, roleClause), queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.ChannelSearchResult, 0) + for rows.Next() { + var r store.ChannelSearchResult + if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &r.Rank, &r.Timestamp); err != nil { + continue + } + results = append(results, r) + } + return results, rows.Err() +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) { + var total int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`, + channelID).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err := DB.QueryContext(ctx, ` + SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, + m.sibling_index, m.participant_type, m.participant_id, + CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) + WHEN m.participant_type = 'persona' THEN p.name + ELSE NULL END AS sender_name, + CASE WHEN m.participant_type = 'user' THEN u.avatar_url + WHEN m.participant_type = 'persona' THEN p.avatar + ELSE NULL END AS sender_avatar, + m.created_at + FROM messages m + LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text + LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text + WHERE m.channel_id = $1 AND m.deleted_at IS NULL + ORDER BY m.created_at ASC + LIMIT $2 OFFSET $3 + `, channelID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + results := make([]store.MessageWithSender, 0) + for rows.Next() { + var m store.MessageWithSender + if err := rows.Scan( + &m.ID, &m.ChannelID, &m.Role, &m.Content, + &m.Model, &m.TokensUsed, &m.ParentID, + &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, + &m.SenderName, &m.SenderAvatar, + &m.CreatedAt, + ); err != nil { + return nil, total, err + } + results = append(results, m) + } + return results, total, rows.Err() +} + +func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) { + var parentID sql.NullString + var role string + err := DB.QueryRowContext(ctx, ` + SELECT parent_id, role FROM messages + WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL + `, messageID, channelID).Scan(&parentID, &role) + if err != nil { + return nil, "", err + } + return NullableStringPtr(parentID), role, nil +} \ No newline at end of file diff --git a/server/store/postgres/message_tree.go b/server/store/postgres/message_tree.go new file mode 100644 index 0000000..a80aeca --- /dev/null +++ b/server/store/postgres/message_tree.go @@ -0,0 +1,383 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Tree Operations (v0.29.0) ─────────────────────────────────────────── +// Moved from treepath package. All message tree traversal goes through +// the store interface now. + +func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) { + var leafID *string + + // Try cursor first + err := DB.QueryRowContext(ctx, ` + SELECT active_leaf_id FROM channel_cursors + WHERE channel_id = $1 AND user_id = $2 + `, channelID, userID).Scan(&leafID) + + if err == nil && leafID != nil { + // Verify the leaf still exists and isn't deleted + var exists bool + DB.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) + `, *leafID).Scan(&exists) + if exists { + return leafID, nil + } + } + + // Fallback: latest live message in channel + var fallbackID string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM messages + WHERE channel_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1 + `, channelID).Scan(&fallbackID) + if err == sql.ErrNoRows { + return nil, nil // empty channel + } + if err != nil { + return nil, err + } + return &fallbackID, nil +} + +func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) { + rows, err := DB.QueryContext(ctx, ` + WITH RECURSIVE path AS ( + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, + participant_type, participant_id, sibling_index, created_at, + 0 AS depth + FROM messages + WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL + + UNION ALL + + SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, + m.participant_type, m.participant_id, m.sibling_index, m.created_at, + p.depth + 1 + FROM messages m + JOIN path p ON m.id = p.parent_id + WHERE m.deleted_at IS NULL + ) + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, + participant_type, participant_id, sibling_index, created_at + FROM path + ORDER BY depth DESC + `, leafID, channelID) + if err != nil { + return nil, fmt.Errorf("GetPathToLeaf: %w", err) + } + defer rows.Close() + + var path []store.PathMessage + for rows.Next() { + var m store.PathMessage + var participantType, participantID sql.NullString + var toolCallsJSON, metadataJSON []byte + if err := rows.Scan( + &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, + &toolCallsJSON, &metadataJSON, + &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) + } + if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { + raw := json.RawMessage(toolCallsJSON) + m.ToolCalls = &raw + } + if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { + raw := json.RawMessage(metadataJSON) + m.Metadata = &raw + } + if participantType.Valid { + m.ParticipantType = participantType.String + } + if participantID.Valid { + m.ParticipantID = participantID.String + } + path = append(path, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Enrich with sibling counts + for i := range path { + count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID) + path[i].SiblingCount = count + } + + // Resolve sender info + _ = s.ResolveSenderInfo(ctx, path) + + return path, nil +} + +func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) { + leafID, err := s.GetActiveLeaf(ctx, channelID, userID) + if err != nil { + return nil, err + } + if leafID == nil { + return []store.PathMessage{}, nil + } + return s.GetPathToLeaf(ctx, channelID, *leafID) +} + +func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) { + // Get parent_id and channel_id of the target message + var parentID *string + var channelID string + err := DB.QueryRowContext(ctx, ` + SELECT parent_id, channel_id FROM messages + WHERE id = $1 AND deleted_at IS NULL + `, messageID).Scan(&parentID, &channelID) + if err != nil { + return nil, 0, fmt.Errorf("message not found: %w", err) + } + + var rows *sql.Rows + if parentID == nil { + rows, err = DB.QueryContext(ctx, ` + SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at + FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, channelID) + } else { + rows, err = DB.QueryContext(ctx, ` + SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at + FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, *parentID) + } + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var siblings []store.SiblingInfo + currentIdx := 0 + for i := 0; rows.Next(); i++ { + var si store.SiblingInfo + if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil { + return nil, 0, err + } + if si.ID == messageID { + currentIdx = i + } + siblings = append(siblings, si) + } + + return siblings, currentIdx, rows.Err() +} + +func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) { + var count int + var err error + if parentID == nil { + err = DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&count) + } else { + err = DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + `, *parentID).Scan(&count) + } + if err != nil || count == 0 { + return 1, nil // minimum 1 (the message itself) + } + return count, nil +} + +func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) { + var leafID string + err := DB.QueryRowContext(ctx, ` + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth + FROM messages + WHERE id = $1 AND deleted_at IS NULL + + UNION ALL + + SELECT child.id, d.depth + 1 + FROM messages child + JOIN descendants d ON child.parent_id = d.id + WHERE child.deleted_at IS NULL + AND child.sibling_index = ( + SELECT MIN(sibling_index) FROM messages + WHERE parent_id = d.id AND deleted_at IS NULL + ) + ) + SELECT id FROM descendants + ORDER BY depth DESC + LIMIT 1 + `, messageID).Scan(&leafID) + + if err != nil { + return messageID, nil // fallback to message itself + } + return leafID, nil +} + +func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) { + var maxIdx sql.NullInt64 + var err error + if parentID == nil { + err = DB.QueryRowContext(ctx, ` + SELECT MAX(sibling_index) FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&maxIdx) + } else { + err = DB.QueryRowContext(ctx, ` + SELECT MAX(sibling_index) FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + `, *parentID).Scan(&maxIdx) + } + if err != nil || !maxIdx.Valid { + return 0, nil + } + return int(maxIdx.Int64) + 1, nil +} + +func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM messages + WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona' + LIMIT 1 + `, channelID).Scan(&id) + return err == nil && id != "", nil +} + +func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error { + // Insert message — PG generates ID via gen_random_uuid() + err := DB.QueryRowContext(ctx, ` + INSERT INTO messages (channel_id, role, content, model, tokens_used, + tool_calls, parent_id, participant_type, participant_id, + provider_config_id, sibling_index) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11) + RETURNING id, created_at`, + m.ChannelID, m.Role, m.Content, safeModel(m.Model), m.TokensUsed, + ToJSON(m.ToolCalls), models.NullString(m.ParentID), + m.ParticipantType, m.ParticipantID, + safeString(m.ProviderConfigID), m.SiblingIndex, + ).Scan(&m.ID, &m.CreatedAt) + if err != nil { + return fmt.Errorf("CreateWithCursor insert: %w", err) + } + + // Update cursor + if cursorUserID != "" { + _, _ = DB.ExecContext(ctx, ` + INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) + VALUES ($1, $2, $3) + ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW() + `, m.ChannelID, cursorUserID, m.ID) + } + + // Touch channel + _, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = NOW() WHERE id = $1`, m.ChannelID) + + return nil +} + +func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error { + // Collect unique participant IDs by type + userIDs := map[string]bool{} + personaIDs := map[string]bool{} + for _, m := range path { + if m.ParticipantID == "" { + continue + } + switch m.ParticipantType { + case "user": + userIDs[m.ParticipantID] = true + case "persona": + personaIDs[m.ParticipantID] = true + } + } + + // Resolve users + userNames := map[string]string{} + userAvatars := map[string]string{} + for uid := range userIDs { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 + `, uid).Scan(&name, &avatar) + if name.Valid { + userNames[uid] = name.String + } + if avatar.Valid { + userAvatars[uid] = avatar.String + } + } + + // Resolve personas + personaNames := map[string]string{} + personaAvatars := map[string]string{} + for pid := range personaIDs { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT name, avatar FROM personas WHERE id = $1 + `, pid).Scan(&name, &avatar) + if name.Valid { + personaNames[pid] = name.String + } + if avatar.Valid { + personaAvatars[pid] = avatar.String + } + } + + // Apply to path + for i := range path { + pid := path[i].ParticipantID + switch path[i].ParticipantType { + case "user": + if n, ok := userNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := userAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + case "persona": + if n, ok := personaNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := personaAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + } + } + + return nil +} + +// ── helpers ───────────────────────────────── + +func safeModel(m string) interface{} { + if m == "" { + return "" + } + return m +} + +func safeString(s *string) interface{} { + if s == nil || *s == "" { + return nil + } + return *s +} diff --git a/server/store/postgres/note.go b/server/store/postgres/note.go index 5529ff9..d410c97 100644 --- a/server/store/postgres/note.go +++ b/server/store/postgres/note.go @@ -219,3 +219,100 @@ func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limi } return results, rows.Err() } + +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { + _, err := DB.ExecContext(ctx, + `UPDATE notes SET embedding = $1::vector WHERE id = $2`, + vecStr, noteID) + return err +} + +func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, title, folder_path, tags, LEFT(content, 500), + ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, + ts_headline('english', content, plainto_tsquery('english', $2), + 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline + FROM notes + WHERE user_id = $1 + AND search_vector @@ plainto_tsquery('english', $2) + ORDER BY rank DESC + LIMIT $3 + `, userID, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.NoteSearchResult, 0) + for rows.Next() { + var r store.NoteSearchResult + var dbTags pq.StringArray + if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { + continue + } + r.Tags = []string(dbTags) + if r.Tags == nil { + r.Tags = []string{} + } + results = append(results, r) + } + return results, rows.Err() +} + +func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, title, folder_path, tags, LEFT(content, 500), + 1 - (embedding <=> $2::vector) AS similarity + FROM notes + WHERE user_id = $1 + AND embedding IS NOT NULL + AND 1 - (embedding <=> $2::vector) > 0.3 + ORDER BY embedding <=> $2::vector + LIMIT $3 + `, userID, vecStr, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.NoteSearchResult, 0) + for rows.Next() { + var r store.NoteSearchResult + var dbTags pq.StringArray + if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &dbTags, &r.Excerpt, &r.Rank); err != nil { + continue + } + r.Tags = []string(dbTags) + if r.Tags == nil { + r.Tags = []string{} + } + results = append(results, r) + } + return results, rows.Err() +} + +func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT folder_path, COUNT(*) AS count + FROM notes WHERE user_id = $1 + GROUP BY folder_path + ORDER BY folder_path + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.FolderInfo, 0) + for rows.Next() { + var f store.FolderInfo + if err := rows.Scan(&f.Path, &f.Count); err != nil { + continue + } + results = append(results, f) + } + return results, rows.Err() +} diff --git a/server/store/postgres/packages.go b/server/store/postgres/packages.go index 65972c7..b2d8794 100644 --- a/server/store/postgres/packages.go +++ b/server/store/postgres/packages.go @@ -51,6 +51,20 @@ func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) return nil } +func (s *PackageStore) SetStatus(ctx context.Context, id string, status string) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET status = $2, updated_at = NOW() WHERE id = $1`, + id, status) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + func (s *PackageStore) Delete(ctx context.Context, id string) error { result, err := DB.ExecContext(ctx, `DELETE FROM packages WHERE id = $1 AND source != 'core'`, id) @@ -87,15 +101,18 @@ func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) { func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error { manifestJSON := ToJSON(pkg.Manifest) + if pkg.Status == "" { + pkg.Status = "active" + } return DB.QueryRowContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, - is_system, scope, team_id, installed_by, manifest, enabled, source) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + is_system, scope, team_id, installed_by, manifest, enabled, status, source) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING installed_at, updated_at`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), - manifestJSON, pkg.Enabled, pkg.Source, + manifestJSON, pkg.Enabled, pkg.Status, pkg.Source, ).Scan(&pkg.InstalledAt, &pkg.UpdatedAt) } @@ -155,7 +172,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &up.ID, &up.Title, &up.Type, &up.Version, &up.Description, &up.Author, &up.Tier, &up.IsSystem, &up.Scope, &teamID, &installedBy, - &manifestJSON, &up.Enabled, &up.Source, + &manifestJSON, &up.Enabled, &up.Status, &up.Source, &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { @@ -218,7 +235,7 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str // so column additions don't silently break positional Scan(). const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, - p.manifest, p.enabled, p.source, p.installed_at, p.updated_at` + p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration @@ -228,7 +245,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &pkg.Enabled, &pkg.Source, + &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { @@ -259,7 +276,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &pkg.Enabled, &pkg.Source, + &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err diff --git a/server/store/postgres/persona_group.go b/server/store/postgres/persona_group.go new file mode 100644 index 0000000..c57f086 --- /dev/null +++ b/server/store/postgres/persona_group.go @@ -0,0 +1,138 @@ +package postgres + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type PersonaGroupStore struct{} + +func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} } + +const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at` + +func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT `+personaGroupCols+` FROM persona_groups + WHERE owner_id = $1 ORDER BY name + `, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + + groups := []models.PersonaGroup{} + for rows.Next() { + var g models.PersonaGroup + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil { + continue + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) { + var g models.PersonaGroup + err := DB.QueryRowContext(ctx, ` + SELECT `+personaGroupCols+` FROM persona_groups WHERE id = $1 AND owner_id = $2 + `, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &g, nil +} + +func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO persona_groups (name, description, owner_id, scope) + VALUES ($1, $2, $3, $4) + RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at + `, g.Name, g.Description, g.OwnerID, g.Scope).Scan( + &g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) +} + +func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { + for k, v := range fields { + _, err := DB.ExecContext(ctx, + `UPDATE persona_groups SET `+k+` = $1, updated_at = NOW() WHERE id = $2`, v, id) + if err != nil { + return err + } + } + return nil +} + +func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2`, id, ownerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) { + var ownerID string + err := DB.QueryRowContext(ctx, + `SELECT owner_id FROM persona_groups WHERE id = $1`, id).Scan(&ownerID) + if err == sql.ErrNoRows { + return "", nil + } + return ownerID, err +} + +func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error { + if isLeader { + _, _ = DB.ExecContext(ctx, + `UPDATE persona_group_members SET is_leader = false WHERE group_id = $1`, groupID) + } + _, err := DB.ExecContext(ctx, ` + INSERT INTO persona_group_members (group_id, persona_id, is_leader) + VALUES ($1, $2, $3) + ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader + `, groupID, personaID, isLeader) + return err +} + +func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2`, memberID, groupID) + return err +} + +func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, + COALESCE(p.name, '') AS persona_name, + COALESCE(p.handle, '') AS persona_handle, + COALESCE(p.avatar, '') AS persona_avatar + FROM persona_group_members pgm + LEFT JOIN personas p ON p.id = pgm.persona_id + WHERE pgm.group_id = $1 + ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id + `, groupID) + if err != nil { + return []models.PersonaGroupMember{}, nil + } + defer rows.Close() + + members := []models.PersonaGroupMember{} + for rows.Next() { + var m models.PersonaGroupMember + if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, + &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { + continue + } + members = append(members, m) + } + return members, rows.Err() +} diff --git a/server/store/postgres/persona_mention.go b/server/store/postgres/persona_mention.go new file mode 100644 index 0000000..d12c72a --- /dev/null +++ b/server/store/postgres/persona_mention.go @@ -0,0 +1,80 @@ +package postgres + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Mention resolution + display info (v0.29.0) ──────────────────────── + +func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM personas + WHERE LOWER(handle) = LOWER($1) AND is_active = true + LIMIT 1 + `, handle).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true + `, prefix+"%").Scan(&count) + if err != nil { + return "", 0, err + } + if count != 1 { + return "", count, nil + } + var id string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER($1) AND is_active = true + `, prefix+"%").Scan(&id) + return id, 1, err +} + +func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) { + var name string + err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + return name, err +} + +func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) { + result := make(map[string]string) + for _, id := range ids { + var name string + err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = $1`, id).Scan(&name) + if err == nil && name != "" { + result[id] = name + } + } + return result, nil +} + +func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { + result := make(map[string]store.UserDisplayInfo) + for _, id := range ids { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT name, avatar FROM personas WHERE id = $1 + `, id).Scan(&name, &avatar) + if name.Valid { + info := store.UserDisplayInfo{Name: name.String} + if avatar.Valid { + info.Avatar = avatar.String + } + result[id] = info + } + } + return result, nil +} diff --git a/server/store/postgres/presence.go b/server/store/postgres/presence.go new file mode 100644 index 0000000..26c22d4 --- /dev/null +++ b/server/store/postgres/presence.go @@ -0,0 +1,50 @@ +package postgres + +import ( + "context" + "database/sql" + "time" +) + +// PresenceStore manages user_presence table. +type PresenceStore struct{} + +func NewPresenceStore() *PresenceStore { return &PresenceStore{} } + +func (s *PresenceStore) Heartbeat(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO user_presence (user_id, last_seen, status) + VALUES ($1, NOW(), 'online') + ON CONFLICT (user_id) DO UPDATE + SET last_seen = NOW(), status = 'online' + `, userID) + return err +} + +func (s *PresenceStore) GetLastSeen(ctx context.Context, userID string) (*time.Time, error) { + var lastSeen time.Time + err := DB.QueryRowContext(ctx, + `SELECT last_seen FROM user_presence WHERE user_id = $1`, userID).Scan(&lastSeen) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &lastSeen, nil +} + +func (s *PresenceStore) GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) { + result := make(map[string]string, len(userIDs)) + for _, id := range userIDs { + var lastSeen time.Time + err := DB.QueryRowContext(ctx, + `SELECT last_seen FROM user_presence WHERE user_id = $1`, id).Scan(&lastSeen) + if err != nil || lastSeen.Before(threshold) { + result[id] = "offline" + } else { + result[id] = "online" + } + } + return result, nil +} diff --git a/server/store/postgres/project.go b/server/store/postgres/project.go index ddd2027..5e1a716 100644 --- a/server/store/postgres/project.go +++ b/server/store/postgres/project.go @@ -8,6 +8,7 @@ import ( "github.com/lib/pq" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── ProjectStore ─────────────────────────── @@ -395,6 +396,45 @@ func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID str return projectID, err } +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT p.id, p.name, p.description, p.scope, + p.owner_id, p.team_id, p.is_archived, + p.created_at, p.updated_at, + (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), + (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), + (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), + COALESCE(u.username, '') + FROM projects p + LEFT JOIN users u ON u.id = p.owner_id + WHERE ($1 OR p.is_archived = false) + ORDER BY p.updated_at DESC`, includeArchived) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.AdminProject, 0) + for rows.Next() { + var p store.AdminProject + var teamID sql.NullString + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.Scope, + &p.OwnerID, &teamID, &p.IsArchived, + &p.CreatedAt, &p.UpdatedAt, + &p.ChannelCount, &p.KBCount, &p.NoteCount, + &p.OwnerName, + ); err != nil { + return nil, err + } + p.TeamID = NullableStringPtr(teamID) + results = append(results, p) + } + return results, rows.Err() +} + // ── Query Helper ──────────────────────────── func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { diff --git a/server/store/postgres/provider.go b/server/store/postgres/provider.go index 61f364e..59af17b 100644 --- a/server/store/postgres/provider.go +++ b/server/store/postgres/provider.go @@ -195,3 +195,64 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) { } return result, rows.Err() } + +// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1`, ownerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { + rows, err := DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = $1 ORDER BY name", providerCols), + teamID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProviders(rows) +} + +func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, + id, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) { + var configID string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM provider_configs + WHERE is_active = true AND ( + (scope = 'personal' AND owner_id = $1) + OR scope = 'global' + ) + ORDER BY scope ASC, created_at ASC + LIMIT 1 + `, userID).Scan(&configID) + return configID, err +} + +func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) { + row := DB.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM provider_configs + WHERE id = $1 AND is_active = true + AND (scope = 'global' + OR (scope = 'personal' AND owner_id = $2) + OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) + `, providerCols), configID, userID) + return scanProvider(row) +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index d5776d8..a6e4d22 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -42,5 +42,10 @@ func NewStores(db *sql.DB) store.Stores { Packages: NewPackageStore(), Workflows: NewWorkflowStore(), Tasks: NewTaskStore(), + Presence: NewPresenceStore(), + PersonaGroups: NewPersonaGroupStore(), + Folders: NewFolderStore(), + Health: NewHealthStore(db), + ExtPermissions: NewExtensionPermissionStore(db), } } diff --git a/server/store/postgres/team.go b/server/store/postgres/team.go index 01ca050..5c720e5 100644 --- a/server/store/postgres/team.go +++ b/server/store/postgres/team.go @@ -226,3 +226,101 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, // unused but keeping for reference var _ = fmt.Sprintf + +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) { + var exists bool + err := DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM teams WHERE id = $1)`, teamID).Scan(&exists) + return exists, err +} + +func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) { + res, err := DB.ExecContext(ctx, + `UPDATE team_members SET role = $1 WHERE id = $2 AND team_id = $3`, + role, memberID, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM team_members WHERE id = $1 AND team_id = $2`, + memberID, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT al.action + FROM audit_log al + WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1) + ORDER BY al.action + `, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []string + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + return nil, err + } + actions = append(actions, a) + } + if actions == nil { + actions = []string{} + } + return actions, rows.Err() +} + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) { + var teamID string + err := DB.QueryRowContext(ctx, + `SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1`, userID).Scan(&teamID) + if err != nil { + return "", nil + } + return teamID, nil +} + +// ── CS6 additions (v0.29.0) ──────────────────────────────────────────── + +func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) { + var id string + err := DB.QueryRowContext(ctx, ` + INSERT INTO team_members (team_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING id`, teamID, userID, role).Scan(&id) + return id, err +} + +func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) { + var has bool + err := DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM team_members tm + JOIN teams t ON t.id = tm.team_id + WHERE tm.user_id = $1 + AND t.is_active = true + AND t.settings->>'require_private_providers' = 'true' + )`, userID).Scan(&has) + return has, err +} + +func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error { + _, err := DB.ExecContext(ctx, + `UPDATE teams SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb WHERE id = $2`, + settingsJSON, teamID) + return err +} diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 3b5e024..ac224be 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -186,3 +187,110 @@ func scanOneUser(ctx context.Context, query string, args ...interface{}) (*model ScanJSON(sj, &u.Settings) return &u, nil } +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) { + var exists bool + err := DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists) + return exists, err +} + +func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) { + q := ` + SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle + FROM users + WHERE is_active = true AND id != $1` + args := []interface{}{excludeUserID} + + if query != "" { + q += ` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)` + pattern := "%" + strings.ToLower(query) + "%" + args = append(args, pattern, pattern, pattern) + } + + q += ` ORDER BY username LIMIT 20` + + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []store.UserSearchResult + for rows.Next() { + var u store.UserSearchResult + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil { + continue + } + results = append(results, u) + } + if results == nil { + results = []store.UserSearchResult{} + } + return results, rows.Err() +} + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE role = $1`, role).Scan(&count) + return count, err +} + +func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users SET settings = ( + CASE WHEN settings IS NULL OR settings = 'null'::jsonb OR jsonb_typeof(settings) != 'object' + THEN '{}'::jsonb ELSE settings END + ) || $1::jsonb, updated_at = NOW() WHERE id = $2 + `, string(patch), userID) + return err +} + +func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) { + var vaultSet bool + var encUEK, salt, nonce []byte + err := DB.QueryRowContext(ctx, ` + SELECT vault_set, encrypted_uek, uek_salt, uek_nonce + FROM users WHERE id = $1 + `, userID).Scan(&vaultSet, &encUEK, &salt, &nonce) + return vaultSet, encUEK, salt, nonce, err +} + +func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW() + WHERE id = $4 + `, encUEK, salt, nonce, userID) + return err +} + +func (s *UserStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} + +// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false + WHERE id = $1 + `, userID) + return err +} + +func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true + WHERE id = $4 + `, encUEK, salt, nonce, userID) + return err +} diff --git a/server/store/postgres/user_mention.go b/server/store/postgres/user_mention.go new file mode 100644 index 0000000..6872a79 --- /dev/null +++ b/server/store/postgres/user_mention.go @@ -0,0 +1,62 @@ +package postgres + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Mention resolution + display info (v0.29.0) ──────────────────────── + +func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM users + WHERE LOWER(handle) = LOWER($1) AND id != $2 AND is_active = true + LIMIT 1 + `, handle, excludeUserID).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM users + WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true + `, prefix+"%", excludeUserID).Scan(&count) + if err != nil { + return "", 0, err + } + if count != 1 { + return "", count, nil + } + var id string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM users + WHERE LOWER(handle) LIKE LOWER($1) AND id != $2 AND is_active = true + LIMIT 1 + `, prefix+"%", excludeUserID).Scan(&id) + return id, 1, err +} + +func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { + result := make(map[string]store.UserDisplayInfo) + for _, id := range ids { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 + `, id).Scan(&name, &avatar) + if name.Valid { + info := store.UserDisplayInfo{Name: name.String} + if avatar.Valid { + info.Avatar = avatar.String + } + result[id] = info + } + } + return result, nil +} diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 5f93c85..61ccbc8 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -2,10 +2,13 @@ package postgres import ( "context" + "database/sql" "encoding/json" "fmt" + "time" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" ) // WorkflowStore implements store.WorkflowStore for Postgres. @@ -357,3 +360,129 @@ func nullIfEmpty(s string) interface{} { } return s } + +// ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── + +func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error { + a.ID = store.NewID() + _, err := DB.ExecContext(ctx, ` + INSERT INTO workflow_assignments (id, channel_id, stage, team_id) + VALUES ($1, $2, $3, $4) + `, a.ID, a.ChannelID, a.Stage, a.TeamID) + return err +} + +func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, channel_id, stage, team_id, assigned_to, status, + created_at, claimed_at, completed_at + FROM workflow_assignments + WHERE team_id = $1 AND status = $2 + ORDER BY created_at DESC + `, teamID, status) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAssignments(rows) +} + +func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status, + wa.created_at, wa.claimed_at, wa.completed_at + FROM workflow_assignments wa + LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1 + WHERE (wa.assigned_to = $2 AND wa.status = 'claimed') + OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL) + ORDER BY wa.created_at DESC + `, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAssignments(rows) +} + +func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET assigned_to = $1, status = 'claimed', claimed_at = $2 + WHERE id = $3 AND status = 'unassigned' + `, userID, time.Now().UTC(), assignmentID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET status = 'completed', completed_at = $1 + WHERE id = $2 AND status = 'claimed' + `, time.Now().UTC(), assignmentID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) { + var channelID string + err := DB.QueryRowContext(ctx, + `SELECT channel_id FROM workflow_assignments WHERE id = $1`, assignmentID).Scan(&channelID) + return channelID, err +} + +func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) { + // Find least-recently-assigned team member + rows, err := DB.QueryContext(ctx, ` + SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim + FROM team_members m + LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1 + WHERE m.team_id = $2 + GROUP BY m.user_id + ORDER BY last_claim ASC + LIMIT 1 + `, teamID, teamID) + if err != nil { + return "", err + } + defer rows.Close() + + if !rows.Next() { + return "", nil // no team members + } + var userID, lastClaim string + if err := rows.Scan(&userID, &lastClaim); err != nil { + return "", err + } + + // Claim for that user + _, err = DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET assigned_to = $1, status = 'claimed', claimed_at = $2 + WHERE id = $3 AND status = 'unassigned' + `, userID, time.Now().UTC(), assignmentID) + if err != nil { + return "", err + } + return userID, nil +} + +func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) { + var result []store.WorkflowAssignment + for rows.Next() { + var a store.WorkflowAssignment + if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, + &a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil { + return nil, err + } + result = append(result, a) + } + if result == nil { + result = []store.WorkflowAssignment{} + } + return result, rows.Err() +} diff --git a/server/store/project_interface.go b/server/store/project_interface.go index 3fa633e..7fb07ee 100644 --- a/server/store/project_interface.go +++ b/server/store/project_interface.go @@ -2,6 +2,7 @@ package store import ( "context" + "time" "git.gobha.me/xcaliber/chat-switchboard/models" ) @@ -44,4 +45,26 @@ type ProjectStore interface { // Get KB IDs bound to a project (used for virtual injection at completion) GetKBIDs(ctx context.Context, projectID string) ([]string, error) + + // ── CS5c additions (v0.29.0) ── + + // AdminList returns all projects with counts and owner names (admin only). + AdminList(ctx context.Context, includeArchived bool) ([]AdminProject, error) +} + +// AdminProject is returned by ProjectStore.AdminList. +type AdminProject struct { + ID string + Name string + Description string + Scope string + OwnerID string + TeamID *string + IsArchived bool + CreatedAt time.Time + UpdatedAt time.Time + ChannelCount int + KBCount int + NoteCount int + OwnerName string } diff --git a/server/store/sqlite/audit.go b/server/store/sqlite/audit.go index 3e4c730..7487195 100644 --- a/server/store/sqlite/audit.go +++ b/server/store/sqlite/audit.go @@ -34,6 +34,9 @@ func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]m al.ip_address, al.user_agent, al.created_at`, "audit_log al") b.Join("LEFT JOIN users u ON al.actor_id = u.id") + if opts.TeamID != "" { + b.Where("al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?)", opts.TeamID) + } if opts.ActorID != "" { b.Where("al.actor_id = ?", opts.ActorID) } diff --git a/server/store/sqlite/catalog.go b/server/store/sqlite/catalog.go index 8e386e5..0f5bc41 100644 --- a/server/store/sqlite/catalog.go +++ b/server/store/sqlite/catalog.go @@ -276,3 +276,58 @@ func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) { } return result, rows.Err() } + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *CatalogStore) GetCapabilities(ctx context.Context, modelID, configID string) ([]byte, error) { + var capsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT capabilities FROM model_catalog + WHERE model_id = ? AND provider_config_id = ? + `, modelID, configID).Scan(&capsJSON) + if err != nil { + return nil, err + } + return capsJSON, nil +} + +func (s *CatalogStore) GetCapabilitiesAny(ctx context.Context, modelID string) ([]byte, error) { + var capsJSON []byte + err := DB.QueryRowContext(ctx, ` + SELECT capabilities FROM model_catalog + WHERE model_id = ? ORDER BY last_synced_at DESC LIMIT 1 + `, modelID).Scan(&capsJSON) + if err != nil { + return nil, err + } + return capsJSON, nil +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *CatalogStore) ListTeamAvailable(ctx context.Context) ([]store.TeamAvailableModel, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, + ac.provider, ac.name AS provider_name + FROM model_catalog mc + JOIN provider_configs ac ON mc.provider_config_id = ac.id + WHERE mc.visibility IN ('enabled', 'team') + AND ac.is_active = 1 AND ac.scope = 'global' + ORDER BY ac.name, mc.model_id + `) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.TeamAvailableModel, 0) + for rows.Next() { + var m store.TeamAvailableModel + if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, + &m.Provider, &m.ProviderName); err != nil { + continue + } + results = append(results, m) + } + return results, rows.Err() +} diff --git a/server/store/sqlite/catalog_mention.go b/server/store/sqlite/catalog_mention.go new file mode 100644 index 0000000..5068a05 --- /dev/null +++ b/server/store/sqlite/catalog_mention.go @@ -0,0 +1,57 @@ +package sqlite + +import ( + "context" + "database/sql" +) + +// ── Mention resolution (v0.29.0) ──────────────────────────────────────── + +func (s *CatalogStore) FindEnabledByModelID(ctx context.Context, modelID string) (string, string, error) { + var foundModelID, configID string + err := DB.QueryRowContext(ctx, ` + SELECT mc.model_id, mc.provider_config_id + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) = LOWER(?) + AND mc.visibility = 'enabled' + AND pc.is_active = 1 + ORDER BY + CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END + LIMIT 1 + `, modelID).Scan(&foundModelID, &configID) + if err == sql.ErrNoRows { + return "", "", nil + } + return foundModelID, configID, err +} + +func (s *CatalogStore) FindEnabledByModelIDPrefix(ctx context.Context, prefix string) (string, string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT mc.model_id) + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) LIKE LOWER(?) + AND mc.visibility = 'enabled' + AND pc.is_active = 1 + `, prefix+"%").Scan(&count) + if err != nil { + return "", "", 0, err + } + if count != 1 { + return "", "", count, nil + } + var foundModelID, configID string + err = DB.QueryRowContext(ctx, ` + SELECT mc.model_id, mc.provider_config_id + FROM model_catalog mc + JOIN provider_configs pc ON pc.id = mc.provider_config_id + WHERE LOWER(mc.model_id) LIKE LOWER(?) + AND mc.visibility = 'enabled' + AND pc.is_active = 1 + ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END + LIMIT 1 + `, prefix+"%").Scan(&foundModelID, &configID) + return foundModelID, configID, 1, err +} diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go index 1993ca3..9063b9c 100644 --- a/server/store/sqlite/channel.go +++ b/server/store/sqlite/channel.go @@ -477,3 +477,427 @@ func (s *ChannelStore) Purge(ctx context.Context, id string) error { _, err = DB.ExecContext(ctx, `DELETE FROM channels WHERE id = ?`, id) return err } +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ChannelStore) FindExistingDM(ctx context.Context, userID1, userID2 string) (string, error) { + var channelID string + err := DB.QueryRowContext(ctx, ` + SELECT cp1.channel_id FROM channel_participants cp1 + JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id + JOIN channels c ON c.id = cp1.channel_id + WHERE c.type = 'dm' + AND cp1.participant_type = 'user' AND cp1.participant_id = ? + AND cp2.participant_type = 'user' AND cp2.participant_id = ? + LIMIT 1 + `, userID1, userID2).Scan(&channelID) + if err == sql.ErrNoRows { + return "", nil + } + return channelID, err +} + +func (s *ChannelStore) GetUnreadCount(ctx context.Context, channelID, userID string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages m + JOIN channel_participants cp ON cp.channel_id = m.channel_id + WHERE cp.channel_id = ? + AND cp.participant_type = 'user' AND cp.participant_id = ? + AND m.created_at > cp.last_read_at + `, channelID, userID).Scan(&count) + return count, err +} + +func (s *ChannelStore) DeleteByOwner(ctx context.Context, channelID, userID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM channels WHERE id = ? AND user_id = ?`, + channelID, userID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ChannelStore) MarkRead(ctx context.Context, channelID, userID string) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channel_participants + SET last_read_at = datetime('now') + WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? + `, channelID, userID) + if err != nil { + return nil + } + + var latestMsgID *string + _ = DB.QueryRowContext(ctx, ` + SELECT id FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT 1 + `, channelID).Scan(&latestMsgID) + if latestMsgID != nil { + _, _ = DB.ExecContext(ctx, ` + UPDATE channel_participants + SET last_read_message_id = ? + WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? + `, *latestMsgID, channelID, userID) + } + + return nil +} + +func (s *ChannelStore) CountParticipantsByType(ctx context.Context, channelID, pType string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM channel_participants + WHERE channel_id = ? AND participant_type = ? + `, channelID, pType).Scan(&count) + return count, err +} + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ChannelStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&count) + return count, err +} + +// ── Workflow instance state (v0.29.0-cs3) ─────────────────────────────── + +func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET workflow_id = ?, workflow_version = ?, current_stage = 0, + stage_data = ?, workflow_status = ?, last_activity_at = ? + WHERE id = ? + `, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID) + return err +} + +func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string) (*store.WorkflowChannelStatus, error) { + var ws store.WorkflowChannelStatus + var stageData []byte + err := DB.QueryRowContext(ctx, ` + SELECT workflow_id, workflow_version, current_stage, + COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'), + last_activity_at + FROM channels WHERE id = ? AND type = 'workflow' + `, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion, + &ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + ws.StageData = stageData + return &ws, nil +} + +func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET current_stage = ?, stage_data = ?, last_activity_at = ? + WHERE id = ? + `, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID) + return err +} + +func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, finalStage int, stageData json.RawMessage) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels + SET current_stage = ?, workflow_status = 'completed', + stage_data = ?, last_activity_at = ?, ai_mode = 'off' + WHERE id = ? + `, finalStage, stageData, time.Now().UTC().Format(timeFmt), channelID) + return err +} + +func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error { + _, err := DB.ExecContext(ctx, ` + UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ? + `, stage, time.Now().UTC().Format(timeFmt), channelID) + return err +} + +func (s *ChannelStore) GetStageData(ctx context.Context, channelID string) (json.RawMessage, error) { + var data json.RawMessage + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = ? + `, channelID).Scan(&data) + return data, err +} + +// ── Background job helpers (v0.29.0-cs4) ──────────────────────────────── + +func (s *ChannelStore) MarkStaleWorkflows(ctx context.Context, cutoff time.Time) (int64, error) { + result, err := DB.ExecContext(ctx, ` + UPDATE channels + SET workflow_status = 'stale' + WHERE type = 'workflow' + AND workflow_status = 'active' + AND last_activity_at < ? + `, cutoff.Format(timeFmt)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *ChannelStore) EnforceWorkflowRetention(ctx context.Context) (int64, error) { + // SQLite doesn't support JSON operators for retention policy evaluation. + // This is a no-op on SQLite; retention enforcement is PG-only. + return 0, nil +} + +func (s *ChannelStore) GetTypeAndAllowAnonymous(ctx context.Context, channelID string) (string, bool, error) { + var chType string + var allowAnon bool + err := DB.QueryRowContext(ctx, + `SELECT type, allow_anonymous FROM channels WHERE id = ?`, channelID).Scan(&chType, &allowAnon) + return chType, allowAnon, err +} + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ChannelStore) FindCompactionCandidates(ctx context.Context, activityBefore, createdAfter time.Time, minMessages, minChars, limit int) ([]models.Channel, error) { + // Auto-compaction scanner is PG-only (uses settings::text cast). + return []models.Channel{}, nil +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ChannelStore) GetProviderConfigID(ctx context.Context, channelID string) (*string, error) { + var configID sql.NullString + err := DB.QueryRowContext(ctx, + `SELECT provider_config_id FROM channels WHERE id = ?`, channelID).Scan(&configID) + if err != nil { + return nil, err + } + return NullableStringPtr(configID), nil +} + +func (s *ChannelStore) UserCanAccess(ctx context.Context, channelID, userID string) (bool, error) { + var ok bool + err := DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM channels WHERE id = ? AND user_id = ? + UNION ALL + SELECT 1 FROM channel_participants + WHERE channel_id = ? AND participant_type = 'user' AND participant_id = ? + LIMIT 1 + )`, channelID, userID, channelID, userID).Scan(&ok) + return ok, err +} + +// ── CS7b additions (v0.29.0) ──────────────────────────────────────────── + +const channelListCols = `c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, + c.description, c.model, c.provider_config_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id, + c.tags, c.settings, + COALESCE(mc.cnt, 0) AS message_count, + c.created_at, c.updated_at` + +const channelListFrom = `channels c + LEFT JOIN ( + SELECT channel_id, COUNT(*) AS cnt FROM messages WHERE deleted_at IS NULL GROUP BY channel_id + ) mc ON mc.channel_id = c.id` + +func (s *ChannelStore) ListFiltered(ctx context.Context, userID string, f store.ChannelListFilter) ([]store.ChannelListItem, int, error) { + b := NewSelect(channelListCols, channelListFrom) + b.Where("(c.user_id = ? OR c.id IN (SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ?))", userID, userID) + archivedVal := 0 + if f.Archived { + archivedVal = 1 + } + b.Where("c.is_archived = ?", archivedVal) + + if len(f.Types) == 1 { + b.Where("c.type = ?", f.Types[0]) + } else if len(f.Types) > 1 { + placeholders := make([]string, len(f.Types)) + for i, t := range f.Types { + placeholders[i] = "?" + b.args = append(b.args, t) + } + b.WhereRaw("c.type IN (" + strings.Join(placeholders, ",") + ")") + } + if f.Folder != "" { + b.Where("c.folder = ?", f.Folder) + } + if f.FolderID != "" { + b.Where("c.folder_id = ?", f.FolderID) + } + if f.Search != "" { + b.Where("c.title LIKE ?", "%"+f.Search+"%") + } + if f.ProjectID == "none" { + b.WhereRaw("c.project_id IS NULL") + } else if f.ProjectID != "" { + b.Where("c.project_id = ?", f.ProjectID) + } + + // Count + countQ, countArgs := b.CountBuild() + var total int + DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) + + b.OrderBy("c.is_pinned DESC, c.updated_at", "DESC") + b.Paginate(f.ListOptions) + + q, args := b.Build() + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items, err := scanChannelListItems(rows) + if err != nil { + return nil, 0, err + } + + // Compute unread counts + for i := range items { + DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages m + JOIN channel_participants cp ON cp.channel_id = m.channel_id + WHERE cp.channel_id = ? + AND cp.participant_type = 'user' AND cp.participant_id = ? + AND m.created_at > cp.last_read_at + AND m.deleted_at IS NULL + `, items[i].ID, userID).Scan(&items[i].UnreadCount) + } + + return items, total, nil +} + +func (s *ChannelStore) GetForUser(ctx context.Context, channelID, userID string) (*store.ChannelListItem, error) { + row := DB.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM %s + WHERE c.id = ? AND (c.user_id = ? OR c.id IN ( + SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = ? + )) + `, channelListCols, channelListFrom), channelID, userID, userID) + + var item store.ChannelListItem + var tags, settings string + err := row.Scan( + &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, + &item.Description, &item.Model, &item.ProviderConfigID, + &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, + &tags, &settings, + &item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime), + ) + if err != nil { + return nil, err + } + item.Tags = ScanArray(tags) + if item.Tags == nil { + item.Tags = []string{} + } + item.Settings = safeJSONString(settings) + item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") + item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") + return &item, nil +} + +func (s *ChannelStore) CreateFull(ctx context.Context, ch *models.Channel, folder string, tags []string, aiMode string, + ownerUserID string, dmPartnerIDs []string, defaultModel, defaultConfigID string) error { + ch.ID = store.NewID() + now := time.Now().UTC() + ch.CreatedAt = now + ch.UpdatedAt = now + + if tags == nil { + tags = []string{} + } + + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + tagsJSON := ArrayToJSON(tags) + _, err = tx.ExecContext(ctx, ` + INSERT INTO channels (id, user_id, title, type, description, model, system_prompt, + provider_config_id, folder, folder_id, tags, ai_mode, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ch.ID, ch.UserID, ch.Title, ch.Type, ch.Description, ch.Model, ch.SystemPrompt, + models.NullString(ch.ProviderConfigID), folder, models.NullString(ch.FolderID), + tagsJSON, aiMode, now.Format(timeFmt), now.Format(timeFmt), + ) + if err != nil { + return fmt.Errorf("CreateFull insert channel: %w", err) + } + + // Add owner participant + _, _ = tx.ExecContext(ctx, ` + INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) + VALUES (?, ?, 'user', ?, 'owner') + ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, ownerUserID) + + // Add DM partner participants + for _, pid := range dmPartnerIDs { + if pid == ownerUserID { + continue + } + _, _ = tx.ExecContext(ctx, ` + INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) + VALUES (?, ?, 'user', ?, 'member') + ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, pid) + } + + // Auto-create channel_model if model specified + if defaultModel != "" { + _, _ = tx.ExecContext(ctx, ` + INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT DO NOTHING`, store.NewID(), ch.ID, defaultModel, models.NullString(&defaultConfigID)) + } + + return tx.Commit() +} + +func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error { + _, err := DB.ExecContext(ctx, + `UPDATE channels SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`, + string(settingsJSON), channelID) + return err +} + +// ── CS7b helpers ──────────────────────────── + +func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) { + var result []store.ChannelListItem + for rows.Next() { + var item store.ChannelListItem + var tags, settings string + err := rows.Scan( + &item.ID, &item.UserID, &item.Title, &item.Type, &item.AiMode, &item.Topic, + &item.Description, &item.Model, &item.ProviderConfigID, + &item.SystemPrompt, &item.IsArchived, &item.IsPinned, &item.Folder, &item.FolderID, &item.ProjectID, &item.WorkspaceID, + &tags, &settings, + &item.MessageCount, st(&item.CreatedAtTime), st(&item.UpdatedAtTime), + ) + if err != nil { + return nil, err + } + item.Tags = ScanArray(tags) + if item.Tags == nil { + item.Tags = []string{} + } + item.Settings = safeJSONString(settings) + item.CreatedAt = item.CreatedAtTime.Format("2006-01-02T15:04:05Z") + item.UpdatedAt = item.UpdatedAtTime.Format("2006-01-02T15:04:05Z") + result = append(result, item) + } + return result, rows.Err() +} + +func safeJSONString(s string) json.RawMessage { + if s == "" || s == "null" { + return json.RawMessage("{}") + } + return json.RawMessage(s) +} diff --git a/server/store/sqlite/channel_helpers.go b/server/store/sqlite/channel_helpers.go new file mode 100644 index 0000000..bda0e91 --- /dev/null +++ b/server/store/sqlite/channel_helpers.go @@ -0,0 +1,143 @@ +package sqlite + +import ( + "context" + "database/sql" +) + +// ── Single-field helpers (v0.29.0) ────────────────────────────────────── + +func (s *ChannelStore) GetAIMode(ctx context.Context, channelID string) (string, error) { + var aiMode string + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = ? + `, channelID).Scan(&aiMode) + if err != nil { + return "auto", err + } + return aiMode, nil +} + +func (s *ChannelStore) GetTypeAndTeamID(ctx context.Context, channelID string) (string, *string, error) { + var channelType string + var teamID *string + err := DB.QueryRowContext(ctx, ` + SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = ? + `, channelID).Scan(&channelType, &teamID) + return channelType, teamID, err +} + +func (s *ChannelStore) GetSystemPrompt(ctx context.Context, channelID string) (*string, error) { + var prompt *string + err := DB.QueryRowContext(ctx, ` + SELECT system_prompt FROM channels WHERE id = ? + `, channelID).Scan(&prompt) + if err == sql.ErrNoRows { + return nil, nil + } + return prompt, err +} + +func (s *ChannelStore) GetDefaultModel(ctx context.Context, channelID string) (*string, error) { + var model *string + err := DB.QueryRowContext(ctx, ` + SELECT model FROM channels WHERE id = ? + `, channelID).Scan(&model) + if err == sql.ErrNoRows { + return nil, nil + } + return model, err +} + +func (s *ChannelStore) TouchUpdatedAt(ctx context.Context, channelID string) error { + _, err := DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, channelID) + return err +} + +func (s *ChannelStore) ListUserParticipantIDs(ctx context.Context, channelID, excludeUserID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = ? AND participant_type = 'user' AND participant_id != ? + `, channelID, excludeUserID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if ids == nil { + ids = []string{} + } + return ids, rows.Err() +} + +func (s *ChannelStore) ListPersonaParticipantIDs(ctx context.Context, channelID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = ? AND participant_type = 'persona' + ORDER BY created_at + `, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if ids == nil { + ids = []string{} + } + return ids, rows.Err() +} + +func (s *ChannelStore) GetLeaderPersonaID(ctx context.Context, channelID string) (string, error) { + var leaderID string + err := DB.QueryRowContext(ctx, ` + SELECT cp.participant_id + FROM channel_participants cp + JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id + WHERE cp.channel_id = ? + AND cp.participant_type = 'persona' + AND pgm.is_leader = 1 + LIMIT 1 + `, channelID).Scan(&leaderID) + if err == nil && leaderID != "" { + return leaderID, nil + } + + err = DB.QueryRowContext(ctx, ` + SELECT participant_id FROM channel_participants + WHERE channel_id = ? AND participant_type = 'persona' + ORDER BY created_at LIMIT 1 + `, channelID).Scan(&leaderID) + if err == sql.ErrNoRows { + return "", nil + } + return leaderID, err +} + +func (s *ChannelStore) GetWorkflowInfo(ctx context.Context, channelID string) (*string, int, error) { + var workflowID *string + var currentStage int + err := DB.QueryRowContext(ctx, ` + SELECT workflow_id, COALESCE(current_stage, 0) + FROM channels WHERE id = ? AND type = 'workflow' + `, channelID).Scan(&workflowID, ¤tStage) + if err == sql.ErrNoRows { + return nil, 0, nil + } + return workflowID, currentStage, err +} diff --git a/server/store/sqlite/extension_permissions.go b/server/store/sqlite/extension_permissions.go new file mode 100644 index 0000000..44c03fc --- /dev/null +++ b/server/store/sqlite/extension_permissions.go @@ -0,0 +1,164 @@ +package sqlite + +import ( + "context" + "time" + + "github.com/google/uuid" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +type ExtensionPermissionStore struct{} + +func NewExtensionPermissionStore() *ExtensionPermissionStore { + return &ExtensionPermissionStore{} +} + +func (s *ExtensionPermissionStore) DeclareForPackage(ctx context.Context, packageID string, permissions []string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if len(permissions) == 0 { + _, err = tx.ExecContext(ctx, `DELETE FROM extension_permissions WHERE package_id = ?`, packageID) + if err != nil { + return err + } + return tx.Commit() + } + + declared := make(map[string]bool, len(permissions)) + for _, p := range permissions { + declared[p] = true + } + + rows, err := tx.QueryContext(ctx, + `SELECT permission FROM extension_permissions WHERE package_id = ?`, packageID) + if err != nil { + return err + } + existing := make(map[string]bool) + for rows.Next() { + var perm string + if err := rows.Scan(&perm); err != nil { + rows.Close() + return err + } + existing[perm] = true + } + rows.Close() + + for perm := range existing { + if !declared[perm] { + _, err = tx.ExecContext(ctx, + `DELETE FROM extension_permissions WHERE package_id = ? AND permission = ?`, + packageID, perm) + if err != nil { + return err + } + } + } + + for _, perm := range permissions { + if !existing[perm] { + _, err = tx.ExecContext(ctx, + `INSERT OR IGNORE INTO extension_permissions (id, package_id, permission) + VALUES (?, ?, ?)`, + uuid.New().String(), packageID, perm) + if err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (s *ExtensionPermissionStore) ListForPackage(ctx context.Context, packageID string) ([]models.ExtensionPermission, error) { + rows, err := DB.QueryContext(ctx, + `SELECT id, package_id, permission, granted, granted_by, granted_at, created_at + FROM extension_permissions + WHERE package_id = ? + ORDER BY permission`, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var perms []models.ExtensionPermission + for rows.Next() { + var p models.ExtensionPermission + if err := rows.Scan(&p.ID, &p.PackageID, &p.Permission, &p.Granted, + &p.GrantedBy, database.SNT(&p.GrantedAt), database.ST(&p.CreatedAt)); err != nil { + return nil, err + } + perms = append(perms, p) + } + if perms == nil { + perms = []models.ExtensionPermission{} + } + return perms, nil +} + +func (s *ExtensionPermissionStore) GrantedForPackage(ctx context.Context, packageID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, + `SELECT permission FROM extension_permissions + WHERE package_id = ? AND granted = 1 + ORDER BY permission`, packageID) + if err != nil { + return nil, err + } + defer rows.Close() + + var perms []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + perms = append(perms, p) + } + if perms == nil { + perms = []string{} + } + return perms, nil +} + +func (s *ExtensionPermissionStore) Grant(ctx context.Context, packageID, permission, grantedBy string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := DB.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = 1, granted_by = ?, granted_at = ? + WHERE package_id = ? AND permission = ?`, + grantedBy, now, packageID, permission) + return err +} + +func (s *ExtensionPermissionStore) Revoke(ctx context.Context, packageID, permission string) error { + _, err := DB.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = 0, granted_by = NULL, granted_at = NULL + WHERE package_id = ? AND permission = ?`, + packageID, permission) + return err +} + +func (s *ExtensionPermissionStore) GrantAll(ctx context.Context, packageID, grantedBy string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := DB.ExecContext(ctx, + `UPDATE extension_permissions + SET granted = 1, granted_by = ?, granted_at = ? + WHERE package_id = ? AND granted = 0`, + grantedBy, now, packageID) + return err +} + +func (s *ExtensionPermissionStore) DeleteForPackage(ctx context.Context, packageID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM extension_permissions WHERE package_id = ?`, packageID) + return err +} diff --git a/server/store/sqlite/file.go b/server/store/sqlite/file.go index 8bbc833..93929c9 100644 --- a/server/store/sqlite/file.go +++ b/server/store/sqlite/file.go @@ -254,3 +254,12 @@ func (s *FileStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([ } return out, rows.Err() } + +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +func (s *FileStore) UpdateStorageKey(ctx context.Context, id, key string) error { + _, err := DB.ExecContext(ctx, + `UPDATE files SET storage_key = ?, updated_at = datetime('now') WHERE id = ?`, + key, id) + return err +} diff --git a/server/store/sqlite/folder.go b/server/store/sqlite/folder.go new file mode 100644 index 0000000..bdee558 --- /dev/null +++ b/server/store/sqlite/folder.go @@ -0,0 +1,82 @@ +package sqlite + +import ( + "context" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type FolderStore struct{} + +func NewFolderStore() *FolderStore { return &FolderStore{} } + +func (s *FolderStore) List(ctx context.Context, userID string) ([]models.Folder, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, name, parent_id, sort_order, created_at, updated_at + FROM folders WHERE user_id = ? + ORDER BY sort_order, name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.Folder + for rows.Next() { + var f models.Folder + if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, + st(&f.CreatedAt), st(&f.UpdatedAt)); err != nil { + continue + } + f.UserID = userID + result = append(result, f) + } + if result == nil { + result = []models.Folder{} + } + return result, rows.Err() +} + +func (s *FolderStore) Create(ctx context.Context, f *models.Folder) error { + f.ID = store.NewID() + _, err := DB.ExecContext(ctx, ` + INSERT INTO folders (id, user_id, name, sort_order) + VALUES (?, ?, ?, ?) + `, f.ID, f.UserID, f.Name, f.SortOrder) + if err != nil { + return err + } + return DB.QueryRowContext(ctx, ` + SELECT name, parent_id, sort_order, created_at, updated_at + FROM folders WHERE id = ? + `, f.ID).Scan(&f.Name, &f.ParentID, &f.SortOrder, st(&f.CreatedAt), st(&f.UpdatedAt)) +} + +func (s *FolderStore) Update(ctx context.Context, folderID, userID string, name string, sortOrder *int) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE folders + SET name = COALESCE(NULLIF(?, ''), name), + sort_order = COALESCE(?, sort_order) + WHERE id = ? AND user_id = ? + `, name, sortOrder, folderID, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *FolderStore) Delete(ctx context.Context, folderID, userID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM folders WHERE id = ? AND user_id = ?`, folderID, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *FolderStore) UnassignChannels(ctx context.Context, folderID, userID string) error { + _, err := DB.ExecContext(ctx, + `UPDATE channels SET folder_id = NULL WHERE folder_id = ? AND user_id = ?`, folderID, userID) + return err +} diff --git a/server/store/sqlite/global_config.go b/server/store/sqlite/global_config.go index 1bc6ffd..63b4ca8 100644 --- a/server/store/sqlite/global_config.go +++ b/server/store/sqlite/global_config.go @@ -56,3 +56,39 @@ func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONM } return result, rows.Err() } + +// ── OIDC state (v0.29.0-cs4) ──────────────────────────────────────────── + +func (s *GlobalConfigStore) SaveOIDCState(ctx context.Context, state, nonce, redirectTo string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO oidc_auth_state (state, nonce, redirect_to) VALUES (?, ?, ?) + `, state, nonce, redirectTo) + return err +} + +func (s *GlobalConfigStore) ConsumeOIDCState(ctx context.Context, state string) (string, string, error) { + var nonce, redirectTo string + err := DB.QueryRowContext(ctx, ` + SELECT nonce, COALESCE(redirect_to, '') FROM oidc_auth_state WHERE state = ? + `, state).Scan(&nonce, &redirectTo) + if err != nil { + return "", "", err + } + _, _ = DB.ExecContext(ctx, `DELETE FROM oidc_auth_state WHERE state = ?`, state) + return nonce, redirectTo, nil +} + +func (s *GlobalConfigStore) CleanupOIDCState(ctx context.Context) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM oidc_auth_state WHERE created_at < datetime('now', '-10 minutes')`) + return err +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *GlobalConfigStore) GetString(ctx context.Context, key string) (string, error) { + var val string + err := DB.QueryRowContext(ctx, + "SELECT value FROM global_settings WHERE key = ?", key).Scan(&val) + return val, err +} diff --git a/server/store/sqlite/memory.go b/server/store/sqlite/memory.go index 9c3737e..80e3bca 100644 --- a/server/store/sqlite/memory.go +++ b/server/store/sqlite/memory.go @@ -254,3 +254,34 @@ func (s *MemoryStore) scanMemories(rows *sql.Rows) ([]models.Memory, error) { // ensure compile-time interface satisfaction var _ store.MemoryStore = (*MemoryStore)(nil) + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *MemoryStore) SetEmbedding(ctx context.Context, id, embedding string) error { + _, err := DB.ExecContext(ctx, + `UPDATE memories SET embedding = ? WHERE id = ?`, embedding, id) + return err +} + +func (s *MemoryStore) GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) { + var lastID string + err := DB.QueryRowContext(ctx, + `SELECT last_message_id FROM memory_extraction_log WHERE channel_id = ? AND user_id = ?`, + channelID, userID).Scan(&lastID) + if err != nil { + return "", nil + } + return lastID, nil +} + +func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO memory_extraction_log (id, channel_id, user_id, last_message_id, memory_count) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(channel_id, user_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + extracted_at = datetime('now'), + memory_count = memory_extraction_log.memory_count + excluded.memory_count + `, store.NewID(), channelID, userID, lastMessageID, count) + return err +} diff --git a/server/store/sqlite/message.go b/server/store/sqlite/message.go index dd07ae8..ad92c91 100644 --- a/server/store/sqlite/message.go +++ b/server/store/sqlite/message.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -137,7 +138,9 @@ func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]m m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at FROM messages m JOIN path p ON m.id = p.parent_id ) - SELECT * FROM path ORDER BY created_at ASC`, messageID) + SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata, + parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at + FROM path ORDER BY created_at ASC`, messageID) if err != nil { return nil, err } @@ -188,3 +191,120 @@ func scanMessages(rows *sql.Rows) ([]models.Message, error) { } return result, rows.Err() } + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *MessageStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&count) + return count, err +} + +// ── CS5c additions (v0.29.0) ───────────────────────────────────────────── + +func (s *MessageStore) SearchInChannel(ctx context.Context, channelID, query, roleFilter string, limit int) ([]store.ChannelSearchResult, error) { + words := strings.Fields(query) + if len(words) == 0 { + return []store.ChannelSearchResult{}, nil + } + + q := `SELECT m.id, m.role, SUBSTR(m.content, 1, 300), m.created_at + FROM messages m + WHERE m.channel_id = ? + AND m.deleted_at IS NULL + AND m.role IN ('user', 'assistant')` + args := []interface{}{channelID} + + if roleFilter == "user" || roleFilter == "assistant" { + q += " AND m.role = ?" + args = append(args, roleFilter) + } + + for _, w := range words { + q += " AND m.content LIKE ?" + args = append(args, "%"+w+"%") + } + + q += " ORDER BY m.created_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.ChannelSearchResult, 0) + for rows.Next() { + var r store.ChannelSearchResult + if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, st(&r.Timestamp)); err != nil { + continue + } + r.Rank = 1.0 // no ranking on SQLite + results = append(results, r) + } + return results, rows.Err() +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *MessageStore) ListWithSenderInfo(ctx context.Context, channelID string, limit, offset int) ([]store.MessageWithSender, int, error) { + var total int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM messages WHERE channel_id = ? AND deleted_at IS NULL`, + channelID).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err := DB.QueryContext(ctx, ` + SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, + m.sibling_index, m.participant_type, m.participant_id, + CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) + WHEN m.participant_type = 'persona' THEN p.name + ELSE NULL END AS sender_name, + CASE WHEN m.participant_type = 'user' THEN u.avatar_url + WHEN m.participant_type = 'persona' THEN p.avatar + ELSE NULL END AS sender_avatar, + m.created_at + FROM messages m + LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id + LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id + WHERE m.channel_id = ? AND m.deleted_at IS NULL + ORDER BY m.created_at ASC + LIMIT ? OFFSET ? + `, channelID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + results := make([]store.MessageWithSender, 0) + for rows.Next() { + var m store.MessageWithSender + if err := rows.Scan( + &m.ID, &m.ChannelID, &m.Role, &m.Content, + &m.Model, &m.TokensUsed, &m.ParentID, + &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID, + &m.SenderName, &m.SenderAvatar, + &m.CreatedAt, + ); err != nil { + return nil, total, err + } + results = append(results, m) + } + return results, total, rows.Err() +} + +func (s *MessageStore) GetParentAndRole(ctx context.Context, messageID, channelID string) (*string, string, error) { + var parentID sql.NullString + var role string + err := DB.QueryRowContext(ctx, ` + SELECT parent_id, role FROM messages + WHERE id = ? AND channel_id = ? AND deleted_at IS NULL + `, messageID, channelID).Scan(&parentID, &role) + if err != nil { + return nil, "", err + } + return NullableStringPtr(parentID), role, nil +} \ No newline at end of file diff --git a/server/store/sqlite/message_tree.go b/server/store/sqlite/message_tree.go new file mode 100644 index 0000000..bf90a75 --- /dev/null +++ b/server/store/sqlite/message_tree.go @@ -0,0 +1,382 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Tree Operations (v0.29.0) ─────────────────────────────────────────── + +func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) { + var leafID *string + + err := DB.QueryRowContext(ctx, ` + SELECT active_leaf_id FROM channel_cursors + WHERE channel_id = ? AND user_id = ? + `, channelID, userID).Scan(&leafID) + + if err == nil && leafID != nil { + var exists bool + DB.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM messages WHERE id = ? AND deleted_at IS NULL) + `, *leafID).Scan(&exists) + if exists { + return leafID, nil + } + } + + var fallbackID string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM messages + WHERE channel_id = ? AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1 + `, channelID).Scan(&fallbackID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &fallbackID, nil +} + +func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) { + rows, err := DB.QueryContext(ctx, ` + WITH RECURSIVE path AS ( + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, + participant_type, participant_id, sibling_index, created_at, + 0 AS depth + FROM messages + WHERE id = ? AND channel_id = ? AND deleted_at IS NULL + + UNION ALL + + SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, + m.participant_type, m.participant_id, m.sibling_index, m.created_at, + p.depth + 1 + FROM messages m + JOIN path p ON m.id = p.parent_id + WHERE m.deleted_at IS NULL + ) + SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, + participant_type, participant_id, sibling_index, created_at + FROM path + ORDER BY depth DESC + `, leafID, channelID) + if err != nil { + return nil, fmt.Errorf("GetPathToLeaf: %w", err) + } + defer rows.Close() + + var path []store.PathMessage + for rows.Next() { + var m store.PathMessage + var participantType, participantID sql.NullString + var toolCallsJSON, metadataJSON []byte + if err := rows.Scan( + &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, + &toolCallsJSON, &metadataJSON, + &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) + } + if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { + raw := json.RawMessage(toolCallsJSON) + m.ToolCalls = &raw + } + if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { + raw := json.RawMessage(metadataJSON) + m.Metadata = &raw + } + if participantType.Valid { + m.ParticipantType = participantType.String + } + if participantID.Valid { + m.ParticipantID = participantID.String + } + path = append(path, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for i := range path { + count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID) + path[i].SiblingCount = count + } + + _ = s.ResolveSenderInfo(ctx, path) + + return path, nil +} + +func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) { + leafID, err := s.GetActiveLeaf(ctx, channelID, userID) + if err != nil { + return nil, err + } + if leafID == nil { + return []store.PathMessage{}, nil + } + return s.GetPathToLeaf(ctx, channelID, *leafID) +} + +func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) { + var parentID *string + var channelID string + err := DB.QueryRowContext(ctx, ` + SELECT parent_id, channel_id FROM messages + WHERE id = ? AND deleted_at IS NULL + `, messageID).Scan(&parentID, &channelID) + if err != nil { + return nil, 0, fmt.Errorf("message not found: %w", err) + } + + var rows *sql.Rows + if parentID == nil { + rows, err = DB.QueryContext(ctx, ` + SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at + FROM messages + WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, channelID) + } else { + rows, err = DB.QueryContext(ctx, ` + SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at + FROM messages + WHERE parent_id = ? AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, *parentID) + } + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var siblings []store.SiblingInfo + currentIdx := 0 + for i := 0; rows.Next(); i++ { + var si store.SiblingInfo + if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil { + return nil, 0, err + } + if si.ID == messageID { + currentIdx = i + } + siblings = append(siblings, si) + } + + return siblings, currentIdx, rows.Err() +} + +func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) { + var count int + var err error + if parentID == nil { + err = DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages + WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&count) + } else { + err = DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM messages + WHERE parent_id = ? AND deleted_at IS NULL + `, *parentID).Scan(&count) + } + if err != nil || count == 0 { + return 1, nil + } + return count, nil +} + +func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) { + var leafID string + err := DB.QueryRowContext(ctx, ` + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth + FROM messages + WHERE id = ? AND deleted_at IS NULL + + UNION ALL + + SELECT child.id, d.depth + 1 + FROM messages child + JOIN descendants d ON child.parent_id = d.id + WHERE child.deleted_at IS NULL + AND child.sibling_index = ( + SELECT MIN(sibling_index) FROM messages + WHERE parent_id = d.id AND deleted_at IS NULL + ) + ) + SELECT id FROM descendants + ORDER BY depth DESC + LIMIT 1 + `, messageID).Scan(&leafID) + + if err != nil { + return messageID, nil + } + return leafID, nil +} + +func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) { + var maxIdx sql.NullInt64 + var err error + if parentID == nil { + err = DB.QueryRowContext(ctx, ` + SELECT MAX(sibling_index) FROM messages + WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&maxIdx) + } else { + err = DB.QueryRowContext(ctx, ` + SELECT MAX(sibling_index) FROM messages + WHERE parent_id = ? AND deleted_at IS NULL + `, *parentID).Scan(&maxIdx) + } + if err != nil || !maxIdx.Valid { + return 0, nil + } + return int(maxIdx.Int64) + 1, nil +} + +func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM messages + WHERE channel_id = ? AND role = 'assistant' AND participant_type = 'persona' + LIMIT 1 + `, channelID).Scan(&id) + return err == nil && id != "", nil +} + +func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error { + m.ID = store.NewID() + now := time.Now().UTC() + m.CreatedAt = now + + // tool_calls: serialize for SQLite + var tcVal interface{} + if m.ToolCalls != nil { + tcVal = ToJSON(m.ToolCalls) + } + + _, err := DB.ExecContext(ctx, ` + INSERT INTO messages (id, channel_id, role, content, model, tokens_used, + tool_calls, parent_id, participant_type, participant_id, + provider_config_id, sibling_index, created_at) + VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?) + `, m.ID, m.ChannelID, m.Role, m.Content, safeModelStr(m.Model), m.TokensUsed, + tcVal, models.NullString(m.ParentID), + m.ParticipantType, m.ParticipantID, + safeStringPtr(m.ProviderConfigID), m.SiblingIndex, + now.Format(timeFmt), + ) + if err != nil { + return fmt.Errorf("CreateWithCursor insert: %w", err) + } + + // Update cursor + if cursorUserID != "" { + _, _ = DB.ExecContext(ctx, ` + INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id) + VALUES (?, ?, ?, ?) + ON CONFLICT (channel_id, user_id) DO UPDATE SET + active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now') + `, store.NewID(), m.ChannelID, cursorUserID, m.ID) + } + + // Touch channel + _, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, m.ChannelID) + + return nil +} + +func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error { + userIDs := map[string]bool{} + personaIDs := map[string]bool{} + for _, m := range path { + if m.ParticipantID == "" { + continue + } + switch m.ParticipantType { + case "user": + userIDs[m.ParticipantID] = true + case "persona": + personaIDs[m.ParticipantID] = true + } + } + + userNames := map[string]string{} + userAvatars := map[string]string{} + for uid := range userIDs { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ? + `, uid).Scan(&name, &avatar) + if name.Valid { + userNames[uid] = name.String + } + if avatar.Valid { + userAvatars[uid] = avatar.String + } + } + + personaNames := map[string]string{} + personaAvatars := map[string]string{} + for pid := range personaIDs { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT name, avatar FROM personas WHERE id = ? + `, pid).Scan(&name, &avatar) + if name.Valid { + personaNames[pid] = name.String + } + if avatar.Valid { + personaAvatars[pid] = avatar.String + } + } + + for i := range path { + pid := path[i].ParticipantID + switch path[i].ParticipantType { + case "user": + if n, ok := userNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := userAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + case "persona": + if n, ok := personaNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := personaAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + } + } + + return nil +} + +// ── helpers ───────────────────────────────── + +func safeModelStr(m string) string { + if m == "" { + return "" + } + return m +} + +func safeStringPtr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/server/store/sqlite/note.go b/server/store/sqlite/note.go index d72ad5f..1aeefe5 100644 --- a/server/store/sqlite/note.go +++ b/server/store/sqlite/note.go @@ -226,3 +226,80 @@ func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, er } return result, total, rows.Err() } + +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +// SetEmbedding is a no-op on SQLite (pgvector is PG-only). +func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { + return nil +} + +// SearchKeyword performs LIKE-based keyword search on SQLite. +func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { + words := strings.Fields(query) + if len(words) == 0 { + return []store.NoteSearchResult{}, nil + } + + q := `SELECT id, title, folder_path, tags, SUBSTR(content, 1, 500) + FROM notes WHERE user_id = ?` + args := []interface{}{userID} + + for _, w := range words { + pattern := "%" + w + "%" + q += " AND (title LIKE ? OR content LIKE ?)" + args = append(args, pattern, pattern) + } + q += " ORDER BY updated_at DESC LIMIT ?" + args = append(args, limit) + + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.NoteSearchResult, 0) + for rows.Next() { + var r store.NoteSearchResult + var tagsJSON string + if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &tagsJSON, &r.Excerpt); err != nil { + continue + } + r.Tags = ScanArray(tagsJSON) + if r.Tags == nil { + r.Tags = []string{} + } + r.Rank = 1.0 // no ranking on SQLite + results = append(results, r) + } + return results, rows.Err() +} + +// SearchSemantic returns empty results on SQLite (vector search requires PG). +func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { + return []store.NoteSearchResult{}, nil +} + +func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT folder_path, COUNT(*) AS count + FROM notes WHERE user_id = ? + GROUP BY folder_path + ORDER BY folder_path + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.FolderInfo, 0) + for rows.Next() { + var f store.FolderInfo + if err := rows.Scan(&f.Path, &f.Count); err != nil { + continue + } + results = append(results, f) + } + return results, rows.Err() +} diff --git a/server/store/sqlite/packages.go b/server/store/sqlite/packages.go index 2c52212..7d6f47a 100644 --- a/server/store/sqlite/packages.go +++ b/server/store/sqlite/packages.go @@ -55,6 +55,20 @@ func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) return nil } +func (s *PackageStore) SetStatus(ctx context.Context, id string, status string) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET status = ?, updated_at = datetime('now') WHERE id = ?`, + status, id) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + func (s *PackageStore) Delete(ctx context.Context, id string) error { result, err := DB.ExecContext(ctx, `DELETE FROM packages WHERE id = ? AND source != 'core'`, id) @@ -92,15 +106,18 @@ func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) { func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error { now := time.Now().UTC() manifestJSON := ToJSON(pkg.Manifest) + if pkg.Status == "" { + pkg.Status = "active" + } _, err := DB.ExecContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, - is_system, scope, team_id, installed_by, manifest, enabled, source, + is_system, scope, team_id, installed_by, manifest, enabled, status, source, installed_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), - manifestJSON, pkg.Enabled, pkg.Source, + manifestJSON, pkg.Enabled, pkg.Status, pkg.Source, now.Format(timeFmt), now.Format(timeFmt), ) if err != nil { @@ -168,7 +185,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &up.ID, &up.Title, &up.Type, &up.Version, &up.Description, &up.Author, &up.Tier, &isSystemInt, &up.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &up.Source, + &manifestJSON, &enabledInt, &up.Status, &up.Source, &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { @@ -231,7 +248,7 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, - p.manifest, p.enabled, p.source, p.installed_at, p.updated_at` + p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration @@ -243,7 +260,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &pkg.Source, + &manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { @@ -278,7 +295,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &pkg.Source, + &manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err diff --git a/server/store/sqlite/persona_group.go b/server/store/sqlite/persona_group.go new file mode 100644 index 0000000..6687762 --- /dev/null +++ b/server/store/sqlite/persona_group.go @@ -0,0 +1,144 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type PersonaGroupStore struct{} + +func NewPersonaGroupStore() *PersonaGroupStore { return &PersonaGroupStore{} } + +const personaGroupCols = `id, name, description, owner_id, scope, team_id, created_at, updated_at` + +func (s *PersonaGroupStore) List(ctx context.Context, ownerID string) ([]models.PersonaGroup, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT `+personaGroupCols+` FROM persona_groups + WHERE owner_id = ? ORDER BY name + `, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + + groups := []models.PersonaGroup{} + for rows.Next() { + var g models.PersonaGroup + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt)); err != nil { + continue + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +func (s *PersonaGroupStore) Get(ctx context.Context, id, ownerID string) (*models.PersonaGroup, error) { + var g models.PersonaGroup + err := DB.QueryRowContext(ctx, ` + SELECT `+personaGroupCols+` FROM persona_groups WHERE id = ? AND owner_id = ? + `, id, ownerID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, st(&g.CreatedAt), st(&g.UpdatedAt)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &g, nil +} + +func (s *PersonaGroupStore) Create(ctx context.Context, g *models.PersonaGroup) error { + g.ID = store.NewID() + now := time.Now().UTC() + g.CreatedAt = now + g.UpdatedAt = now + _, err := DB.ExecContext(ctx, ` + INSERT INTO persona_groups (id, name, description, owner_id, scope, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, g.ID, g.Name, g.Description, g.OwnerID, g.Scope, + now.Format(timeFmt), now.Format(timeFmt)) + return err +} + +func (s *PersonaGroupStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { + for k, v := range fields { + _, err := DB.ExecContext(ctx, + `UPDATE persona_groups SET `+k+` = ?, updated_at = datetime('now') WHERE id = ?`, v, id) + if err != nil { + return err + } + } + return nil +} + +func (s *PersonaGroupStore) Delete(ctx context.Context, id, ownerID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM persona_groups WHERE id = ? AND owner_id = ?`, id, ownerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func (s *PersonaGroupStore) GetOwnerID(ctx context.Context, id string) (string, error) { + var ownerID string + err := DB.QueryRowContext(ctx, + `SELECT owner_id FROM persona_groups WHERE id = ?`, id).Scan(&ownerID) + if err == sql.ErrNoRows { + return "", nil + } + return ownerID, err +} + +func (s *PersonaGroupStore) AddMember(ctx context.Context, groupID, personaID string, isLeader bool) error { + if isLeader { + _, _ = DB.ExecContext(ctx, + `UPDATE persona_group_members SET is_leader = 0 WHERE group_id = ?`, groupID) + } + id := store.NewID() + _, err := DB.ExecContext(ctx, ` + INSERT INTO persona_group_members (id, group_id, persona_id, is_leader) + VALUES (?, ?, ?, ?) + ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader + `, id, groupID, personaID, isLeader) + return err +} + +func (s *PersonaGroupStore) RemoveMember(ctx context.Context, memberID, groupID string) error { + _, err := DB.ExecContext(ctx, + `DELETE FROM persona_group_members WHERE id = ? AND group_id = ?`, memberID, groupID) + return err +} + +func (s *PersonaGroupStore) ListMembers(ctx context.Context, groupID string) ([]models.PersonaGroupMember, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, + COALESCE(p.name, '') AS persona_name, + COALESCE(p.handle, '') AS persona_handle, + COALESCE(p.avatar, '') AS persona_avatar + FROM persona_group_members pgm + LEFT JOIN personas p ON p.id = pgm.persona_id + WHERE pgm.group_id = ? + ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id + `, groupID) + if err != nil { + return []models.PersonaGroupMember{}, nil + } + defer rows.Close() + + members := []models.PersonaGroupMember{} + for rows.Next() { + var m models.PersonaGroupMember + if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, + &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { + continue + } + members = append(members, m) + } + return members, rows.Err() +} diff --git a/server/store/sqlite/persona_mention.go b/server/store/sqlite/persona_mention.go new file mode 100644 index 0000000..7f301a6 --- /dev/null +++ b/server/store/sqlite/persona_mention.go @@ -0,0 +1,80 @@ +package sqlite + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Mention resolution + display info (v0.29.0) ──────────────────────── + +func (s *PersonaStore) FindActiveByHandle(ctx context.Context, handle string) (string, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM personas + WHERE LOWER(handle) = LOWER(?) AND is_active = 1 + LIMIT 1 + `, handle).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +func (s *PersonaStore) FindActiveByHandlePrefix(ctx context.Context, prefix string) (string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1 + `, prefix+"%").Scan(&count) + if err != nil { + return "", 0, err + } + if count != 1 { + return "", count, nil + } + var id string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM personas WHERE LOWER(handle) LIKE LOWER(?) AND is_active = 1 + `, prefix+"%").Scan(&id) + return id, 1, err +} + +func (s *PersonaStore) GetNameByID(ctx context.Context, id string) (string, error) { + var name string + err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name) + if err == sql.ErrNoRows { + return "", nil + } + return name, err +} + +func (s *PersonaStore) GetNamesByIDs(ctx context.Context, ids []string) (map[string]string, error) { + result := make(map[string]string) + for _, id := range ids { + var name string + err := DB.QueryRowContext(ctx, `SELECT name FROM personas WHERE id = ?`, id).Scan(&name) + if err == nil && name != "" { + result[id] = name + } + } + return result, nil +} + +func (s *PersonaStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { + result := make(map[string]store.UserDisplayInfo) + for _, id := range ids { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT name, avatar FROM personas WHERE id = ? + `, id).Scan(&name, &avatar) + if name.Valid { + info := store.UserDisplayInfo{Name: name.String} + if avatar.Valid { + info.Avatar = avatar.String + } + result[id] = info + } + } + return result, nil +} diff --git a/server/store/sqlite/presence.go b/server/store/sqlite/presence.go new file mode 100644 index 0000000..a4301db --- /dev/null +++ b/server/store/sqlite/presence.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" + "database/sql" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// PresenceStore manages user_presence table. +type PresenceStore struct{} + +func NewPresenceStore() *PresenceStore { return &PresenceStore{} } + +func (s *PresenceStore) Heartbeat(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO user_presence (user_id, last_seen, status) + VALUES (?, datetime('now'), 'online') + ON CONFLICT (user_id) DO UPDATE + SET last_seen = datetime('now'), status = 'online' + `, userID) + return err +} + +func (s *PresenceStore) GetLastSeen(ctx context.Context, userID string) (*time.Time, error) { + var lastSeen time.Time + err := DB.QueryRowContext(ctx, + `SELECT last_seen FROM user_presence WHERE user_id = ?`, userID).Scan(st(&lastSeen)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &lastSeen, nil +} + +func (s *PresenceStore) GetStatuses(ctx context.Context, userIDs []string, threshold time.Time) (map[string]string, error) { + result := make(map[string]string, len(userIDs)) + for _, id := range userIDs { + var lastSeen time.Time + err := DB.QueryRowContext(ctx, + `SELECT last_seen FROM user_presence WHERE user_id = ?`, id).Scan(st(&lastSeen)) + if err != nil || lastSeen.Before(threshold) { + result[id] = "offline" + } else { + result[id] = "online" + } + } + return result, nil +} + +// Ensure PresenceStore satisfies the interface at compile time. +var _ store.PresenceStore = (*PresenceStore)(nil) diff --git a/server/store/sqlite/project.go b/server/store/sqlite/project.go index 474dd22..50ec114 100644 --- a/server/store/sqlite/project.go +++ b/server/store/sqlite/project.go @@ -418,6 +418,52 @@ func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID str return projectID, err } +// ── CS5c additions (v0.29.0) ────────────────────────────────────────── + +func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) { + archivedFilter := "AND p.is_archived = 0" + if includeArchived { + archivedFilter = "" + } + + rows, err := DB.QueryContext(ctx, fmt.Sprintf(` + SELECT p.id, p.name, p.description, p.scope, + p.owner_id, p.team_id, p.is_archived, + p.created_at, p.updated_at, + (SELECT COUNT(*) FROM project_channels WHERE project_id = p.id), + (SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id), + (SELECT COUNT(*) FROM project_notes WHERE project_id = p.id), + COALESCE(u.username, '') + FROM projects p + LEFT JOIN users u ON u.id = p.owner_id + WHERE 1=1 %s + ORDER BY p.updated_at DESC`, archivedFilter)) + if err != nil { + return nil, err + } + defer rows.Close() + + results := make([]store.AdminProject, 0) + for rows.Next() { + var p store.AdminProject + var teamID sql.NullString + var archived int + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.Scope, + &p.OwnerID, &teamID, &archived, + st(&p.CreatedAt), st(&p.UpdatedAt), + &p.ChannelCount, &p.KBCount, &p.NoteCount, + &p.OwnerName, + ); err != nil { + return nil, err + } + p.IsArchived = archived != 0 + p.TeamID = NullableStringPtr(teamID) + results = append(results, p) + } + return results, rows.Err() +} + // ── Helpers ───────────────────────────────── func boolToInt(b bool) int { diff --git a/server/store/sqlite/provider.go b/server/store/sqlite/provider.go index b5eff1a..ec41d02 100644 --- a/server/store/sqlite/provider.go +++ b/server/store/sqlite/provider.go @@ -203,3 +203,64 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) { } return result, rows.Err() } + +// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ProviderStore) DeletePersonalByOwner(ctx context.Context, ownerID string) (int64, error) { + result, err := DB.ExecContext(ctx, + `DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = ?`, ownerID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// ── CS6 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *ProviderStore) ListAllForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) { + rows, err := DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = 'team' AND owner_id = ? ORDER BY name", providerCols), + teamID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanProviders(rows) +} + +func (s *ProviderStore) DeleteByIDAndTeam(ctx context.Context, id, teamID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM provider_configs WHERE id = ? AND scope = 'team' AND owner_id = ?`, + id, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ── CS7a additions (v0.29.0) ──────────────────────────────────────────── + +func (s *ProviderStore) FindFirstForUser(ctx context.Context, userID string) (string, error) { + var configID string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM provider_configs + WHERE is_active = 1 AND ( + (scope = 'personal' AND owner_id = ?) + OR scope = 'global' + ) + ORDER BY scope ASC, created_at ASC + LIMIT 1 + `, userID).Scan(&configID) + return configID, err +} + +func (s *ProviderStore) LoadAccessible(ctx context.Context, configID, userID string) (*models.ProviderConfig, error) { + row := DB.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM provider_configs + WHERE id = ? AND is_active = 1 + AND (scope = 'global' + OR (scope = 'personal' AND owner_id = ?) + OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = ?))) + `, providerCols), configID, userID, userID) + return scanProvider(row) +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 6e6d0d6..dd34278 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -42,5 +42,10 @@ func NewStores(db *sql.DB) store.Stores { Packages: NewPackageStore(), Workflows: NewWorkflowStore(), Tasks: NewTaskStore(), + Presence: NewPresenceStore(), + PersonaGroups: NewPersonaGroupStore(), + Folders: NewFolderStore(), + Health: NewHealthStore(), + ExtPermissions: NewExtensionPermissionStore(), } } diff --git a/server/store/sqlite/team.go b/server/store/sqlite/team.go index 162ef5c..830ff53 100644 --- a/server/store/sqlite/team.go +++ b/server/store/sqlite/team.go @@ -233,3 +233,100 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, // unused but keeping for reference var _ = fmt.Sprintf + +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *TeamStore) Exists(ctx context.Context, teamID string) (bool, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM teams WHERE id = ?`, teamID).Scan(&count) + return count > 0, err +} + +func (s *TeamStore) UpdateMemberRoleByID(ctx context.Context, memberID, teamID, role string) (int64, error) { + res, err := DB.ExecContext(ctx, + `UPDATE team_members SET role = ? WHERE id = ? AND team_id = ?`, + role, memberID, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *TeamStore) DeleteMemberByID(ctx context.Context, memberID, teamID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM team_members WHERE id = ? AND team_id = ?`, + memberID, teamID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *TeamStore) ListTeamAuditActions(ctx context.Context, teamID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT al.action + FROM audit_log al + WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = ?) + ORDER BY al.action + `, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + + var actions []string + for rows.Next() { + var a string + if err := rows.Scan(&a); err != nil { + return nil, err + } + actions = append(actions, a) + } + if actions == nil { + actions = []string{} + } + return actions, rows.Err() +} + +// ── CS5b additions (v0.29.0) ──────────────────────────────────────────── + +func (s *TeamStore) GetFirstTeamIDForUser(ctx context.Context, userID string) (string, error) { + var teamID string + err := DB.QueryRowContext(ctx, + `SELECT team_id FROM team_members WHERE user_id = ? LIMIT 1`, userID).Scan(&teamID) + if err != nil { + return "", nil + } + return teamID, nil +} + +// ── CS6 additions (v0.29.0) ──────────────────────────────────────────── + +func (s *TeamStore) AddMemberReturningID(ctx context.Context, teamID, userID, role string) (string, error) { + id := store.NewID() + _, err := DB.ExecContext(ctx, ` + INSERT INTO team_members (id, team_id, user_id, role) + VALUES (?, ?, ?, ?)`, id, teamID, userID, role) + return id, err +} + +func (s *TeamStore) HasPrivateProviderRequirement(ctx context.Context, userID string) (bool, error) { + var has bool + err := DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM team_members tm + JOIN teams t ON t.id = tm.team_id + WHERE tm.user_id = ? + AND t.is_active = 1 + AND json_extract(t.settings, '$.require_private_providers') = 'true' + )`, userID).Scan(&has) + return has, err +} + +func (s *TeamStore) MergeSettings(ctx context.Context, teamID, settingsJSON string) error { + _, err := DB.ExecContext(ctx, + `UPDATE teams SET settings = json_patch(COALESCE(settings, '{}'), ?) WHERE id = ?`, + settingsJSON, teamID) + return err +} diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go index 48e6bd0..cabde84 100644 --- a/server/store/sqlite/user.go +++ b/server/store/sqlite/user.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "strings" "time" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -193,3 +194,110 @@ func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface ScanJSON(sj, &u.Settings) return &u, nil } +// ── CS1 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE id = ?`, userID).Scan(&count) + return count > 0, err +} + +func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) { + q := ` + SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle + FROM users + WHERE is_active = 1 AND id != ?` + args := []interface{}{excludeUserID} + + if query != "" { + q += ` AND (LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(handle) LIKE ?)` + pattern := "%" + strings.ToLower(query) + "%" + args = append(args, pattern, pattern, pattern) + } + + q += ` ORDER BY username LIMIT 20` + + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []store.UserSearchResult + for rows.Next() { + var u store.UserSearchResult + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil { + continue + } + results = append(results, u) + } + if results == nil { + results = []store.UserSearchResult{} + } + return results, rows.Err() +} + +// ── CS2 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) CountByRole(ctx context.Context, role string) (int, error) { + var count int + err := DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count) + return count, err +} + +func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users SET settings = json_patch( + CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object' + THEN '{}' ELSE settings END, + ?), updated_at = datetime('now') WHERE id = ? + `, string(patch), userID) + return err +} + +func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) { + var vaultSet bool + var encUEK, salt, nonce []byte + err := DB.QueryRowContext(ctx, ` + SELECT vault_set, encrypted_uek, uek_salt, uek_nonce + FROM users WHERE id = ? + `, userID).Scan(&vaultSet, &encUEK, &salt, &nonce) + return vaultSet, encUEK, salt, nonce, err +} + +func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, updated_at = datetime('now') + WHERE id = ? + `, encUEK, salt, nonce, userID) + return err +} + +func (s *UserStore) CountAll(ctx context.Context) (int, error) { + var count int + err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} + +// ── CS4 additions (v0.29.0) ───────────────────────────────────────────── + +func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = 0 + WHERE id = ? + `, userID) + return err +} + +func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { + _, err := DB.ExecContext(ctx, ` + UPDATE users + SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, vault_set = 1 + WHERE id = ? + `, encUEK, salt, nonce, userID) + return err +} diff --git a/server/store/sqlite/user_mention.go b/server/store/sqlite/user_mention.go new file mode 100644 index 0000000..dc087c3 --- /dev/null +++ b/server/store/sqlite/user_mention.go @@ -0,0 +1,62 @@ +package sqlite + +import ( + "context" + "database/sql" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── Mention resolution + display info (v0.29.0) ──────────────────────── + +func (s *UserStore) FindActiveByHandle(ctx context.Context, handle, excludeUserID string) (string, error) { + var id string + err := DB.QueryRowContext(ctx, ` + SELECT id FROM users + WHERE LOWER(handle) = LOWER(?) AND id != ? AND is_active = 1 + LIMIT 1 + `, handle, excludeUserID).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +func (s *UserStore) FindActiveByHandlePrefix(ctx context.Context, prefix, excludeUserID string) (string, int, error) { + var count int + err := DB.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM users + WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1 + `, prefix+"%", excludeUserID).Scan(&count) + if err != nil { + return "", 0, err + } + if count != 1 { + return "", count, nil + } + var id string + err = DB.QueryRowContext(ctx, ` + SELECT id FROM users + WHERE LOWER(handle) LIKE LOWER(?) AND id != ? AND is_active = 1 + LIMIT 1 + `, prefix+"%", excludeUserID).Scan(&id) + return id, 1, err +} + +func (s *UserStore) GetDisplayInfoByIDs(ctx context.Context, ids []string) (map[string]store.UserDisplayInfo, error) { + result := make(map[string]store.UserDisplayInfo) + for _, id := range ids { + var name, avatar sql.NullString + _ = DB.QueryRowContext(ctx, ` + SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ? + `, id).Scan(&name, &avatar) + if name.Valid { + info := store.UserDisplayInfo{Name: name.String} + if avatar.Valid { + info.Avatar = avatar.String + } + result[id] = info + } + } + return result, nil +} diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index dae5d26..b35a8b4 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -364,3 +364,130 @@ func nullIfEmpty(s string) interface{} { } return s } + +// ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── + +func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error { + a.ID = store.NewID() + _, err := DB.ExecContext(ctx, ` + INSERT INTO workflow_assignments (id, channel_id, stage, team_id) + VALUES (?, ?, ?, ?) + `, a.ID, a.ChannelID, a.Stage, a.TeamID) + return err +} + +func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT id, channel_id, stage, team_id, assigned_to, status, + created_at, claimed_at, completed_at + FROM workflow_assignments + WHERE team_id = ? AND status = ? + ORDER BY created_at DESC + `, teamID, status) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAssignments(rows) +} + +func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status, + wa.created_at, wa.claimed_at, wa.completed_at + FROM workflow_assignments wa + LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = ? + WHERE (wa.assigned_to = ? AND wa.status = 'claimed') + OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL) + ORDER BY wa.created_at DESC + `, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAssignments(rows) +} + +func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET assigned_to = ?, status = 'claimed', claimed_at = ? + WHERE id = ? AND status = 'unassigned' + `, userID, time.Now().UTC().Format(timeFmt), assignmentID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) { + res, err := DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET status = 'completed', completed_at = ? + WHERE id = ? AND status = 'claimed' + `, time.Now().UTC().Format(timeFmt), assignmentID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) { + var channelID string + err := DB.QueryRowContext(ctx, + `SELECT channel_id FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&channelID) + return channelID, err +} + +func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim + FROM team_members m + LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = ? + WHERE m.team_id = ? + GROUP BY m.user_id + ORDER BY last_claim ASC + LIMIT 1 + `, teamID, teamID) + if err != nil { + return "", err + } + defer rows.Close() + + if !rows.Next() { + return "", nil + } + var userID, lastClaim string + if err := rows.Scan(&userID, &lastClaim); err != nil { + return "", err + } + + _, err = DB.ExecContext(ctx, ` + UPDATE workflow_assignments + SET assigned_to = ?, status = 'claimed', claimed_at = ? + WHERE id = ? AND status = 'unassigned' + `, userID, time.Now().UTC().Format(timeFmt), assignmentID) + if err != nil { + return "", err + } + return userID, nil +} + +func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) { + var result []store.WorkflowAssignment + for rows.Next() { + var a store.WorkflowAssignment + var claimedAt, completedAt *time.Time + if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, + &a.AssignedTo, &a.Status, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt)); err != nil { + return nil, err + } + a.ClaimedAt = claimedAt + a.CompletedAt = completedAt + result = append(result, a) + } + if result == nil { + result = []store.WorkflowAssignment{} + } + return result, rows.Err() +} diff --git a/server/store/store_memory.go b/server/store/store_memory.go index f9f3b26..37860e8 100644 --- a/server/store/store_memory.go +++ b/server/store/store_memory.go @@ -52,4 +52,16 @@ type MemoryStore interface { // RecallHybrid returns active memories using vector similarity + keyword search. // If queryVec is nil/empty, falls back to keyword-only (same as Recall). RecallHybrid(ctx context.Context, userID string, personaID *string, query string, queryVec []float64, limit int) ([]models.Memory, error) + + // ── CS5b additions (v0.29.0) ── + + // SetEmbedding updates the embedding vector for a memory. Handles dialect differences. + SetEmbedding(ctx context.Context, id, embedding string) error + + // GetLastExtractionMessageID returns the last processed message ID from the extraction log. + // Returns "" if no log entry exists. + GetLastExtractionMessageID(ctx context.Context, channelID, userID string) (string, error) + + // UpsertExtractionLog creates or updates the memory extraction log entry. + UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error } diff --git a/server/store/tree_types.go b/server/store/tree_types.go new file mode 100644 index 0000000..92aa9aa --- /dev/null +++ b/server/store/tree_types.go @@ -0,0 +1,54 @@ +package store + +import "encoding/json" + +// ── Tree Types (v0.29.0) ──────────────────── +// Moved from treepath package to store so MessageStore methods can +// return enriched path/sibling data without import cycles. +// treepath aliases these types for backward compatibility. + +// PathMessage represents a single message in an active path, enriched +// with sibling metadata for the frontend branch indicator. +type PathMessage struct { + ID string `json:"id"` + ParentID *string `json:"parent_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used,omitempty"` + ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` + Metadata *json.RawMessage `json:"metadata,omitempty"` + SiblingCount int `json:"sibling_count"` + SiblingIndex int `json:"sibling_index"` + ParticipantType string `json:"participant_type,omitempty"` + ParticipantID string `json:"participant_id,omitempty"` + SenderName *string `json:"sender_name,omitempty"` + SenderAvatar *string `json:"sender_avatar,omitempty"` + CreatedAt string `json:"created_at"` +} + +// SiblingInfo is a lightweight entry for the siblings listing endpoint. +type SiblingInfo struct { + ID string `json:"id"` + Role string `json:"role"` + Model *string `json:"model,omitempty"` + SiblingIndex int `json:"sibling_index"` + Preview string `json:"preview"` // first ~80 chars of content + CreatedAt string `json:"created_at"` +} + +// MentionResult is the outcome of resolving an @mention token. +// Exactly one of PersonaID, UserID, or ModelID will be non-empty. +type MentionResult struct { + ModelID string // resolved model ID (for @model mentions) + ProviderConfigID string // provider config for model-based mentions + PersonaID string // matched persona ID + PersonaName string // persona display name (for logging) + UserID string // matched user ID (for @user mentions) +} + +// UserDisplayInfo holds name + avatar for sender resolution in paths. +type UserDisplayInfo struct { + Name string + Avatar string +} diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go index 9971a12..ba62314 100644 --- a/server/store/workflow_iface.go +++ b/server/store/workflow_iface.go @@ -28,4 +28,30 @@ type WorkflowStore interface { Publish(ctx context.Context, v *models.WorkflowVersion) error GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) + + // ── Assignments (v0.29.0-cs3) ── + + // CreateAssignment inserts a workflow_assignments row. + CreateAssignment(ctx context.Context, a *WorkflowAssignment) error + + // ListAssignmentsForTeam returns assignments for a team filtered by status. + ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]WorkflowAssignment, error) + + // ListAssignmentsMine returns claimed + unassigned assignments visible to a user. + ListAssignmentsMine(ctx context.Context, userID string) ([]WorkflowAssignment, error) + + // ClaimAssignment sets assigned_to and status='claimed' on an unassigned row. + // Returns rows affected (0 = already claimed or not found). + ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) + + // CompleteAssignment sets status='completed' on a claimed row. + // Returns rows affected (0 = not claimed or not found). + CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) + + // GetAssignmentChannelID returns the channel_id for an assignment. + GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) + + // TryRoundRobin finds the least-recently-assigned team member and claims. + // Returns the assigned user ID, or "" if no members available. + TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) } diff --git a/server/store/workflow_types.go b/server/store/workflow_types.go new file mode 100644 index 0000000..6a5d7ac --- /dev/null +++ b/server/store/workflow_types.go @@ -0,0 +1,29 @@ +package store + +import ( + "encoding/json" + "time" +) + +// WorkflowChannelStatus holds the workflow state columns from the channels table. +type WorkflowChannelStatus struct { + WorkflowID *string `json:"workflow_id"` + WorkflowVersion *int `json:"workflow_version"` + CurrentStage int `json:"current_stage"` + StageData json.RawMessage `json:"stage_data"` + Status string `json:"status"` + LastActivityAt *string `json:"last_activity_at"` +} + +// WorkflowAssignment is a row from the workflow_assignments table. +type WorkflowAssignment struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Stage int `json:"stage"` + TeamID string `json:"team_id"` + AssignedTo *string `json:"assigned_to"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ClaimedAt *time.Time `json:"claimed_at"` + CompletedAt *time.Time `json:"completed_at"` +} diff --git a/server/tools/conversation_search.go b/server/tools/conversation_search.go index 299030e..4a5a969 100644 --- a/server/tools/conversation_search.go +++ b/server/tools/conversation_search.go @@ -7,7 +7,6 @@ import ( "log" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -66,34 +65,10 @@ func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionC args.MaxResults = 5 } - // Build query — full-text search scoped to this channel - // Uses on-the-fly to_tsvector (no index needed — channel-scoped is bounded) - roleClause := "" - queryArgs := []interface{}{execCtx.ChannelID, args.Query, args.MaxResults} - if args.RoleFilter == "user" || args.RoleFilter == "assistant" { - roleClause = "AND m.role = $4" - queryArgs = append(queryArgs, args.RoleFilter) - } - - rows, err := database.DB.QueryContext(ctx, fmt.Sprintf(` - SELECT m.id, m.role, - ts_headline('english', m.content, plainto_tsquery('english', $2), - 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline, - ts_rank(to_tsvector('english', m.content), plainto_tsquery('english', $2)) AS rank, - m.created_at - FROM messages m - WHERE m.channel_id = $1 - AND m.deleted_at IS NULL - AND m.role IN ('user', 'assistant') - AND to_tsvector('english', m.content) @@ plainto_tsquery('english', $2) - %s - ORDER BY rank DESC, m.created_at DESC - LIMIT $3 - `, roleClause), queryArgs...) + storeResults, err := t.stores.Messages.SearchInChannel(ctx, execCtx.ChannelID, args.Query, args.RoleFilter, args.MaxResults) if err != nil { return "", fmt.Errorf("search failed: %w", err) } - defer rows.Close() type searchResult struct { MessageID string `json:"message_id"` @@ -103,17 +78,15 @@ func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionC Timestamp string `json:"timestamp"` } - results := make([]searchResult, 0) - for rows.Next() { - var r searchResult - var rank float64 - var createdAt time.Time - if err := rows.Scan(&r.MessageID, &r.Role, &r.Excerpt, &rank, &createdAt); err != nil { - continue - } - r.Rank = rank - r.Timestamp = createdAt.Format(time.RFC3339) - results = append(results, r) + results := make([]searchResult, 0, len(storeResults)) + for _, sr := range storeResults { + results = append(results, searchResult{ + MessageID: sr.MessageID, + Role: sr.Role, + Excerpt: sr.Excerpt, + Rank: sr.Rank, + Timestamp: sr.Timestamp.Format(time.RFC3339), + }) } log.Printf("🔍 conversation_search: %q → %d results in channel %s", diff --git a/server/tools/memory.go b/server/tools/memory.go index e688a63..eaa623c 100644 --- a/server/tools/memory.go +++ b/server/tools/memory.go @@ -7,7 +7,6 @@ import ( "log" "strings" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" @@ -132,13 +131,7 @@ func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, user } vecStr := vectorToString(result.Vectors[0]) - if database.IsSQLite() { - database.DB.Exec(`UPDATE memories SET embedding = ? WHERE id = ?`, - vecStr, m.ID) - } else { - database.DB.Exec(`UPDATE memories SET embedding = $1::vector WHERE id = $2`, - vecStr, m.ID) - } + _ = t.stores.Memories.SetEmbedding(ctx, m.ID, vecStr) log.Printf("🧠 memory %s embedded (%d dims)", m.ID[:8], len(result.Vectors[0])) } diff --git a/server/tools/notes.go b/server/tools/notes.go index 726f617..2425126 100644 --- a/server/tools/notes.go +++ b/server/tools/notes.go @@ -7,10 +7,8 @@ import ( "log" "strings" - "github.com/lib/pq" - - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/knowledge" + "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -26,7 +24,7 @@ func RegisterNoteTools(stores store.Stores, embedder *knowledge.Embedder) { Register(¬eCreateTool{stores: stores, embedder: embedder}) Register(¬eSearchTool{stores: stores, embedder: embedder}) Register(¬eUpdateTool{stores: stores, embedder: embedder}) - Register(¬eListTool{}) + Register(¬eListTool{stores: stores}) } // ── Shared helpers ───────────────────────────── @@ -61,12 +59,8 @@ func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.S return } - // Store the vector — format as pgvector literal - vecStr := vectorToString(result.Vectors[0]) - _, err = database.DB.Exec( - `UPDATE notes SET embedding = $1::vector WHERE id = $2`, - vecStr, noteID, - ) + // Store the vector via store method + err = stores.Notes.SetEmbedding(ctx, noteID, vectorToString(result.Vectors[0])) if err != nil { log.Printf("⚠ note embed store failed for %s: %v", noteID, err) return @@ -138,27 +132,28 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, sourceChannelID = &execCtx.ChannelID } - var id, createdAt string - err := database.DB.QueryRow(` - INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, created_at::text - `, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID, - ).Scan(&id, &createdAt) + note := &models.Note{ + UserID: execCtx.UserID, + Title: args.Title, + Content: args.Content, + FolderPath: folder, + Tags: tags, + SourceChannelID: sourceChannelID, + } - if err != nil { + if err := t.stores.Notes.Create(ctx, note); err != nil { return "", fmt.Errorf("failed to create note: %w", err) } // Embed async — don't block the tool response - go embedNote(context.Background(), t.embedder, t.stores, id, execCtx.UserID, args.Title, args.Content) + go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content) result, _ := json.Marshal(map[string]interface{}{ - "id": id, + "id": note.ID, "title": args.Title, "folder": folder, "tags": tags, - "created_at": createdAt, + "created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"), "message": fmt.Sprintf("Note '%s' created successfully.", args.Title), }) return string(result), nil @@ -220,35 +215,22 @@ func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, } func (t *noteSearchTool) keywordSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) { - rows, err := database.DB.Query(` - SELECT id, title, folder_path, tags, LEFT(content, 500), - ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, - ts_headline('english', content, plainto_tsquery('english', $2), - 'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline - FROM notes - WHERE user_id = $1 - AND search_vector @@ plainto_tsquery('english', $2) - ORDER BY rank DESC - LIMIT $3 - `, execCtx.UserID, query, limit) - + storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit) if err != nil { return "", fmt.Errorf("search failed: %w", err) } - defer rows.Close() - results := make([]noteSearchResult, 0) - for rows.Next() { - var r noteSearchResult - var dbTags pq.StringArray - if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil { - continue - } - r.Tags = []string(dbTags) - if r.Tags == nil { - r.Tags = []string{} - } - results = append(results, r) + results := make([]noteSearchResult, 0, len(storeResults)) + for _, sr := range storeResults { + results = append(results, noteSearchResult{ + ID: sr.ID, + Title: sr.Title, + Folder: sr.FolderPath, + Tags: sr.Tags, + Excerpt: sr.Excerpt, + Headline: sr.Headline, + Rank: sr.Rank, + }) } out, _ := json.Marshal(map[string]interface{}{ @@ -292,36 +274,23 @@ func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionCo queryVec := vectorToString(embedResult.Vectors[0]) - // Vector similarity search — only notes that have embeddings - rows, err := database.DB.Query(` - SELECT id, title, folder_path, tags, LEFT(content, 500), - 1 - (embedding <=> $2::vector) AS similarity - FROM notes - WHERE user_id = $1 - AND embedding IS NOT NULL - AND 1 - (embedding <=> $2::vector) > 0.3 - ORDER BY embedding <=> $2::vector - LIMIT $3 - `, execCtx.UserID, queryVec, limit) - + // Vector similarity search via store + storeResults, err := t.stores.Notes.SearchSemantic(ctx, execCtx.UserID, queryVec, limit) if err != nil { log.Printf("⚠ note semantic search query failed: %v — falling back to keyword", err) return t.keywordSearch(ctx, execCtx, query, limit) } - defer rows.Close() - results := make([]noteSearchResult, 0) - for rows.Next() { - var r noteSearchResult - var dbTags pq.StringArray - if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Similarity); err != nil { - continue - } - r.Tags = []string(dbTags) - if r.Tags == nil { - r.Tags = []string{} - } - results = append(results, r) + results := make([]noteSearchResult, 0, len(storeResults)) + for _, sr := range storeResults { + results = append(results, noteSearchResult{ + ID: sr.ID, + Title: sr.Title, + Folder: sr.FolderPath, + Tags: sr.Tags, + Excerpt: sr.Excerpt, + Similarity: sr.Rank, + }) } out, _ := json.Marshal(map[string]interface{}{ @@ -386,60 +355,57 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, return "", fmt.Errorf("note_id is required") } - // Build dynamic update - setClauses := []string{} - queryArgs := []interface{}{} - argIdx := 1 + // Verify ownership + get existing content for append/prepend + existing, err := t.stores.Notes.GetByID(ctx, args.NoteID) + if err != nil || existing.UserID != execCtx.UserID { + return "", fmt.Errorf("note not found or update failed") + } + + // Build fields map for store.Update + fields := map[string]interface{}{} if args.Title != nil { - setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx)) - queryArgs = append(queryArgs, *args.Title) - argIdx++ + fields["title"] = *args.Title } if args.Content != nil { mode := strings.ToLower(args.Mode) switch mode { case "append": - setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx)) + fields["content"] = existing.Content + *args.Content case "prepend": - setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx)) + fields["content"] = *args.Content + existing.Content default: - setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx)) + fields["content"] = *args.Content } - queryArgs = append(queryArgs, *args.Content) - argIdx++ } if args.Tags != nil { - setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx)) - queryArgs = append(queryArgs, pq.Array(args.Tags)) - argIdx++ + fields["tags"] = args.Tags } - if len(setClauses) == 0 { + if len(fields) == 0 { return "", fmt.Errorf("no fields to update — provide at least title, content, or tags") } - queryArgs = append(queryArgs, args.NoteID, execCtx.UserID) - query := "UPDATE notes SET " + strings.Join(setClauses, ", ") + - fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) + - " RETURNING id, title, content, updated_at::text" + if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil { + return "", fmt.Errorf("note not found or update failed") + } - var id, title, content, updatedAt string - err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &content, &updatedAt) + // Re-fetch to get updated values + updated, err := t.stores.Notes.GetByID(ctx, args.NoteID) if err != nil { return "", fmt.Errorf("note not found or update failed") } // Re-embed async with updated content - go embedNote(context.Background(), t.embedder, t.stores, id, execCtx.UserID, title, content) + go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content) result, _ := json.Marshal(map[string]interface{}{ - "id": id, - "title": title, - "updated_at": updatedAt, - "message": fmt.Sprintf("Note '%s' updated successfully.", title), + "id": updated.ID, + "title": updated.Title, + "updated_at": updated.UpdatedAt.Format("2006-01-02T15:04:05Z"), + "message": fmt.Sprintf("Note '%s' updated successfully.", updated.Title), }) return string(result), nil } @@ -448,7 +414,10 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, // note_list // ═══════════════════════════════════════════ -type noteListTool struct{ visitorDeniedBase } +type noteListTool struct { + visitorDeniedBase + stores store.Stores +} func (t *noteListTool) Definition() ToolDef { return ToolDef{ @@ -478,31 +447,20 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar args.Limit = 20 } - query := `SELECT id, title, folder_path, tags, LEFT(content, 100), - created_at::text, updated_at::text - FROM notes WHERE user_id = $1` - queryArgs := []interface{}{execCtx.UserID} - argIdx := 2 - + opts := store.NoteListOptions{ + ListOptions: store.ListOptions{Limit: args.Limit}, + } if args.Folder != "" { - query += fmt.Sprintf(" AND folder_path = $%d", argIdx) - queryArgs = append(queryArgs, normalizePath(args.Folder)) - argIdx++ + opts.FolderPath = normalizePath(args.Folder) } if args.Tag != "" { - query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx) - queryArgs = append(queryArgs, args.Tag) - argIdx++ + opts.Tag = args.Tag } - query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx) - queryArgs = append(queryArgs, args.Limit) - - rows, err := database.DB.Query(query, queryArgs...) + notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts) if err != nil { return "", fmt.Errorf("failed to list notes: %w", err) } - defer rows.Close() type item struct { ID string `json:"id"` @@ -514,19 +472,25 @@ func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, ar UpdatedAt string `json:"updated_at"` } - items := make([]item, 0) - for rows.Next() { - var n item - var dbTags pq.StringArray - if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview, - &n.CreatedAt, &n.UpdatedAt); err != nil { - continue + items := make([]item, 0, len(notes)) + for _, n := range notes { + tags := n.Tags + if tags == nil { + tags = []string{} } - n.Tags = []string(dbTags) - if n.Tags == nil { - n.Tags = []string{} + preview := n.Content + if len(preview) > 100 { + preview = preview[:100] } - items = append(items, n) + items = append(items, item{ + ID: n.ID, + Title: n.Title, + Folder: n.FolderPath, + Tags: tags, + Preview: preview, + CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"), + }) } out, _ := json.Marshal(map[string]interface{}{ diff --git a/server/tools/task_create.go b/server/tools/task_create.go index aba01d2..070db6d 100644 --- a/server/tools/task_create.go +++ b/server/tools/task_create.go @@ -7,7 +7,6 @@ import ( "log" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/taskutil" @@ -112,10 +111,7 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, // Depth guard: verify we're not inside a service channel (task output) if execCtx.ChannelID != "" { - var channelType string - _ = database.DB.QueryRowContext(ctx, database.Q( - `SELECT type FROM channels WHERE id = $1`, - ), execCtx.ChannelID).Scan(&channelType) + channelType, _, _ := t.stores.Channels.GetTypeAndTeamID(ctx, execCtx.ChannelID) if channelType == "service" { return "", fmt.Errorf("tasks cannot create tasks (depth limit)") } @@ -124,10 +120,7 @@ func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, // Resolve persona ID from handle var personaID *string if args.Persona != "" { - var pID string - err := database.DB.QueryRowContext(ctx, database.Q( - `SELECT id FROM personas WHERE LOWER(handle) = LOWER($1) AND is_active = true`, - ), args.Persona).Scan(&pID) + pID, err := t.stores.Personas.FindActiveByHandle(ctx, args.Persona) if err == nil && pID != "" { personaID = &pID } diff --git a/server/tools/workflow.go b/server/tools/workflow.go index 8a2a7ff..afcb72c 100644 --- a/server/tools/workflow.go +++ b/server/tools/workflow.go @@ -7,7 +7,6 @@ import ( "log" "time" - "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/webhook" @@ -79,18 +78,13 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont } // Read current workflow state - var workflowID string - var currentStage int - var status string - var wfID *string - err := database.DB.QueryRowContext(ctx, database.Q(` - SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active') - FROM channels WHERE id = $1 AND type = 'workflow' - `), channelID).Scan(&wfID, ¤tStage, &status) - if err != nil || wfID == nil { + ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID) + if err != nil || ws == nil || ws.WorkflowID == nil { return "", fmt.Errorf("not a workflow channel") } - workflowID = *wfID + workflowID := *ws.WorkflowID + currentStage := ws.CurrentStage + status := ws.Status if status != "active" { return "", fmt.Errorf("workflow is %s, cannot advance", status) @@ -103,17 +97,12 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont } // Merge collected data into stage_data - mergedData := MergeWorkflowStageData(ctx, channelID, args.Data) + mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data) nextStage := currentStage + 1 if nextStage >= len(stages) { // Workflow complete - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET current_stage = $1, workflow_status = 'completed', - stage_data = $2, last_activity_at = $3, ai_mode = 'off' - WHERE id = $4 - `), nextStage, mergedData, time.Now().UTC(), channelID) + err = t.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) if err != nil { return "", fmt.Errorf("failed to complete workflow: %w", err) } @@ -133,11 +122,7 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont } // Advance to next stage - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET current_stage = $1, stage_data = $2, last_activity_at = $3 - WHERE id = $4 - `), nextStage, mergedData, time.Now().UTC(), channelID) + err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) if err != nil { return "", fmt.Errorf("failed to advance: %w", err) } @@ -148,7 +133,7 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont // Create assignment if next stage has an assignment team nextStageDef := stages[nextStage] if nextStageDef.AssignmentTeamID != nil { - CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID) + CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID) } result, _ := json.Marshal(map[string]interface{}{ @@ -163,11 +148,9 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont // ── Shared helpers (used by both tool and handler) ─ // MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string. -func MergeWorkflowStageData(ctx context.Context, channelID string, newData json.RawMessage) string { - var existing []byte - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1 - `), channelID).Scan(&existing) +// v0.29.0: channelStore parameter replaces raw database.DB access. +func MergeWorkflowStageData(ctx context.Context, channelStore store.ChannelStore, channelID string, newData json.RawMessage) string { + existing, _ := channelStore.GetStageData(ctx, channelID) if len(newData) == 0 || string(newData) == "null" { if len(existing) == 0 { @@ -204,10 +187,11 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID content := string(data) // Find workflow channel owner for the note's user_id - var userID string - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT user_id FROM channels WHERE id = $1 - `), channelID).Scan(&userID) + ch, err := stores.Channels.GetByID(ctx, channelID) + if err != nil || ch == nil { + return + } + userID := ch.UserID if userID == "" { return } @@ -226,28 +210,18 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID // CreateWorkflowAssignment creates an unassigned workflow assignment entry. // Returns the assignment ID (for round-robin auto-assignment). -func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) string { - id := store.NewID() - if database.IsSQLite() { - _, err := database.DB.ExecContext(ctx, ` - INSERT INTO workflow_assignments (id, channel_id, stage, team_id) - VALUES (?, ?, ?, ?) - `, id, channelID, stage, teamID) - if err != nil { - log.Printf("⚠️ Failed to create workflow assignment: %v", err) - return "" - } - return id +// v0.29.0: accepts stores parameter, delegates to WorkflowStore. +func CreateWorkflowAssignment(ctx context.Context, stores store.Stores, channelID string, stage int, teamID string) string { + a := &store.WorkflowAssignment{ + ChannelID: channelID, + Stage: stage, + TeamID: teamID, } - _, err := database.DB.ExecContext(ctx, ` - INSERT INTO workflow_assignments (id, channel_id, stage, team_id) - VALUES ($1, $2, $3, $4) - `, id, channelID, stage, teamID) - if err != nil { + if err := stores.Workflows.CreateAssignment(ctx, a); err != nil { log.Printf("⚠️ Failed to create workflow assignment: %v", err) return "" } - return id + return a.ID } // ── Workflow Completion Triggers (v0.27.3) ── @@ -307,10 +281,11 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo targetData = sourceData } - var ownerID string - _ = database.DB.QueryRowContext(ctx, database.Q( - `SELECT user_id FROM channels WHERE id = $1`, - ), channelID).Scan(&ownerID) + ch2, err2 := stores.Channels.GetByID(ctx, channelID) + if err2 != nil || ch2 == nil { + return + } + ownerID := ch2.UserID if ownerID == "" { return } @@ -338,17 +313,12 @@ func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflo } initialData, _ := json.Marshal(targetData) - _, err = database.DB.ExecContext(ctx, database.Q(` - UPDATE channels - SET workflow_id = $1, workflow_version = $2, current_stage = 0, - stage_data = $3, workflow_status = 'active', - last_activity_at = $4, ai_mode = 'auto' - WHERE id = $5 - `), target.ID, ver.VersionNumber, string(initialData), time.Now().UTC(), ch.ID) + err = stores.Channels.SetWorkflowInstance(ctx, ch.ID, target.ID, ver.VersionNumber, initialData, "active") if err != nil { log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err) return } + _ = stores.Channels.Update(ctx, ch.ID, map[string]interface{}{"ai_mode": "auto"}) // Bind first stage persona if len(stages) > 0 && stages[0].PersonaID != nil { diff --git a/server/treepath/cursor.go b/server/treepath/cursor.go deleted file mode 100644 index 0147994..0000000 --- a/server/treepath/cursor.go +++ /dev/null @@ -1,53 +0,0 @@ -package treepath - -import ( - "database/sql" - - "github.com/google/uuid" - - "git.gobha.me/xcaliber/chat-switchboard/database" -) - -// UpdateCursor upserts the channel_cursors row for a user. -func UpdateCursor(channelID, userID, messageID string) error { - if database.IsSQLite() { - // SQLite: id TEXT PRIMARY KEY has no default generator; supply one. - // Use excluded.active_leaf_id so ON CONFLICT works cleanly. - _, err := database.DB.Exec(` - INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id) - VALUES (?, ?, ?, ?) - ON CONFLICT (channel_id, user_id) DO UPDATE SET - active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now') - `, uuid.New().String(), channelID, userID, messageID) - return err - } - // Postgres: id has DEFAULT gen_random_uuid(); $3 is referenced twice. - _, err := database.DB.Exec(` - INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) - VALUES ($1, $2, $3) - ON CONFLICT (channel_id, user_id) DO UPDATE SET - active_leaf_id = $3, updated_at = NOW() - `, channelID, userID, messageID) - return err -} - -// NextSiblingIndex returns the next sibling_index for a new child of parentID. -// For root messages (parentID is nil), counts existing roots in the channel. -func NextSiblingIndex(channelID string, parentID *string) int { - var maxIdx sql.NullInt64 - if parentID == nil { - database.DB.QueryRow(database.Q(` - SELECT MAX(sibling_index) FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - `), channelID).Scan(&maxIdx) - } else { - database.DB.QueryRow(database.Q(` - SELECT MAX(sibling_index) FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - `), *parentID).Scan(&maxIdx) - } - if !maxIdx.Valid { - return 0 - } - return int(maxIdx.Int64) + 1 -} diff --git a/server/treepath/path.go b/server/treepath/path.go index e169344..206fe28 100644 --- a/server/treepath/path.go +++ b/server/treepath/path.go @@ -1,239 +1,75 @@ +// Package treepath provides message tree traversal operations. +// +// v0.29.0: Delegates to store.MessageStore. The package exists only for +// backward compatibility with handler aliases in tree.go. New code should +// call stores.Messages.* directly. This package will be deleted once all +// callers are migrated. package treepath import ( - "database/sql" + "context" "encoding/json" - "fmt" - "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/store" ) -// ── Tree Types ────────────────────────────── +// Stores is set at startup by main.go. All tree operations delegate here. +// If nil, functions panic — there is no fallback to raw SQL. +var Stores *store.Stores -// PathMessage represents a single message in an active path, enriched -// with sibling metadata for the frontend branch indicator. -type PathMessage struct { - ID string `json:"id"` - ParentID *string `json:"parent_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used,omitempty"` - ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` - Metadata *json.RawMessage `json:"metadata,omitempty"` - SiblingCount int `json:"sibling_count"` - SiblingIndex int `json:"sibling_index"` - ParticipantType string `json:"participant_type,omitempty"` - ParticipantID string `json:"participant_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - SenderAvatar *string `json:"sender_avatar,omitempty"` - CreatedAt string `json:"created_at"` -} +// ── Type Aliases ──────────────────────────── +// Existing consumers reference these types. They're now aliases into the +// store package where the canonical definitions live. -// SiblingInfo is a lightweight entry for the siblings listing endpoint. -type SiblingInfo struct { - ID string `json:"id"` - Role string `json:"role"` - Model *string `json:"model,omitempty"` - SiblingIndex int `json:"sibling_index"` - Preview string `json:"preview"` // first ~80 chars of content - CreatedAt string `json:"created_at"` -} +type PathMessage = store.PathMessage +type SiblingInfo = store.SiblingInfo -// ── Get Active Leaf ───────────────────────── +// ── Public Functions ──────────────────────── +// Same signatures as before. All delegate to store methods. -// GetActiveLeaf returns the cursor's active_leaf_id for a user in a channel. -// Falls back to the chronologically latest live message if no cursor exists. func GetActiveLeaf(channelID, userID string) (*string, error) { - var leafID *string - - // Try cursor first - err := database.DB.QueryRow(database.Q(` - SELECT active_leaf_id FROM channel_cursors - WHERE channel_id = $1 AND user_id = $2 - `), channelID, userID).Scan(&leafID) - - if err == nil && leafID != nil { - // Verify the leaf still exists and isn't deleted - var exists bool - database.DB.QueryRow(database.Q(` - SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) - `), *leafID).Scan(&exists) - if exists { - return leafID, nil - } - } - - // Fallback: latest live message in channel - var fallbackID string - err = database.DB.QueryRow(database.Q(` - SELECT id FROM messages - WHERE channel_id = $1 AND deleted_at IS NULL - ORDER BY created_at DESC LIMIT 1 - `), channelID).Scan(&fallbackID) - if err == sql.ErrNoRows { - return nil, nil // empty channel - } - if err != nil { - return nil, err - } - return &fallbackID, nil + return Stores.Messages.GetActiveLeaf(context.Background(), channelID, userID) } -// ── Get Active Path ───────────────────────── - -// GetActivePath walks from the active leaf up to the root via parent_id, -// returning messages in root-first order. This is what gets sent to the LLM. func GetActivePath(channelID, userID string) ([]PathMessage, error) { - leafID, err := GetActiveLeaf(channelID, userID) - if err != nil { - return nil, err - } - if leafID == nil { - return []PathMessage{}, nil // empty channel - } - - return GetPathToLeaf(channelID, *leafID) + return Stores.Messages.GetActivePath(context.Background(), channelID, userID) } -// GetPathToLeaf walks from a specific leaf up to root, returning root-first. func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) { - rows, err := database.DB.Query(database.Q(` - WITH RECURSIVE path AS ( - -- Anchor: start at the leaf - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at, - 0 AS depth - FROM messages - WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL - - UNION ALL - - -- Walk up via parent_id - SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, - m.participant_type, m.participant_id, m.sibling_index, m.created_at, - p.depth + 1 - FROM messages m - JOIN path p ON m.id = p.parent_id - WHERE m.deleted_at IS NULL - ) - SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, - participant_type, participant_id, sibling_index, created_at - FROM path - ORDER BY depth DESC - `), leafID, channelID) - if err != nil { - return nil, fmt.Errorf("GetPathToLeaf: %w", err) - } - defer rows.Close() - - var path []PathMessage - for rows.Next() { - var m PathMessage - var participantType, participantID sql.NullString - var toolCallsJSON, metadataJSON []byte - if err := rows.Scan( - &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, - &toolCallsJSON, &metadataJSON, - &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, - ); err != nil { - return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) - } - if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { - raw := json.RawMessage(toolCallsJSON) - m.ToolCalls = &raw - } - if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { - raw := json.RawMessage(metadataJSON) - m.Metadata = &raw - } - if participantType.Valid { - m.ParticipantType = participantType.String - } - if participantID.Valid { - m.ParticipantID = participantID.String - } - path = append(path, m) - } - - // Enrich with sibling counts - for i := range path { - path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID) - } - - // v0.23.2: Resolve sender names and avatars - resolveSenderInfo(path) - - return path, nil + return Stores.Messages.GetPathToLeaf(context.Background(), channelID, leafID) } -// resolveSenderInfo batch-resolves sender_name and sender_avatar for path messages. -func resolveSenderInfo(path []PathMessage) { - // Collect unique participant IDs by type - userIDs := map[string]bool{} - personaIDs := map[string]bool{} - for _, m := range path { - if m.ParticipantID == "" { - continue - } - switch m.ParticipantType { - case "user": - userIDs[m.ParticipantID] = true - case "persona": - personaIDs[m.ParticipantID] = true - } - } - - // Resolve users - userNames := map[string]string{} - userAvatars := map[string]string{} - for uid := range userIDs { - var name, avatar sql.NullString - _ = database.DB.QueryRow(database.Q(` - SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 - `), uid).Scan(&name, &avatar) - if name.Valid { - userNames[uid] = name.String - } - if avatar.Valid { - userAvatars[uid] = avatar.String - } - } - - // Resolve personas - personaNames := map[string]string{} - personaAvatars := map[string]string{} - for pid := range personaIDs { - var name, avatar sql.NullString - _ = database.DB.QueryRow(database.Q(` - SELECT name, avatar FROM personas WHERE id = $1 - `), pid).Scan(&name, &avatar) - if name.Valid { - personaNames[pid] = name.String - } - if avatar.Valid { - personaAvatars[pid] = avatar.String - } - } - - // Apply to path - for i := range path { - pid := path[i].ParticipantID - switch path[i].ParticipantType { - case "user": - if n, ok := userNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := userAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - case "persona": - if n, ok := personaNames[pid]; ok { - path[i].SenderName = &n - } - if a, ok := personaAvatars[pid]; ok && a != "" { - path[i].SenderAvatar = &a - } - } - } +func GetSiblingCount(channelID string, parentID *string) int { + count, _ := Stores.Messages.GetSiblingCount(context.Background(), channelID, parentID) + return count +} + +func GetSiblings(messageID string) ([]SiblingInfo, int, error) { + return Stores.Messages.GetSiblingsList(context.Background(), messageID) +} + +func FindLeafFromMessage(messageID string) (string, error) { + return Stores.Messages.FindLeafFromMessage(context.Background(), messageID) +} + +func UpdateCursor(channelID, userID, messageID string) error { + return Stores.Channels.SetCursor(context.Background(), channelID, userID, messageID) +} + +func NextSiblingIndex(channelID string, parentID *string) int { + idx, _ := Stores.Messages.NextSiblingIndexForParent(context.Background(), channelID, parentID) + return idx +} + +// IsSummaryMessage checks if a PathMessage has summary metadata. +func IsSummaryMessage(m *PathMessage) bool { + if m.Metadata == nil { + return false + } + var meta map[string]interface{} + if err := json.Unmarshal(*m.Metadata, &meta); err != nil { + return false + } + t, _ := meta["type"].(string) + return t == "summary" } diff --git a/server/treepath/siblings.go b/server/treepath/siblings.go deleted file mode 100644 index c7100a6..0000000 --- a/server/treepath/siblings.go +++ /dev/null @@ -1,114 +0,0 @@ -package treepath - -import ( - "database/sql" - "fmt" - - "git.gobha.me/xcaliber/chat-switchboard/database" -) - -// GetSiblingCount returns how many live siblings share the same parent_id. -// Root messages (parent_id IS NULL) count all roots in the same channel. -func GetSiblingCount(channelID string, parentID *string) int { - var count int - if parentID == nil { - database.DB.QueryRow(database.Q(` - SELECT COUNT(*) FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - `), channelID).Scan(&count) - } else { - database.DB.QueryRow(database.Q(` - SELECT COUNT(*) FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - `), *parentID).Scan(&count) - } - if count == 0 { - count = 1 - } - return count -} - -// GetSiblings returns all live siblings of a given message (including itself), -// ordered by sibling_index. -func GetSiblings(messageID string) ([]SiblingInfo, int, error) { - // First get the parent_id of the target message - var parentID *string - var channelID string - err := database.DB.QueryRow(database.Q(` - SELECT parent_id, channel_id FROM messages - WHERE id = $1 AND deleted_at IS NULL - `), messageID).Scan(&parentID, &channelID) - if err != nil { - return nil, 0, fmt.Errorf("message not found: %w", err) - } - - var rows *sql.Rows - if parentID == nil { - // Root messages: siblings are other roots in the same channel - rows, err = database.DB.Query(database.Q(` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `), channelID) - } else { - rows, err = database.DB.Query(database.Q(` - SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at - FROM messages - WHERE parent_id = $1 AND deleted_at IS NULL - ORDER BY sibling_index, created_at - `), *parentID) - } - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var siblings []SiblingInfo - currentIdx := 0 - for i := 0; rows.Next(); i++ { - var s SiblingInfo - if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil { - return nil, 0, err - } - if s.ID == messageID { - currentIdx = i - } - siblings = append(siblings, s) - } - - return siblings, currentIdx, nil -} - -// FindLeafFromMessage walks down from a message to find the deepest descendant. -// Follows the first child (lowest sibling_index) at each level. -// Used when switching branches: clicking a mid-tree sibling navigates to its leaf. -func FindLeafFromMessage(messageID string) (string, error) { - var leafID string - err := database.DB.QueryRow(database.Q(` - WITH RECURSIVE descendants AS ( - SELECT id, 0 AS depth - FROM messages - WHERE id = $1 AND deleted_at IS NULL - - UNION ALL - - SELECT child.id, d.depth + 1 - FROM messages child - JOIN descendants d ON child.parent_id = d.id - WHERE child.deleted_at IS NULL - AND child.sibling_index = ( - SELECT MIN(sibling_index) FROM messages - WHERE parent_id = d.id AND deleted_at IS NULL - ) - ) - SELECT id FROM descendants - ORDER BY depth DESC - LIMIT 1 - `), messageID).Scan(&leafID) - - if err != nil { - return messageID, nil // if anything fails, just use the message itself - } - return leafID, nil -} diff --git a/server/treepath/summary.go b/server/treepath/summary.go deleted file mode 100644 index 5fd90b8..0000000 --- a/server/treepath/summary.go +++ /dev/null @@ -1,38 +0,0 @@ -package treepath - -import "encoding/json" - -// IsSummaryMessage checks if a PathMessage has summary metadata -// (metadata.type == "summary"). -func IsSummaryMessage(m *PathMessage) bool { - if m.Metadata == nil { - return false - } - meta := ParseMetadata(m) - return meta["type"] == "summary" -} - -// ParseMetadata unmarshals the JSONB metadata on a PathMessage. -// Returns an empty map on any error. -func ParseMetadata(m *PathMessage) map[string]interface{} { - if m.Metadata == nil { - return map[string]interface{}{} - } - var meta map[string]interface{} - if err := json.Unmarshal(*m.Metadata, &meta); err != nil { - return map[string]interface{}{} - } - return meta -} - -// FindSummaryBoundary returns the index of the most recent summary node -// in a path, or -1 if no summary exists. -func FindSummaryBoundary(path []PathMessage) int { - idx := -1 - for i := range path { - if IsSummaryMessage(&path[i]) { - idx = i - } - } - return idx -} diff --git a/src/js/api.js b/src/js/api.js index 9117928..379d0c6 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -337,7 +337,7 @@ const API = { listWorkspaces() { return this._get('/api/v1/workspaces'); }, createWorkspace(data) { return this._post('/api/v1/workspaces', data); }, updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); }, - deleteWorkspace(id) { return this._delete(`/api/v1/workspaces/${id}`); }, + deleteWorkspace(id) { return this._del(`/api/v1/workspaces/${id}`); }, listGitCredentials() { return this._get('/api/v1/git-credentials'); }, // ── Messages ───────────────────────────── @@ -945,6 +945,7 @@ const API = { async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); }, async _put(path, body) { return this._authed(path, 'PUT', body); }, async _del(path) { return this._authed(path, 'DELETE'); }, + async _delete(path) { return this._del(path); }, // alias — `delete` is a JS reserved word async _patch(path, body) { return this._authed(path, 'PATCH', body); }, async _authed(path, method = 'GET', body) { @@ -991,4 +992,4 @@ const API = { } }; -sb.ns('API', API); +sb.ns('API', API); \ No newline at end of file diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js index d6c2166..a064ae0 100644 --- a/src/js/switchboard-sdk.js +++ b/src/js/switchboard-sdk.js @@ -101,7 +101,7 @@ const Switchboard = { * @param {object} [opts] — { signal } */ async del(path, opts) { - return API._delete(path, opts?.signal); + return API._del(path, opts?.signal); }, /**