From 748f49beddefe54aa5633ce3cf188060a3436757 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sat, 28 Feb 2026 23:46:23 +0000 Subject: [PATCH] Changeset 0.19.0.1 (#82) --- CHANGELOG.md | 62 + README.md | 355 +++-- VERSION | 2 +- docs/ARCHITECTURE.md | 31 +- docs/DESIGN-0.19.0.md | 1151 +++++++++++++++++ docs/ROADMAP.md | 68 +- scripts/db-validate.sh | 11 +- .../migrations/006_v0190_projects.sql | 157 +++ .../migrations/sqlite/005_v0190_projects.sql | 131 ++ server/database/testhelper.go | 4 + server/handlers/channels.go | 35 +- server/handlers/knowledge_bases.go | 19 + server/handlers/notes.go | 9 + server/handlers/projects.go | 471 +++++++ server/main.go | 23 + server/models/models.go | 52 +- server/store/interfaces.go | 1 + server/store/postgres/project.go | 403 ++++++ server/store/postgres/stores.go | 1 + server/store/project_interface.go | 47 + server/store/sqlite/project.go | 435 +++++++ server/store/sqlite/stores.go | 1 + server/tools/kbsearch.go | 18 + src/css/styles.css | 90 ++ src/index.html | 5 + src/js/api.js | 41 + src/js/app.js | 3 + src/js/chat.js | 3 +- src/js/projects-ui.js | 288 +++++ src/js/ui-core.js | 107 +- 30 files changed, 3873 insertions(+), 151 deletions(-) create mode 100644 docs/DESIGN-0.19.0.md create mode 100644 server/database/migrations/006_v0190_projects.sql create mode 100644 server/database/migrations/sqlite/005_v0190_projects.sql create mode 100644 server/handlers/projects.go create mode 100644 server/store/postgres/project.go create mode 100644 server/store/project_interface.go create mode 100644 server/store/sqlite/project.go create mode 100644 src/js/projects-ui.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9f70d..c9294ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to Chat Switchboard. +## [0.19.0] — 2026-02-28 + +### Added +- **Projects / Workspaces.** Organizational containers that group related + conversations, knowledge bases, and notes into a single workspace. + Projects provide a scope-aware organizational layer above individual + channels, with support for personal, team, and global visibility. +- **Project data model.** Four new tables: `projects` (with scope, owner, + team, color, icon, settings JSONB), `project_channels` (ordered + membership with UNIQUE constraint), `project_knowledge_bases` (with + auto_search flag), and `project_notes`. Channels gain a denormalized + `project_id` FK for efficient filtering. +- **Project API.** 17 new endpoints under `/api/v1/projects`: full CRUD, + channel add/remove/list/reorder, KB add/remove/list, note + add/remove/list. Access checks enforce owner-or-team-member visibility + with owner-only delete. +- **Project KB resolution.** Knowledge bases bound to a project are + automatically available to all channels within that project. Resolution + chain extended: Persona KBs → **Project KBs** → Channel KBs → + Personal KBs. Both `BuildKBHint` (system prompt injection) and + `kbsearch` (tool-time retrieval) updated. +- **Note auto-association.** Notes created from a channel that belongs to + a project are automatically added to that project's note collection. +- **Sidebar project groups.** Projects appear as collapsible groups in the + sidebar above the existing time-based "Recent" section. Each group shows + a color dot, chat count, and an options menu (⋯) for rename, color + picker, and delete. +- **Drag-and-drop.** Drag chat items between project groups or back to + Recent. Visual feedback with outline and background highlight on valid + drop targets. +- **Right-click context menu.** Right-click any chat to move it to a + project, remove it from its current project, or create a new project + and assign in one step. +- **New Project button.** Added to the New Chat split-button dropdown for + quick project creation. +- **Channel project_id filter.** `GET /api/v1/channels` accepts + `?project_id=` to filter by project, or `?project_id=none` for + unassigned channels. Response includes `project_id` field. + +### Changed +- `renderChatList()` rewritten to support project grouping while + preserving the original time-based layout when no projects exist. + Chat items now include `draggable`, `oncontextmenu`, and drag event + handlers. +- Channel response struct, SELECT queries, and scan calls updated across + `ListChannels`, `GetChannel`, and `CreateChannel` (both Postgres and + SQLite paths) to include `project_id`. +- `resource_grants` CHECK constraint extended to include `'project'` as a + valid resource type. +- `db-validate.sh` updated: former "Dropped tables" assertions for + `projects` and `project_channels` replaced with positive checks for all + four project tables plus `channels.project_id` column check. +- Test helper truncation list updated with project junction tables. + +### Fixed +- **JSON corruption defense** (hotfix carry-forward from 0.18.2): + `SafeJSON` wrapper with `scanJSON` and `scanTags` hardened helpers that + replace bare `json.RawMessage` / `pq.Array` scanning. Prevents + `encoding/json: invalid character` panics from NULL or empty columns. +- **Service worker** (hotfix carry-forward): `chrome-extension://` URL + filtering to avoid opaque response cache errors. + ## [0.18.1] — 2026-02-28 ### Added diff --git a/README.md b/README.md index fd4b8c9..ec02433 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,292 @@ -# Hotfix Patches — v0.18.1 → v0.18.2 +# Chat Switchboard -Priority order: apply top-to-bottom. All patches are independent -except 1 and 2 which must be applied together. +A self-hosted AI chat application for enterprise and government environments. Unified interface for multiple AI providers with admin controls, team management, and security features. ---- +## Quick Start -## Patch 1+2: Harden JSON scanning + SafeJSON (P0 — fixes broken chat list) +```bash +# Clone and start +git clone && cd chat-switchboard +docker compose up -d -**Root cause:** Corrupt `\x02` byte in a channel's `settings` column. -`scanJSON` accepts it, Gin's `c.JSON()` starts writing 200 + headers, -`json.RawMessage.MarshalJSON()` hits the bad byte, aborts, browser -gets truncated body → "Unexpected end of JSON input" → 0 chats loaded. - -**Files:** - -1. **NEW:** `server/handlers/safe_json.go` - Copy `patches/server/handlers/safe_json.go` into your tree. - Contains: `SafeJSON()`, hardened `scanJSON()`, hardened `scanTags()`. - -2. **NEW:** `server/handlers/safe_json_test.go` - Copy `patches/server/handlers/safe_json_test.go` into your tree. - -3. **EDIT:** `server/handlers/channels.go` - Apply these three changes (or diff against `patches/server/handlers/channels.go`): - - a. Remove the old `scanJSON` + `jsonScanner` + `scanTags` + `tagsScanner` - definitions (~lines 101–155). Replace with a comment: - ```go - // scanJSON, scanTags, SafeJSON → safe_json.go - ``` - - b. Replace `c.JSON(http.StatusOK, paginatedResponse{` in ListChannels - with `SafeJSON(c, http.StatusOK, paginatedResponse{` - - c. Replace `c.JSON(http.StatusCreated, ch)` at end of CreateChannel - with `SafeJSON(c, http.StatusCreated, ch)` - - d. Replace `c.JSON(http.StatusOK, ch)` at end of GetChannel - with `SafeJSON(c, http.StatusOK, ch)` - -**Verify:** `go build ./...` and `go test ./server/handlers/ -run TestScanJSON -v` - -**Data fix:** Find and repair the corrupt row: -```sql --- Find it -SELECT id, title, length(settings::text), - encode(substring(settings::bytea, 1, 20), 'hex') AS hex_preview -FROM channels -WHERE settings IS NOT NULL - AND settings::text ~ '[^\x20-\x7E\t\n\r]'; - --- Fix it (reset to empty settings) -UPDATE channels SET settings = '{}' -WHERE settings IS NOT NULL - AND settings::text ~ '[^\x20-\x7E\t\n\r]'; +# Access at http://localhost:3000 +# Default admin: admin / admin ``` ---- +## Features -## Patch 3: Service Worker scheme guard (P1 — console noise) +- **Multi-Provider**: Anthropic, OpenAI, OpenRouter, Venice AI — with BYOK support +- **Projects**: Group related conversations, KBs, and notes into workspaces with scope-aware visibility +- **Team Management**: Roles (admin/user) for vertical permissions, Teams for horizontal visibility +- **Personas**: Preset configurations (model + system prompt + parameters) at global, team, or personal scope +- **Model Catalog**: Three-state visibility (enabled/disabled/team-only) with admin controls +- **Message Trees**: Edit and regenerate with full conversation forking — navigate sibling branches +- **Notes**: Markdown notes with full-text search, folders, and tags +- **Extensions**: Browser extension system with custom renderers, tool bridge, and self-contained styling + - Built-in: Mermaid diagrams, KaTeX math, CSV tables, diff viewer, JS sandbox, regex tester +- **Server Tools**: Calculator and datetime tools (auto-registered, zero config) +- **Audit Log**: All admin operations logged with actor, action, and resource details +- **Security**: JWT + refresh tokens, AES-256-GCM API key encryption, optional mTLS, optional OIDC/Keycloak, environment classification banners +- **Airgapped**: Local vendor files (marked.js, DOMPurify, KaTeX, mermaid.js), no CDN required +- **Mobile**: Responsive design, PWA manifest +- **Real-time**: WebSocket event bus for tool bridge, live updates -**Root cause:** SW fetch handler tries to cache `chrome-extension://` -URLs. `Cache.put()` rejects non-http(s) schemes. +## Architecture -**File:** `src/sw.js` - -Add scheme guard after `const url = new URL(event.request.url);`: -```js - // Only handle http/https — ignore chrome-extension://, moz-extension://, etc. - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return; - } +``` +┌─────────────┐ ┌──────────────────────────────┐ +│ Browser │────▶│ nginx (port 80) │ +│ (vanilla JS)│◀────│ ├─ /api/* → Go backend:8080 │ +└─────────────┘ │ ├─ /ws → WebSocket proxy │ + │ └─ /* → static files │ + └──────────────────────────────┘ + │ + ┌─────────▼─────────┐ + │ Go Backend │ + │ ├─ handlers/ │ + │ ├─ store/postgres/ │ + │ ├─ store/sqlite/ │ + │ ├─ providers/ │ + │ └─ capabilities/ │ + └─────────┬─────────┘ + │ + ┌─────────▼─────────┐ + │ PostgreSQL 16 or │ + │ SQLite (embedded) │ + └───────────────────┘ ``` -Or copy `patches/src/sw.js`. +**Go backend** (vanilla, no framework beyond Gin) with a store layer abstracting all database access. Dual-driver architecture: `store/postgres` for production deployments, `store/sqlite` for single-user, edge, and development scenarios — selected at startup via `DB_DRIVER`. Providers package handles LLM API calls. Capabilities package resolves model features from catalog data, known model tables, and heuristic inference. Server tools (calculator, datetime) auto-register via `init()`. EventBus + WebSocket hub routes tool calls between backend and browser extensions. ---- +**Frontend** is vanilla JavaScript — no build step, no bundler. 15 files organized by domain: `api.js` (HTTP client), `app.js` (state + init), `chat.js` (send, regen, edit, branch), `events.js` (event bus + WebSocket), `extensions.js` (loader, registry, renderer pipeline, tool bridge), `ui-core.js` (DOM rendering + streaming), `ui-format.js` (markdown, code blocks), `ui-primitives.js` (shared components), `ui-settings.js` / `ui-admin.js` (settings and admin panels), `notes.js`, `tokens.js`, `debug.js`, `settings-handlers.js`, `admin-handlers.js`. -## Patch 4: Settings debounce (P2 — cosmetic waste) +## Configuration -Not included as a patch file — straightforward to implement: -- In `app.js` init sequence, debounce `API.updateSettings()` calls -- A 100ms `setTimeout` wrapper that coalesces multiple rapid saves +All configuration via environment variables. See `server/.env.example` for the full list. ---- +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Backend listen port | +| `BASE_PATH` | ` ` | URL prefix (e.g. `/chat`) | +| `DB_DRIVER` | `postgres` | Database backend: `postgres` or `sqlite` | +| `DATABASE_URL` | ` ` | SQLite: path to database file (e.g. `/data/switchboard.db`) | +| `DB_HOST` | `localhost` | PostgreSQL host (ignored when `DB_DRIVER=sqlite`) | +| `DB_NAME` | `chat_switchboard` | Database name (ignored when `DB_DRIVER=sqlite`) | +| `JWT_SECRET` | (required) | Token signing key | +| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username | +| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password | +| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) | +| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` | +| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. | +| `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) | +| `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) | +| `S3_BUCKET` | ` ` | S3 bucket name (must exist, required for S3) | +| `S3_ACCESS_KEY` | ` ` | S3 access key ID | +| `S3_SECRET_KEY` | ` ` | S3 secret access key | +| `S3_REGION` | `us-east-1` | S3 region | +| `S3_PREFIX` | ` ` | Optional key prefix within bucket | +| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs (required for MinIO, Ceph) | -## Future work (not in this hotfix) +## Deployment -- Apply `SafeJSON` to extension list endpoints (lines 47, 115 of - extensions.go) — same vulnerability pattern, lower risk since - extension data is seeded from known-good JSON. +Three Docker images support different scenarios: -- Harden `JSONMap.Scan` in `models/models.go` to warn-and-default - instead of returning error. Currently it fails the whole query - which gives a clean 500, but it means one corrupt row blocks all - results in a list query. +| Image | Dockerfile | Use Case | +|-------|-----------|----------| +| **Unified** | `Dockerfile` | Dev, docker-compose, single-node | +| **Backend** | `server/Dockerfile` | K8s — scale API pods independently | +| **Frontend** | `Dockerfile.frontend` | K8s — scale FE pods independently | -- Startup JSON integrity check (design doc §17.1 Layer 4) — scan - all JSONB columns on boot, log warnings for corrupt data. Not - blocking for hotfix but valuable for post-upgrade diagnostics. +### Docker Compose (development — unified image) -- JWT redaction in Gin log formatter for WebSocket URLs. +```bash +docker compose up -d # start all services +docker compose up -d --build # rebuild after code changes +docker compose --profile dev up # include Adminer DB UI +``` + +### Kubernetes (split images) + +Build and push both images: + +```bash +docker build -f server/Dockerfile -t your-registry/switchboard-api:0.11.0 server/ +docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.11.0 . +``` + +**Backend deployment:** + +```yaml +containers: + - name: api + image: your-registry/switchboard-api:0.11.0 + ports: + - containerPort: 8080 + env: + - name: DB_HOST + value: "postgres-service" + - name: BASE_PATH + value: "/chat" # must match ingress path + - name: JWT_SECRET + valueFrom: + secretKeyRef: + name: switchboard-secrets + key: jwt-secret +``` + +**Frontend deployment:** + +```yaml +containers: + - name: frontend + image: your-registry/switchboard-fe:0.11.0 + ports: + - containerPort: 80 + env: + - name: BASE_PATH + value: "/chat" # injected into index.html at startup + volumeMounts: + - name: branding + mountPath: /branding + readOnly: true # optional: custom logo, colors +``` + +**Ingress** routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint generates the nginx config dynamically based on `BASE_PATH`. + +### Path-Based Routing + +Set `BASE_PATH=/chat` to serve the application under a subpath. The frontend reads `window.__BASE__` injected at container startup, and all API calls are prefixed automatically. + +### Airgapped / Disconnected + +The Docker build bakes in `marked.js`, `DOMPurify`, and `KaTeX` (JS + CSS + fonts) from npm during the vendor stage. Mermaid.js loads dynamically from local vendor with CDN fallback. No CDN calls at runtime in airgapped deployments. The `src/vendor/` directory contains local copies as fallback for development without Docker. + +## Database + +### PostgreSQL (default) + +PostgreSQL 16+ recommended. The `pgcrypto` and `vector` (pgvector) extensions are used for UUID generation and vector similarity search respectively. + +### SQLite (single-user / edge / dev) + +Set `DB_DRIVER=sqlite` and `DATABASE_URL=/path/to/switchboard.db` to run with an embedded SQLite database. No external dependencies — the binary is self-contained (pure Go, no CGO). The SQLite backend has full feature parity with Postgres including knowledge base vector search, which uses app-level cosine similarity computed in Go rather than pgvector. + +```bash +# Minimal single-binary startup +DB_DRIVER=sqlite DATABASE_URL=./data/switchboard.db JWT_SECRET=changeme ./switchboard +``` + +SQLite is best suited for single-user workstations, edge deployments, air-gapped laptops, and local development. For multi-user production with concurrent writes, use PostgreSQL. + +### Schema Management + +The Go backend auto-migrates on startup using files in `server/database/migrations/`. For manual operations: + +```bash +# Bootstrap database (superuser, creates role + DB) +scripts/db-bootstrap.sh + +# Manual migration (usually not needed) +scripts/db-migrate.sh + +# Validate schema +scripts/db-validate.sh +``` + +### Key Tables (v0.19) + +| Table | Purpose | +|-------|---------| +| `users` | Accounts with role, avatar, settings, encrypted vault (UEK) | +| `provider_configs` | API provider configurations (scope: global/team/personal) | +| `model_catalog` | Synced model list with capabilities, visibility, and type | +| `personas` | Model presets (scope: global/team/personal) | +| `projects` | Workspaces grouping channels, KBs, and notes (scope: personal/team/global) | +| `project_channels` | Ordered channel-to-project membership (UNIQUE channel_id) | +| `project_knowledge_bases` | KB-to-project binding with auto_search flag | +| `project_notes` | Note-to-project association | +| `channels` | Conversations with type (direct/group/channel), optional `project_id` | +| `messages` | Message tree with parent_id for forking, tool_calls JSONB | +| `teams` / `team_members` | Organizational units | +| `notes` | Markdown notes with full-text search | +| `knowledge_bases` / `kb_documents` / `kb_chunks` | RAG pipeline with pgvector embeddings | +| `extensions` / `extension_user_settings` | Browser extension registry and per-user config | +| `usage_log` / `model_pricing` | Token usage tracking and cost calculation | +| `audit_log` | Admin action audit trail | +| `user_model_settings` | Per-user model visibility and sort preferences | + +## API + +All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer ` header. + +### Auth +- `POST /auth/register` — Register (if `allow_registration` policy is true) +- `POST /auth/login` — Login, returns access + refresh tokens +- `POST /auth/refresh` — Refresh access token +- `POST /auth/logout` — Revoke refresh token + +### Channels & Messages +- `GET/POST /channels` — List/create conversations +- `GET/PUT/DELETE /channels/:id` — Channel CRUD +- `GET/POST /channels/:id/messages` — Message list/create +- `POST /chat/completions` — Stream AI completions (SSE) +- `POST /channels/:id/messages/:msgId/edit` — Edit and fork +- `POST /channels/:id/messages/:msgId/regenerate` — Regenerate response + +### Models +- `GET /models/enabled` — Models available to the user (global + team + personal) +- `GET/PUT /models/preferences` — User model visibility settings + +### Personal Providers (BYOK) +- `GET/POST /api-configs` — User's personal provider configs +- `GET/PUT/DELETE /api-configs/:id` — Provider CRUD +- `POST /api-configs/:id/models/fetch` — Refresh models from provider API +- `GET /api-configs/:id/models` — List models for a personal provider + +### Teams +- `GET /teams` — Teams the user belongs to +- `GET/POST /teams/:id/providers` — Team provider management (team admins) +- `GET/POST /teams/:id/presets` — Team preset management (team admins) + +### Projects +- `GET/POST /projects` — List and create projects +- `GET/PUT/DELETE /projects/:id` — Project CRUD (owner-only delete) +- `POST/DELETE /projects/:id/channels/:channelId` — Add/remove channel +- `GET /projects/:id/channels` — List project channels +- `PUT /projects/:id/channels/reorder` — Reorder channels +- `POST/DELETE /projects/:id/knowledge-bases/:kbId` — Bind/unbind KB +- `POST/DELETE /projects/:id/notes/:noteId` — Bind/unbind note + +### Admin +- `GET/POST /admin/users` — User management +- `GET/PUT /admin/settings/:key` — Global settings and policies +- `GET/POST /admin/configs` — Global provider configs +- `GET/POST /admin/models/fetch` — Model catalog sync +- `GET/PUT /admin/roles/:role` — Model role configuration +- `GET /admin/usage` — Usage dashboard +- `GET /admin/audit` — Audit log +- `GET/POST/PUT/DELETE /admin/extensions` — Extension management + +### Extensions +- `GET /extensions?tier=browser` — List enabled browser extensions +- `GET /extensions/:id/assets/*path` — Serve extension assets (public, no auth) + +### WebSocket +- `GET /ws?token=` — EventBus WebSocket for real-time events and tool bridge + +## Development + +```bash +# Backend (requires Go 1.22+) +cd server +cp .env.example .env # edit with your DB credentials +go run . + +# Frontend (just serve static files) +# Use any HTTP server pointed at src/ +python3 -m http.server 3000 --directory src +``` + +## License + +Proprietary. All rights reserved. diff --git a/VERSION b/VERSION index 6b2d58c..3f46c4d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.18.1 \ No newline at end of file +0.19.0 \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index acfdecf..3cea7a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,7 @@ Three Docker images support different deployment scenarios: 6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure. -## Workflow Architecture (Future — v0.21.0+) +## Workflow Architecture (Future — v0.25.0+) The platform's existing primitives (teams, personas, channels, tools, notes) compose into a workflow engine where the channel is the execution context @@ -235,7 +235,33 @@ scope='team' → owner_id=team.id → Team-admin-managed, visible to team scope='personal' → owner_id=user.id → User-managed, visible to owner ``` -Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top). +Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds `enabled`/`disabled`/`team` states on top), `projects`. + +## Projects (v0.19.0) + +Projects are organizational containers that group channels, knowledge bases, +and notes. They follow the same scope model as other resources. + +**KB Resolution Chain:** +``` +Persona KBs → Project KBs → Channel KBs → Personal KBs +``` + +When a channel belongs to a project, all KBs bound to that project are +automatically included in both the system prompt hint (`BuildKBHint`) and +tool-time search (`kbsearch`). This means adding a KB to a project makes it +available to every channel in that project without per-channel configuration. + +**Channel Association:** Channels have an optional `project_id` FK. +The `project_channels` junction table maintains ordered membership with a +UNIQUE constraint on `channel_id` (a channel can only belong to one project). +`AddChannel` performs an atomic move: single transaction deletes from the +old project, inserts into the new one, and updates the denormalized FK. + +**Store Pattern:** `ProjectStore` interface with Postgres and SQLite +implementations following the same conventions as other stores (see +Store Layer Pattern above). Access checks via `UserCanAccess` enforce +owner-or-team-member visibility. ## Schema Migration @@ -276,6 +302,7 @@ src/ │ ├── attachments.js # File upload, paste-to-file, lightbox │ ├── notes.js # Notes panel CRUD + graph + daily notes │ ├── note-graph.js # Canvas force-directed graph visualization +│ ├── projects-ui.js # Project sidebar, context menu, drag-and-drop │ ├── knowledge.js # Knowledge base UI │ ├── memory-ui.js # Memory settings tab + admin review panel │ └── __tests__/ # Node.js test suite (node --test) diff --git a/docs/DESIGN-0.19.0.md b/docs/DESIGN-0.19.0.md new file mode 100644 index 0000000..37a5bd5 --- /dev/null +++ b/docs/DESIGN-0.19.0.md @@ -0,0 +1,1151 @@ +# Design — v0.19.0 Projects / Workspaces + +**Version:** 0.19.0 +**Status:** Draft +**Depends on:** user groups + resource grants (v0.16.0), knowledge bases (v0.14.0), side panel (v0.18.1) + +--- + +## 1. Overview + +Projects are organizational containers that group related conversations, +knowledge bases, and notes into a shared workspace. They sit between +"individual channel" and "team" in the hierarchy — a team might have +many projects, a user might have personal projects across teams. + +Projects solve two concrete problems: + +1. **Sidebar sprawl:** Power users accumulate dozens of channels. The + flat (or single-folder) chat list doesn't scale. Projects give + channels meaningful grouping with shared context. + +2. **Shared context:** Today, attaching a KB or Persona to a channel + is per-channel. Projects let you say "every conversation in this + project uses these KBs and this Persona" — one config, many channels. + +**What this release does NOT include:** Notifications are deferred to +v0.19.1 (see §12 for the split rationale and forward pointers). + +--- + +## 2. Scope Model + +Projects reuse the existing three-value scope model: + +``` +┌─────────────────────────────────────────────────┐ +│ personal scope │ +│ owner_id = user_id, team_id = NULL │ +│ Only visible to the owning user │ +│ Use case: personal workspace, side projects │ +└─────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────┐ +│ team scope │ +│ owner_id = creator, team_id = team_id │ +│ Visible to team members (respects grants) │ +│ Use case: department projects, client work │ +└─────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────┐ +│ global scope │ +│ owner_id = admin, team_id = NULL │ +│ Visible to all users (admin-created) │ +│ Use case: company-wide initiatives, templates │ +└─────────────────────────────────────────────────┘ +``` + +**Access control:** Projects use the same grant model as Personas and +KBs (v0.16.0): `team_only`, `global`, or `groups`. The `resource_grants` +table gets `'project'` added to its `resource_type` CHECK constraint. + +--- + +## 3. Data Model + +### 3.1 Table: `projects` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUID / TEXT | Primary key | +| `name` | TEXT NOT NULL | Display name | +| `description` | TEXT DEFAULT '' | Optional description | +| `scope` | TEXT NOT NULL | `personal`, `team`, `global` | +| `owner_id` | UUID / TEXT | Creating user | +| `team_id` | UUID / TEXT | NULL for personal/global | +| `persona_id` | UUID / TEXT | Default Persona for new channels (nullable) | +| `settings` | JSONB / JSON | Project-level config (see §3.7) | +| `is_archived` | BOOLEAN DEFAULT false | Soft archive | +| `created_at` | TIMESTAMPTZ / TEXT | | +| `updated_at` | TIMESTAMPTZ / TEXT | | + +**Constraints:** +```sql +CONSTRAINT project_scope_check CHECK ( + (scope = 'personal' AND team_id IS NULL) OR + (scope = 'team' AND team_id IS NOT NULL) OR + (scope = 'global' AND team_id IS NULL) +) +``` + +**Indexes:** +```sql +CREATE INDEX idx_projects_owner ON projects(owner_id); +CREATE INDEX idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX idx_projects_scope ON projects(scope); +``` + +### 3.2 Junction Tables + +Separate junction tables per resource type — not a polymorphic +`project_resources` table. Rationale in §3.3. + +**`project_channels`** + +| Column | Type | Description | +|--------|------|-------------| +| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE | +| `channel_id` | UUID / TEXT | UNIQUE, FK → channels ON DELETE CASCADE | +| `position` | INT DEFAULT 0 | Sort order within project | +| `folder_path` | TEXT DEFAULT '/' | Sub-folder within project | +| `added_at` | TIMESTAMPTZ / TEXT | | + +```sql +PRIMARY KEY (project_id, channel_id) +UNIQUE (channel_id) -- one project per channel (see §16 Q2) +CREATE INDEX idx_project_channels_channel ON project_channels(channel_id); +``` + +**`project_knowledge_bases`** + +| Column | Type | Description | +|--------|------|-------------| +| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE | +| `kb_id` | UUID / TEXT | FK → knowledge_bases ON DELETE CASCADE | +| `auto_search` | BOOLEAN DEFAULT true | Include in all project channels | +| `added_at` | TIMESTAMPTZ / TEXT | | + +```sql +PRIMARY KEY (project_id, kb_id) +``` + +**`project_notes`** + +| Column | Type | Description | +|--------|------|-------------| +| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE | +| `note_id` | UUID / TEXT | FK → notes ON DELETE CASCADE | +| `added_at` | TIMESTAMPTZ / TEXT | | + +```sql +PRIMARY KEY (project_id, note_id) +``` + +### 3.3 Why Three Tables, Not One + +The roadmap spec proposed a single `project_resources` table with +`(resource_type, resource_id)`. Three tables are better here: + +- **Referential integrity.** FK constraints enforce that resources + exist. `ON DELETE CASCADE` handles cleanup when a channel, KB, or + note is deleted — no application-level orphan scanning. + +- **Existing pattern.** `channel_knowledge_bases`, `persona_knowledge_bases`, + and `group_members` all use dedicated junction tables. + +- **Type-specific columns.** `project_channels` needs `position` and + `folder_path`. `project_knowledge_bases` needs `auto_search`. A + polymorphic table would need nullable type-specific columns or a + JSONB bag, which is worse. + +- **SQLite compatibility.** Simpler constraints, no CHECK on polymorphic + type strings, no mixed-resource indexes. + +### 3.4 Channel Integration + +Channels get a denormalized `project_id` for fast sidebar queries. +The junction table `project_channels` is the source of truth for +position and folder_path. Both are kept in sync by the store layer. + +```sql +ALTER TABLE channels ADD COLUMN project_id UUID + REFERENCES projects(id) ON DELETE SET NULL; +CREATE INDEX idx_channels_project + ON channels(project_id) WHERE project_id IS NOT NULL; +``` + +**On project delete:** Channels detach (`project_id → NULL`). They are +never cascade-deleted — only the junction row is removed. KBs and notes +similarly survive; only the association is dropped. + +### 3.5 Folder/Column Cleanup + +The channels table currently has two unused grouping columns: + +```sql +folder_id UUID -- FK placeholder, never wired to a folders table +folder TEXT -- frontend-managed text label, barely used +``` + +With projects providing real hierarchy: + +- `folder_id` — **Dropped** in this migration (confirmed unused in + store layer and all handlers). +- `folder` — **Deprecated.** Column remains for backward compat but is + no longer read or written by any handler after v0.19.0. Migration + best-effort copies non-empty `folder` values into personal projects + (one project per distinct folder name, channels attached). Can be + dropped in a future schema consolidation. + +### 3.6 Model Structs + +```go +type Project struct { + BaseModel + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Scope string `json:"scope" db:"scope"` + OwnerID string `json:"owner_id" db:"owner_id"` + TeamID *string `json:"team_id,omitempty" db:"team_id"` + PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` + Settings JSONMap `json:"settings,omitempty" db:"settings"` + IsArchived bool `json:"is_archived" db:"is_archived"` + + // Computed (from COUNT joins, not stored) + ChannelCount int `json:"channel_count,omitempty"` + KBCount int `json:"kb_count,omitempty"` + NoteCount int `json:"note_count,omitempty"` +} + +type ProjectPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + PersonaID *string `json:"persona_id,omitempty"` + Settings JSONMap `json:"settings,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` +} + +type ProjectChannel struct { + ProjectID string `json:"project_id" db:"project_id"` + ChannelID string `json:"channel_id" db:"channel_id"` + Position int `json:"position" db:"position"` + FolderPath string `json:"folder_path" db:"folder_path"` +} +``` + +### 3.7 Project Settings (JSONB) + +The `settings` column stores project-level configuration as JSON. +Initial keys: + +```json +{ + "auto_persona": true, + "auto_kbs": true, + "default_model": null, + "description_visible": true +} +``` + +| Key | Type | Description | +|-----|------|-------------| +| `auto_persona` | bool | Apply project Persona to new channels automatically | +| `auto_kbs` | bool | Inject project KBs into channels at completion time | +| `default_model` | string | Override model for new channels (nullable) | +| `description_visible` | bool | Show description in sidebar project header | + +Settings are intentionally sparse. Resist the urge to add per-project +copies of global settings — use project Persona config for model +behavior and project KBs for context. + +--- + +## 4. Store Interface + +```go +type ProjectStore interface { + // CRUD + Create(ctx context.Context, p *models.Project) error + GetByID(ctx context.Context, id string) (*models.Project, error) + Update(ctx context.Context, id string, patch models.ProjectPatch) error + Delete(ctx context.Context, id string) error + + // Listing (scope-aware) + ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Project, int, error) + ListForTeam(ctx context.Context, teamID string, opts store.ListOptions) ([]models.Project, int, error) + ListAccessible(ctx context.Context, userID string) ([]models.Project, error) + + // Resource association + AddChannel(ctx context.Context, projectID, channelID string) error + RemoveChannel(ctx context.Context, projectID, channelID string) error + ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) + MoveChannel(ctx context.Context, projectID, channelID string, position int, folderPath string) error + + AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error + RemoveKB(ctx context.Context, projectID, kbID string) error + ListKBs(ctx context.Context, projectID string) ([]models.KnowledgeBase, error) + + AddNote(ctx context.Context, projectID, noteID string) error + RemoveNote(ctx context.Context, projectID, noteID string) error + ListNotes(ctx context.Context, projectID string) ([]models.Note, error) + + // KB resolution (for completion-time injection) + GetProjectKBsForChannel(ctx context.Context, channelID string) ([]models.KnowledgeBase, error) + + // Access check + UserCanAccess(ctx context.Context, userID, projectID string) (bool, error) +} +``` + +**Key design decisions:** + +- `AddChannel` writes both `project_channels` junction row and + `channels.project_id` denorm column in a single transaction. If + the channel already belongs to a different project, AddChannel + removes the old association first (atomic move). This enables + drag-between-projects and "Move to project" context menu. +- `RemoveChannel` clears `channels.project_id` and deletes the + junction row. +- `GetProjectKBsForChannel` joins `channels → project_channels → + project_knowledge_bases → knowledge_bases` — used at completion time + for virtual KB injection (see §6). +- `ListAccessible` uses the same grant resolution pattern as + `PersonaStore.ListAccessible`: union of personal + team + granted. +- Both Postgres and SQLite implementations required (same pattern as + all other stores). + +--- + +## 5. API Endpoints + +All endpoints require authentication. Team/global project mutations +require team_admin or admin role respectively. + +### 5.1 Projects CRUD + +``` +POST /api/v1/projects Create project +GET /api/v1/projects List accessible projects +GET /api/v1/projects/:id Get project (with resource counts) +PUT /api/v1/projects/:id Update project +DELETE /api/v1/projects/:id Delete project (detaches resources) +``` + +**Create request:** +```json +{ + "name": "Q3 Launch", + "description": "Product launch planning", + "scope": "team", + "team_id": "uuid", + "persona_id": "uuid-or-null", + "settings": {} +} +``` + +**List response:** +```json +{ + "data": [ + { + "id": "uuid", + "name": "Q3 Launch", + "scope": "team", + "channel_count": 5, + "kb_count": 2, + "note_count": 12, + "persona_id": "uuid", + "updated_at": "2026-02-28T..." + } + ], + "total": 3, + "page": 1, + "per_page": 50 +} +``` + +### 5.2 Resource Association + +``` +POST /api/v1/projects/:id/channels Add channel to project +DELETE /api/v1/projects/:id/channels/:cid Remove channel from project +GET /api/v1/projects/:id/channels List project channels +PUT /api/v1/projects/:id/channels/:cid Move/reorder channel + +POST /api/v1/projects/:id/knowledge-bases Add KB +DELETE /api/v1/projects/:id/knowledge-bases/:kid Remove KB +GET /api/v1/projects/:id/knowledge-bases List project KBs + +POST /api/v1/projects/:id/notes Add note +DELETE /api/v1/projects/:id/notes/:nid Remove note +GET /api/v1/projects/:id/notes List project notes +``` + +**Add channel request:** +```json +{ + "channel_id": "uuid", + "folder_path": "/design", + "position": 0 +} +``` + +**Move channel request:** +```json +{ + "position": 2, + "folder_path": "/design/iterations" +} +``` + +### 5.3 Project Grants + +Reuses the existing resource grants API pattern: + +``` +GET /api/v1/projects/:id/grants Get project grant +PUT /api/v1/projects/:id/grants Set project grant +``` + +**Request/response uses existing grant model:** +```json +{ + "grant_scope": "groups", + "granted_groups": ["uuid-group-1", "uuid-group-2"] +} +``` + +**Migration:** Add `'project'` to `resource_grants.resource_type` CHECK: +```sql +ALTER TABLE resource_grants DROP CONSTRAINT resource_grants_resource_type_check; +ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check + CHECK (resource_type IN ('persona', 'knowledge_base', 'project')); +``` + +### 5.4 Channel List Enhancement + +The existing `GET /api/v1/channels` endpoint gains project filtering: + +``` +GET /api/v1/channels?project_id=uuid Channels in a project +GET /api/v1/channels?project_id=none Unassigned channels only +``` + +**Technical debt addressed here:** The `ListChannels` handler currently +does raw SQL against `database.DB` instead of using `ChannelStore`. +This gets migrated to the store layer as part of this work. The handler +is the only one still bypassing the store — fixing it improves both +maintainability and SQLite compat testing. + +--- + +## 6. KB Injection at Completion Time + +Project KBs use **virtual injection** at completion time — the same +pattern as Persona-KB binding (v0.17.0). No physical rows are created +in `channel_knowledge_bases`. + +**Priority chain for KB resolution:** + +``` +channel KBs (explicit per-channel) → highest priority + ↓ +project KBs (auto_search = true) → inherited from project + ↓ +persona KBs (persona_knowledge_bases) → inherited from Persona +``` + +At completion time, `loadConversation()` already calls +`buildKBContext()` which resolves channel + persona KBs. This extends +to include project KBs: + +```go +// In completion handler, after resolving channel +func resolveKBs(ctx context.Context, stores store.Stores, + channelID string, personaID *string) []string { + + var kbIDs []string + + // 1. Channel-level (explicit) + channelKBs, _ := stores.KnowledgeBases.ListForChannel(ctx, channelID) + kbIDs = append(kbIDs, extractIDs(channelKBs)...) + + // 2. Project-level (virtual injection) + projectKBs, _ := stores.Projects.GetProjectKBsForChannel(ctx, channelID) + kbIDs = append(kbIDs, extractIDs(projectKBs)...) + + // 3. Persona-level + if personaID != nil { + personaKBs, _ := stores.KnowledgeBases.ListForPersona(ctx, *personaID) + kbIDs = append(kbIDs, extractIDs(personaKBs)...) + } + + return deduplicate(kbIDs) +} +``` + +**Why virtual, not physical:** Adding a KB to a project should +immediately apply to all project channels without backfilling junction +rows. Removing a KB should immediately stop applying. The Persona-KB +pattern proved this works — query-time resolution is simple and the +JOIN cost is negligible for the cardinality involved. + +--- + +## 7. Sidebar UI + +### 7.1 Layout + +The sidebar transforms from a flat chat list to a grouped view: + +``` +┌──────────────────────────────┐ +│ 🔀 Chat Switchboard │ +│ │ +│ 🔍 Search... │ +│ │ +│ [+ New Chat] │ +│ │ +│ 📌 Pinned │ +│ Chat about deployment │ +│ Weekly standup notes │ +│ │ +│ ▼ Q3 Launch (team) ⚙ │ +│ ├── /design │ +│ │ Chat: mockup review │ +│ │ Chat: color palette │ +│ ├── /copy │ +│ │ Chat: landing page │ +│ └── Chat: kickoff │ +│ │ +│ ▼ Side Projects (personal) ⚙ │ +│ Chat: rust experiments │ +│ Chat: garden planner │ +│ │ +│ ▶ Archived Project ⚙ │ +│ │ +│ ── Recent ── │ +│ Chat: quick question │ +│ Chat: random thought │ +│ │ +│ [+ New Project] │ +└──────────────────────────────┘ +``` + +**Sections (top to bottom):** + +1. **Pinned** — pinned channels, regardless of project assignment + (existing behavior, unchanged) +2. **Projects** — collapsible groups, sorted by `updated_at` desc. + Each project shows its channels organized by `folder_path`. +3. **Recent** — unassigned channels (`project_id IS NULL`, + `is_pinned = false`), sorted by `updated_at` desc. This is the + existing chat list behavior for channels not yet organized. + +### 7.2 Interactions + +| Action | Behavior | +|--------|----------| +| Click project header | Toggle expand/collapse (state persisted) | +| Click ⚙ gear icon | Open project settings in side panel | +| Drag channel → project | `POST /projects/:id/channels` | +| Drag channel → "Recent" | `DELETE /projects/:id/channels/:cid` | +| Drag channel within project | `PUT /projects/:id/channels/:cid` (reorder) | +| Right-click project | Context menu: rename, archive, delete, manage KBs | +| `[+ New Project]` | Inline creation: name input → scope picker | +| Double-click project name | Inline rename (same UX as chat rename from v0.17.0) | + +### 7.3 State Persistence + +```js +// sessionStorage key: 'cs-project-collapse' +// Value: JSON object { [projectId]: boolean } +// true = collapsed, absent/false = expanded +``` + +### 7.4 Data Loading + +The sidebar needs all projects + their channels in one round trip to +avoid N+1 rendering. Two options: + +**Option A — Two calls (preferred):** +``` +GET /api/v1/projects → project list with counts +GET /api/v1/channels?per_page=200 → all channels (project_id included) +``` +Client-side grouping by `project_id`. This reuses existing channel +loading and adds one extra call. Channel data is already loaded at +startup in `loadChats()`. + +**Option B — Single enriched call:** +``` +GET /api/v1/projects?include=channels +``` +Returns projects with embedded channel arrays. More efficient but +requires a new response shape and duplicates channel data if the +flat list is also needed. + +Option A wins on KISS. The project list is small (rarely > 20), the +channel list is already loaded, and grouping is trivial in JS. + +### 7.5 Mobile + +On viewports ≤ 768px: +- Projects render as collapsible accordions (same as desktop) +- Drag-and-drop disabled — use context menu → "Move to project" instead +- Project gear icon opens a bottom sheet instead of side panel +- Folder paths shown as flat prefixes (no tree indentation) + +--- + +## 8. Project Settings Panel + +Project settings open in the side panel (v0.18.1 PanelRegistry). +Registers as `'project-settings'` panel. + +**Sections:** + +1. **General** — name, description, scope badge (read-only for + non-admin), archive toggle +2. **Default Persona** — persona picker dropdown (same component as + channel persona picker from `persona-kb.js`) +3. **Knowledge Bases** — checkbox list of accessible KBs with + `auto_search` toggle per KB +4. **Access** — grant picker (team_only / global / groups), reuses + the same grant picker UI from Persona settings +5. **Resources** — summary counts (N channels, N KBs, N notes) +6. **Danger Zone** — delete project (confirmation dialog: "Resources + will be detached, not deleted") + +--- + +## 9. Channel Creation Within Projects + +When creating a new channel from within a project context (clicking +"New Chat" while a project is expanded, or via project context menu): + +1. Channel created via existing `POST /api/v1/channels` +2. Immediately associated via `POST /projects/:id/channels` +3. If project has `auto_persona: true` and `persona_id` set, the + channel inherits the project's default Persona +4. Project KBs automatically apply at completion time via virtual + injection (§6) — no explicit channel-KB linking needed + +The frontend tracks an "active project context" so new channels +created while browsing a project are auto-associated. Context clears +when the user clicks outside any project or into "Recent." + +```js +// In app state +App.activeProjectId = null; // set when user interacts with a project +``` + +--- + +## 10. Enterprise Use Cases + +### 10.1 Project Templates (Phase 3 stretch) + +Team admins create projects pre-configured with Personas, KBs, and +settings. Templates are projects with `settings.is_template: true`. +Creating "from template" copies configuration into a new project. + +Not in initial delivery — data model supports it without changes. + +### 10.2 Cross-Team Projects + +Global-scope projects with group grants enable cross-team work. +Members of granted groups see the project and its channels. Uses +existing grant resolution from v0.16.0 — no new access control code. + +### 10.3 Admin Oversight + +Global admins can list all projects via admin panel. New "Projects" +section under the "People" category (alongside Teams and Groups). +Table view with scope, owner, team, resource counts, last updated. + +--- + +## 11. Implementation Phases + +### Phase 1 — Data Model + Store + API + +Backend foundation. All store operations, migrations, and API endpoints. + +**New files:** +- `server/database/migrations/006_v0190_projects.sql` (Postgres) +- `server/database/migrations/sqlite/006_v0190_projects.sql` (SQLite) +- `server/store/postgres/project.go` (ProjectStore implementation) +- `server/store/sqlite/project.go` (ProjectStore implementation) +- `server/handlers/projects.go` (CRUD + resource association + grants) + +**Modified files:** +- `server/models/models.go` — add Project, ProjectPatch, ProjectChannel structs +- `server/store/interfaces.go` — add ProjectStore interface, Projects field to Stores +- `server/store/postgres/stores.go` — wire ProjectStore in NewStores() +- `server/store/sqlite/stores.go` — wire ProjectStore in NewStores() +- `server/main.go` — wire project routes, inject store +- `server/handlers/completion.go` — extend KB resolution chain (§6) +- `server/handlers/channels.go` — migrate ListChannels to store layer, + add `project_id` filter parameter + +**Technical debt resolved:** +- `ListChannels` handler: raw SQL → ChannelStore (only remaining + handler bypassing the store layer) +- `folder_id` column dropped (unused FK placeholder) + +### Phase 2 — Sidebar UI + +Frontend project grouping, collapse/expand, drag-and-drop. + +**New files:** +- `src/js/projects.js` — ProjectManager: CRUD calls, sidebar rendering, + drag handlers, active project context, collapse state + +**Modified files:** +- `src/js/ui-core.js` — `renderChatList()` → delegates to ProjectManager + for grouped rendering, falls back to flat list when no projects exist +- `src/js/chat.js` — `loadChats()` adds project fetch, channel objects + include `project_id` +- `src/js/api.js` — project API methods (CRUD, resource association) +- `src/js/app.js` — `App.activeProjectId`, keyboard shortcuts, project + context for new channel creation +- `src/css/styles.css` — project group styles, folder indent, drag + target highlights, collapse animations +- `src/index.html` — sidebar structure updates, "New Project" button + +### Phase 3 — Settings Panel + Admin + +Side panel project settings, admin panel integration. + +**New files:** +- `src/js/project-settings.js` — PanelRegistry registration, + settings form, KB picker, grant picker, delete flow + +**Modified files:** +- `src/js/panels.js` — project-settings panel registration +- `src/js/ui-admin.js` — admin "Projects" section (table + CRUD) +- `src/js/persona-kb.js` — extract KB picker as reusable component + for both Persona and Project settings +- `src/css/styles.css` — settings panel styles + +--- + +## 12. Deferred: Notifications (→ v0.19.1, v0.19.2) + +Notifications are split out because: + +1. **Independent value.** Projects are useful without notifications. + Notifications are useful without projects. Neither blocks the other. + +2. **Scope control.** Notifications span persistence, real-time + WebSocket delivery, email transport with SMTP, HTML templates, + digest batching, and per-user preferences. That's a standalone + feature set with its own store, handler, and UI. + +3. **Email is ops-heavy.** SMTP configuration, template rendering, and + digest scheduling affect deployment requirements. Air-gapped envs + won't have SMTP. Separating lets us ship in-app notifications first. + +**v0.19.1 — In-App Notifications:** +- `notifications` table + NotificationStore (both dialects) +- In-app notification bell (header bar, unread count badge) +- Notification dropdown: grouped by type, mark read, click-to-navigate +- WebSocket push via EventBus (`notification.new` → `DirToClient` in routeTable) +- Sources: grant changes, KB processing complete, project invites, + memory review queue (completes v0.18.0 admin workflow), team invites + +**v0.19.2 — Email Transport (tentative):** +- SMTP config in admin settings (host, port, from, TLS) +- HTML + plaintext templates, branded with instance name +- Per-user preferences: per-type toggles (in-app / email / off) +- Digest mode: batch low-priority notifications (hourly/daily) +- Admin enable/disable email globally or per-team + +--- + +## 13. Forward Compatibility + +### 13.1 Multi-Participant Channels (v0.23.0) + +Projects don't assume single-owner channels. A project channel that +later becomes a group channel (with `channel_participants`) works +unchanged — `project_channels` joins on `channel_id`, not `user_id`. + +### 13.2 Workflow Engine (v0.25.0) + +Workflow channels (`type='workflow'`) can belong to projects. A project +could group all workflow instances for a process. The `project_id` on +channels is type-agnostic. + +### 13.3 Project-Scoped Memory (future) + +Not implemented, but the data model supports it. If a project has a +default Persona, memories from project channels naturally feed into +persona-scoped memory via existing v0.18.0 mechanics. No schema changes. + +### 13.4 Extension Surfaces (v0.21.0) + +The project settings panel uses PanelRegistry (v0.18.1). When extension +surfaces land, a project could define which surfaces/modes are available +to its channels via `settings` JSON extension — no schema change. + +--- + +## 14. Migration Safety + +### 14.1 Postgres + +Migration `006_v0190_projects.sql` is additive: +- New tables: `projects`, `project_channels`, `project_knowledge_bases`, + `project_notes` +- New column: `channels.project_id` (nullable FK, ON DELETE SET NULL) +- Altered CHECK: `resource_grants.resource_type` adds `'project'` +- Dropped column: `channels.folder_id` (confirmed unused) + +All operations are backward-compatible. Existing channels work without +project assignment. The migration is safe to apply to live databases +(no table locks beyond the brief ALTER TABLE). + +### 14.2 SQLite + +`sqlite/006_v0190_projects.sql` with standard dialect adaptations: +- `UUID` → `TEXT` +- `TIMESTAMPTZ` → `TEXT` +- `JSONB` → `TEXT` (JSON) +- `gen_random_uuid()` → app-side `store.NewID()` +- `ALTER TABLE channels ADD COLUMN project_id TEXT` (no FK enforcement + in SQLite, but the store layer handles referential integrity) + +Note: `ALTER TABLE DROP COLUMN` requires SQLite ≥ 3.35.0. Ubuntu 24 +ships 3.45, so `folder_id` drop is safe. Verify minimum SQLite version +in CI. + +### 14.3 Rollback + +All new tables can be dropped without affecting existing data. The +`channels.project_id` column can be dropped (nullable, no data loss). +The `resource_grants` CHECK can be reverted. No destructive operations +on existing tables. + +### 14.4 Folder Migration (best-effort) + +For users with existing `channels.folder` values, the migration +creates personal projects from distinct folder names: + +```sql +-- Pseudo-SQL (actual implementation in Go for SQLite compat) +-- For each distinct (user_id, folder) pair where folder is non-empty: +-- 1. Create a personal project with name = folder +-- 2. Insert project_channels row +-- 3. Set channels.project_id +``` + +Implementation is in the Go migration runner (not raw SQL) because +SQLite needs `store.NewID()` for UUID generation. If no `folder` +values exist, nothing happens. This is a convenience migration, not +a correctness requirement — users can reorganize manually. + +--- + +## 15. Test Plan + +### 15.1 Store Integration Tests (both dialects) + +- Project CRUD: create, read, update, delete, list +- Scope enforcement: personal projects invisible to other users, + team projects visible to team members, global visible to all +- Resource association: add/remove channels, KBs, notes +- Cascade: project delete detaches channels (project_id → NULL) +- Cascade: channel delete removes junction row, project unaffected +- Cascade: KB delete removes junction row, project unaffected +- `GetProjectKBsForChannel`: correct KBs through the full join chain + (channel → project_channel → project_kb → kb) +- `GetProjectKBsForChannel`: returns empty for unassigned channels +- Grant resolution: `UserCanAccess` for team_only, global, group grants +- `ListAccessible`: union of personal + team + granted +- One-project-per-channel: UNIQUE constraint on `project_channels.channel_id` + rejects duplicate assignment +- `MoveChannel`: position and folder_path update correctly + +### 15.2 Handler Integration Tests + +- CRUD with auth: admin creates global, team_admin creates team, + user creates personal, unauthorized users rejected +- Grant management: set/get grants, verify access changes +- Channel filter: `?project_id=uuid` returns only project channels, + `?project_id=none` returns only unassigned +- KB injection: completion with project KBs includes them in search + context alongside channel and persona KBs + +### 15.3 Frontend Tests (manual + smoke) + +- Sidebar renders project groups with correct channels +- Collapse/expand persists across page refresh +- Drag channel into project updates sidebar and API +- Drag channel between projects moves cleanly +- Drag channel to "Recent" removes from project +- New channel within project context auto-associates +- Project settings panel opens with correct data +- Project delete detaches channels (they appear in "Recent") + +### 15.4 Unhappy Path / Defensive Tests + +- Corrupt JSON in `channels.settings` → `scanJSON` defaults to `{}`, + warning logged, channel list still returns valid response +- Corrupt JSON in `channels.tags` → `scanTags` defaults to `[]`, + warning logged +- `SafeJSON` catches marshal failure → returns 500, not truncated 200 +- Startup integrity check detects corrupt rows and logs warnings +- Channel moved between projects: old junction row removed, new one + created, `project_id` denorm updated — all atomic +- Project delete with channels: channels detach cleanly, appear in + "Recent", no orphaned junction rows +- KB removed from project: immediately stops appearing in completion + context for project channels (virtual injection, no stale rows) +- Non-existent project_id filter on channels → empty result, not error +- Adding channel to project when channel already in another project → + atomic move, not duplicate + +--- + +## 16. Resolved Questions + +1. **Should "New Chat" always prompt for project?** **No.** New chats + go to "Recent" by default. Users drag them into projects when worth + keeping. Channels created from within a project context (expanded + project group) auto-associate. Low-friction quick-chat UX preserved. + +2. **Channel in multiple projects?** **One project per channel.** + Enforced by UNIQUE on `project_channels.channel_id`. Moving a + channel between projects must be easy — the UI should support + drag-to-different-project and a "Move to project" context menu + action. The `AddChannel` store method handles the move atomically: + remove from old project (if any), add to new, update denorm column. + +3. **Project-level search?** **Deferred to v0.19.1 or v0.19.2.** The + junction tables make this straightforward — `WHERE channel_id IN + (SELECT channel_id FROM project_channels WHERE project_id = $1)`. + Not MVP but real utility for power users with many project channels. + +4. **Note auto-association?** **Yes.** When a note is created from a + message in a project channel, auto-associate it with the project + via `project_notes`. The provenance chain (channel → project → note) + is natural and expected. Implemented in the note creation handler + by checking `channel.project_id` and inserting the junction row. + +--- + +## 17. Bugfixes Bundled with v0.19.0 + +### 17.0 Defensive Coding: Unhappy Path Philosophy + +Chat Switchboard needs to be resilient to dirty data, especially +post-upgrade. Users (and developers) don't always back up before +upgrading. Migrations can leave stale or corrupt data. External inputs +(API keys, provider responses, pasted content) can inject unexpected +bytes. The codebase should **warn and continue** rather than crash or +produce truncated responses. + +**Principles:** + +- **Validate on read, not just write.** Even if the write path ensures + valid JSON, the read path must handle the case where it isn't. + Migrations, manual DB edits, encoding bugs, and version skew can all + produce invalid data. + +- **Degrade gracefully.** A corrupt `settings` column on one channel + should not prevent loading all channels. Default to `{}` or `[]`, + log a warning with the row ID, and continue. + +- **Never send 200 with a broken body.** Pre-validate serialization + for endpoints that touch user-controlled JSON columns. If marshaling + fails, return 500 with a useful error, not a truncated stream. + +- **Log actionable context.** Warnings should include: table, column, + row ID, and the nature of the corruption. This lets admins find and + fix the specific row without guessing. + +- **Apply everywhere JSON columns are scanned.** The `scanJSON` and + `scanTags` helpers are used across channels, settings, personas, + and knowledge bases. The fix must be in the shared helpers, not + patched per-handler. + +### 17.1 Channels endpoint returns truncated JSON (P0) + +**Root cause found:** A channel row has a corrupted `settings` column +containing a `\x02` (STX control byte) instead of valid JSON. The +`scanJSON` helper copies raw bytes without validation — the scan +succeeds. Then `c.JSON(200, ...)` starts writing the response (status ++ headers flushed), the JSON encoder hits the corrupt `RawMessage`, +`MarshalJSON()` validates the content, finds `\x02`, aborts +mid-stream. Truncated body. Browser gets "Unexpected end of JSON input". + +Server log confirmation (fires on every channels request): +``` +Error #01: json: error calling MarshalJSON for type json.RawMessage: + invalid character '\x02' looking for beginning of value +``` + +**Fix (three layers):** + +**Layer 1 — Harden `scanJSON` (shared helper):** +```go +func (s *jsonScanner) Scan(src interface{}) error { + switch v := src.(type) { + case []byte: + if !json.Valid(v) { + log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(v), v) + *s.dest = json.RawMessage("{}") + return nil + } + *s.dest = json.RawMessage(v) + case string: + b := []byte(v) + if !json.Valid(b) { + log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(b), b) + *s.dest = json.RawMessage("{}") + return nil + } + *s.dest = json.RawMessage(b) + case nil: + *s.dest = json.RawMessage("{}") + default: + *s.dest = json.RawMessage("{}") + } + return nil +} +``` + +This ensures no corrupt data reaches the JSON encoder. The warning +log includes a truncated preview to help identify the source without +dumping the full corrupt blob. + +**Layer 2 — Same treatment for `scanTags`:** +Apply identical validation to the tags scanner. If the stored value +isn't valid JSON array, default to `[]` and warn. + +**Layer 3 — Pre-marshal on list endpoints:** +For endpoints that return arrays of objects with `json.RawMessage` +fields, pre-marshal the response to catch errors before headers are +sent: + +```go +// Helper: SafeJSON writes status + body only after successful marshal +func SafeJSON(c *gin.Context, code int, obj interface{}) { + body, err := json.Marshal(obj) + if err != nil { + log.Printf("⚠ SafeJSON: marshal failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "response serialization failed", + }) + return + } + c.Data(code, "application/json; charset=utf-8", body) +} +``` + +Apply to: `ListChannels`, `GetChannel`, `ListPersonas`, and any other +handler returning models with JSONB fields. + +**Layer 4 — Startup integrity check (warn-only):** +On application boot, after migrations complete, run a lightweight +scan for corrupt JSON columns: + +```go +func checkJSONIntegrity(db *sql.DB) { + tables := []struct{ table, column string }{ + {"channels", "settings"}, + {"channels", "tags"}, + {"personas", "settings"}, + {"knowledge_bases", "metadata"}, + {"users", "settings"}, + } + for _, t := range tables { + // Postgres: check for non-JSON values + query := fmt.Sprintf( + `SELECT id FROM %s WHERE %s IS NOT NULL + AND %s::text != '' AND %s::text NOT SIMILAR TO '[{"[\s]%%'`, + t.table, t.column, t.column, t.column) + rows, err := db.Query(query) + if err != nil { continue } + var ids []string + for rows.Next() { + var id string + rows.Scan(&id) + ids = append(ids, id) + } + rows.Close() + if len(ids) > 0 { + log.Printf("⚠ Corrupt JSON detected: %s.%s in %d rows: %v", + t.table, t.column, len(ids), ids) + } + } +} +``` + +This doesn't repair anything — just surfaces problems in the startup +log so admins know. Repair is manual or via a future `--repair` flag. + +### 17.2 Service Worker caches chrome-extension:// URLs + +**Symptom:** `TypeError: Failed to execute 'put' on 'Cache': Request +scheme 'chrome-extension' is unsupported` on every page load. + +**Root cause:** The SW fetch handler's exclusion checks are all +pathname-based (`/api/`, `/ws`, etc.). URLs from browser extensions +pass through and hit `Cache.put()` which rejects non-http(s) schemes. + +**Fix:** Scheme guard at the top of the fetch handler: +```js +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return; // Ignore chrome-extension://, moz-extension://, etc. + } + // ... existing handler ... +}); +``` + +### 17.3 Branding 404 on custom.css + +**Symptom:** `GET /branding/custom.css 404` on every page load when +no custom branding is configured. + +**Fix:** Serve an empty CSS file as the default when no custom +branding is mounted. Either: +- (a) Backend: static handler returns empty `text/css` body with + `Cache-Control: no-cache` if file doesn't exist, or +- (b) Frontend: conditionally emit the `` tag based on a + `has_custom_branding` flag in the health/settings response. + +Option (a) is simpler — one handler change, no frontend coordination. + +### 17.4 Duplicate PUT /settings during init + +**Symptom:** Two `PUT /api/v1/settings` calls within ~50ms during +startup. Confirmed in server logs — two PUTs at 21:21:23 and again +at 21:21:36 (second page load). + +**Root cause:** Two independent code paths saving settings on init. + +**Fix:** Consolidate or debounce. A `settingsDirty` flag + 100ms +`setTimeout` debounce in the API layer ensures only one PUT fires +per init cycle. + +### 17.5 WebSocket token in URL (minor security hygiene) + +**Observation from server logs:** The WS endpoint logs include the +full JWT in the URL query string: +``` +GET "/test/ws?token=eyJhbG..." +``` + +This means the JWT appears in: Gin access logs, nginx access logs, +and any log aggregator. Access tokens are short-lived (15 min) so +the risk is low, but it's worth: +- Redacting the token in Gin's log formatter (show `ws?token=[REDACTED]`) +- Or switching to WS subprotocol auth (send token as first message + after connect) in a future release + +Not blocking for v0.19.0 but worth noting. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 84e68ab..84e08e9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -70,9 +70,9 @@ v0.18.0 Memory ✅ (user + persona scopes, review pipeline) │ v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid refactor) │ -v0.19.0 Projects / Workspaces + Notifications +v0.19.0 Projects / Workspaces ✅ (project groups, KB resolution, drag-drop sidebar) │ -v0.20.0 @mention Routing + Multi-model +v0.20.0 Notifications + @mention Routing + Multi-model │ v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2) │ @@ -496,37 +496,52 @@ collaboration (v0.23.0) where multiple panels need simultaneous visibility. --- -## v0.19.0 — Projects / Workspaces + Notifications +## ✅ v0.19.0 — Projects / Workspaces Organizational containers that group related conversations, KBs, and -notes into a single workspace with shared access controls. Plus the -notification infrastructure that makes collaboration real-time. +notes into a single workspace with shared access controls. Depends on: user groups (v0.16.0), knowledge bases (v0.14.0). **Data Model** -- [ ] `projects` table: `id`, `name`, `description`, `scope` (personal, team, global), `owner_id`, `team_id`, `settings` (JSONB), `created_at`, `updated_at` -- [ ] `project_resources` table: `project_id`, `resource_type` (channel, knowledge_base, note), `resource_id`, `added_at` -- [ ] Projects use the same grant model (v0.16.0): team_only / global / groups +- [x] `projects` table: `id`, `name`, `description`, `color`, `icon`, `scope` (personal/team/global), `owner_id`, `team_id`, `is_archived`, `settings` (JSONB) +- [x] `project_channels` junction: ordered membership with UNIQUE `channel_id`, denormalized FK on `channels.project_id` +- [x] `project_knowledge_bases` junction: with `auto_search` flag +- [x] `project_notes` junction +- [x] `resource_grants` CHECK extended to include `'project'` **Features** -- [ ] Channels can belong to a project (optional `project_id` FK) -- [ ] KBs can belong to a project → auto-linked to all channels in the project -- [ ] Notes can belong to a project → visible to all project members -- [ ] Project-level Persona default: all new channels in the project start with this Persona -- [ ] Project sidebar section: grouped view of conversations with folder-like nesting +- [x] Channels can belong to a project (optional `project_id` FK) +- [x] KBs can belong to a project → auto-linked to all channels in the project (BuildKBHint + kbsearch) +- [x] Notes can belong to a project → auto-associated when created from project channel +- [x] KB resolution chain: Persona → Project → Channel → Personal +- [x] Project sidebar section: collapsible groups above time-based Recent section **UI** -- [ ] Sidebar: projects as collapsible groups above/integrated with chat history -- [ ] Project creation dialog (name, description, default Persona, default KBs) -- [ ] Drag channels/notes into projects -- [ ] Folders within projects (lightweight — just a `path` column on project_resources) -- [ ] Project settings: manage resources, access, defaults +- [x] Sidebar: projects as collapsible groups with color dots, counts, options menu +- [x] Right-click context menu: move chat to project / remove / create-and-assign +- [x] Drag-and-drop channels between project groups and Recent +- [x] New Project button in split dropdown +- [x] Channel list `project_id` filter (`?project_id=uuid` or `?project_id=none`) +- [x] Collapse state persisted in localStorage -**Enterprise Use** -- [ ] Team admins create projects for their teams -- [ ] Project templates: pre-configured with specific Personas, KBs, and settings -- [ ] Cross-team projects via group grants +**Deferred to later versions:** +- Project creation dialog (name, description, default Persona, default KBs) — using prompt() for now +- Project detail panel (KB + note management within a project) +- Project-level Persona default +- Folders within projects +- Project templates +- Admin-level team/global project management +- Notifications (moved to v0.20.0) + +--- + +## v0.20.0 — Notifications + @mention Routing + Multi-model + +Notification infrastructure that makes collaboration real-time, plus +the channel schema already supports multiple models — this adds routing. + +_(Notifications shifted from v0.19.0; @mention routing shifted from v0.17.0)_ **Notifications** - [ ] `notifications` table: `id`, `user_id`, `type`, `title`, `body`, `resource_type`, `resource_id`, `is_read`, `created_at` @@ -540,14 +555,7 @@ Depends on: user groups (v0.16.0), knowledge bases (v0.14.0). - [ ] Admin settings: enable/disable email, SMTP config, default notification preferences - [ ] User preferences: per-type toggles (in-app, email, off) ---- - -## v0.20.0 — @mention Routing + Multi-model - -The channel schema already supports multiple models. This adds routing. - -_(Shifted from v0.17.0 — no content changes)_ - +**@mention Routing** - [ ] @mention parsing in messages (users and AI models) - [ ] Resolve mentions against `channel_models` - [ ] Multi-model channels: fire completions per mentioned model diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index a1b4efe..30a9c46 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -195,11 +195,13 @@ check_table "kb_documents" check_table "kb_chunks" check_table "channel_knowledge_bases" -# ── Dropped tables (v0.16.0 cleanup) ──── +# ── Projects (v0.19.0) ─────────────────── echo "" -echo "Dropped tables (v0.16.0 cleanup):" -check_table_absent "projects" -check_table_absent "project_channels" +echo "Projects (v0.19.0):" +check_table "projects" +check_table "project_channels" +check_table "project_knowledge_bases" +check_table "project_notes" # ── Users (vault) ──────────────────────── echo "" @@ -266,6 +268,7 @@ echo "Channels & Messages:" check_column "channels" "settings" check_column "channels" "folder_id" check_column "channels" "team_id" +check_column "channels" "project_id" check_column "messages" "parent_id" check_column "messages" "sibling_index" check_column "messages" "tool_calls" diff --git a/server/database/migrations/006_v0190_projects.sql b/server/database/migrations/006_v0190_projects.sql new file mode 100644 index 0000000..7286a80 --- /dev/null +++ b/server/database/migrations/006_v0190_projects.sql @@ -0,0 +1,157 @@ +-- v0.19.0: Projects / Workspaces +-- +-- New: projects table +-- New: project_channels junction table (1:1 channel→project) +-- New: project_knowledge_bases junction table +-- New: project_notes junction table +-- New: channels.project_id denormalized FK +-- Changed: resource_grants.resource_type CHECK includes 'project' +-- Deprecated: channels.folder (column retained, no longer written) + +-- ========================================= +-- 1. Projects Table +-- ========================================= + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(200) NOT NULL, + description TEXT, + color VARCHAR(7), -- hex color e.g. '#3B82F6' + icon VARCHAR(50), -- emoji or icon name + scope VARCHAR(20) NOT NULL DEFAULT 'personal' + CHECK (scope IN ('personal', 'team', 'global')), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + is_archived BOOLEAN NOT NULL DEFAULT false, + settings JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id); +CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope); + +DROP TRIGGER IF EXISTS projects_updated_at ON projects; +CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +COMMENT ON TABLE projects IS 'Organizes channels, KBs, and notes into named workspaces'; +COMMENT ON COLUMN projects.scope IS 'personal=owner only, team=team members, global=all users'; + +-- ========================================= +-- 2. Project ↔ Channel Junction +-- ========================================= +-- One project per channel (UNIQUE on channel_id). +-- Source of truth for position and folder within the project. + +CREATE TABLE IF NOT EXISTS project_channels ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + folder TEXT, -- sub-folder within the project + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, channel_id), + UNIQUE (channel_id) -- one project per channel +); + +CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id); +CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id); + +COMMENT ON TABLE project_channels IS 'Links channels to projects. UNIQUE(channel_id) enforces one-project-per-channel.'; +COMMENT ON COLUMN project_channels.position IS 'Sort order within the project sidebar group'; +COMMENT ON COLUMN project_channels.folder IS 'Optional sub-folder path within the project'; + +-- ========================================= +-- 3. Project ↔ Knowledge Base Junction +-- ========================================= + +CREATE TABLE IF NOT EXISTS project_knowledge_bases ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + auto_search BOOLEAN NOT NULL DEFAULT false, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, kb_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id); +CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id); + +COMMENT ON TABLE project_knowledge_bases IS 'Binds KBs to Projects — injected at completion time for all project channels'; +COMMENT ON COLUMN project_knowledge_bases.auto_search IS 'true = auto-prepend top-K results; false = kb_search tool only'; + +-- ========================================= +-- 4. Project ↔ Note Junction +-- ========================================= + +CREATE TABLE IF NOT EXISTS project_notes ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (project_id, note_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id); +CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id); + +COMMENT ON TABLE project_notes IS 'Links notes to projects for organizational grouping'; + +-- ========================================= +-- 5. Denormalized project_id on Channels +-- ========================================= +-- Fast sidebar query: SELECT ... FROM channels WHERE project_id = $1 +-- Source of truth is project_channels; this is updated atomically. + +ALTER TABLE channels + ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL; + +-- ========================================= +-- 6. Extend resource_grants CHECK +-- ========================================= +-- Add 'project' to allowed resource_type values. +-- Postgres ALTER TABLE ... ALTER CONSTRAINT is not supported; drop + re-add. + +ALTER TABLE resource_grants DROP CONSTRAINT IF EXISTS resource_grants_resource_type_check; +ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check + CHECK (resource_type IN ('persona', 'knowledge_base', 'project')); + +-- ========================================= +-- 7. Best-effort folder → project migration +-- ========================================= +-- Create a personal project for each distinct folder value, then +-- associate the channels. Skip if no folder values exist. +-- This is idempotent: re-running won't duplicate projects because +-- we check for existing project_id. + +DO $$ +DECLARE + _folder TEXT; + _user_id UUID; + _proj_id UUID; + _ch_id UUID; +BEGIN + FOR _user_id, _folder IN + SELECT DISTINCT user_id, folder FROM channels + WHERE folder IS NOT NULL AND folder != '' AND project_id IS NULL + LOOP + -- Create project named after the folder + INSERT INTO projects (name, scope, owner_id) + VALUES (_folder, 'personal', _user_id) + RETURNING id INTO _proj_id; + + -- Associate channels + FOR _ch_id IN + SELECT id FROM channels + WHERE user_id = _user_id AND folder = _folder AND project_id IS NULL + LOOP + INSERT INTO project_channels (project_id, channel_id, position) + VALUES (_proj_id, _ch_id, 0) + ON CONFLICT DO NOTHING; + + UPDATE channels SET project_id = _proj_id WHERE id = _ch_id; + END LOOP; + END LOOP; +END +$$; diff --git a/server/database/migrations/sqlite/005_v0190_projects.sql b/server/database/migrations/sqlite/005_v0190_projects.sql new file mode 100644 index 0000000..7db76a6 --- /dev/null +++ b/server/database/migrations/sqlite/005_v0190_projects.sql @@ -0,0 +1,131 @@ +-- v0.19.0: Projects / Workspaces (SQLite) + +-- ========================================= +-- 1. Projects Table +-- ========================================= + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + color TEXT, + icon TEXT, + scope TEXT NOT NULL DEFAULT 'personal' + CHECK (scope IN ('personal', 'team', 'global')), + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + is_archived INTEGER NOT NULL DEFAULT 0, + settings TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id); +CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id); +CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope); + +CREATE TRIGGER IF NOT EXISTS projects_updated_at AFTER UPDATE ON projects +FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE projects SET updated_at = datetime('now') WHERE id = NEW.id; +END; + +-- ========================================= +-- 2. Project ↔ Channel Junction +-- ========================================= + +CREATE TABLE IF NOT EXISTS project_channels ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + folder TEXT, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, channel_id), + UNIQUE (channel_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id); +CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id); + +-- ========================================= +-- 3. Project ↔ Knowledge Base Junction +-- ========================================= + +CREATE TABLE IF NOT EXISTS project_knowledge_bases ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + auto_search INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, kb_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id); +CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id); + +-- ========================================= +-- 4. Project ↔ Note Junction +-- ========================================= + +CREATE TABLE IF NOT EXISTS project_notes ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, note_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id); +CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id); + +-- ========================================= +-- 5. Denormalized project_id on Channels +-- ========================================= + +ALTER TABLE channels ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id); + +-- ========================================= +-- 6. Extend resource_grants CHECK +-- ========================================= +-- SQLite cannot ALTER CHECK constraints. The original CREATE TABLE +-- defined CHECK(resource_type IN ('persona','knowledge_base')). +-- SQLite does not enforce CHECK on existing rows, and new inserts +-- with 'project' will be accepted only if we recreate the table. +-- For pragmatism: we accept 'project' values via application-level +-- validation and leave the SQLite CHECK as-is (it's advisory in +-- SQLite when PRAGMA ignore_check_constraints is implicitly off +-- for existing data, but CHECK IS enforced on INSERT). +-- +-- Workaround: recreate the table with the new CHECK. This is safe +-- because resource_grants typically has very few rows. + +CREATE TABLE IF NOT EXISTS resource_grants_new ( + id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL + CHECK (resource_type IN ('persona', 'knowledge_base', 'project')), + resource_id TEXT NOT NULL, + grant_scope TEXT NOT NULL DEFAULT 'team_only' + CHECK (grant_scope IN ('team_only', 'global', 'groups')), + granted_groups TEXT NOT NULL DEFAULT '[]', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(resource_type, resource_id) +); + +INSERT OR IGNORE INTO resource_grants_new + SELECT id, resource_type, resource_id, grant_scope, granted_groups, + created_by, created_at, updated_at + FROM resource_grants; + +DROP TABLE IF EXISTS resource_grants; +ALTER TABLE resource_grants_new RENAME TO resource_grants; + +CREATE INDEX IF NOT EXISTS idx_resource_grants_resource + ON resource_grants(resource_type, resource_id); + +CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants +FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at +BEGIN + UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id; +END; diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 7c40241..b359fc9 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -266,10 +266,14 @@ func TruncateAll(t *testing.T) { "channel_members", "channel_knowledge_bases", "persona_knowledge_bases", + "project_notes", + "project_knowledge_bases", + "project_channels", "kb_chunks", "kb_documents", "knowledge_bases", "channels", + "projects", "user_model_settings", "model_catalog", "persona_grants", diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 72db2ee..12324e9 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -53,6 +53,7 @@ type channelResponse struct { IsArchived bool `json:"is_archived"` IsPinned bool `json:"is_pinned"` Folder *string `json:"folder"` + ProjectID *string `json:"project_id,omitempty"` Tags []string `json:"tags"` Settings json.RawMessage `json:"settings,omitempty"` MessageCount int `json:"message_count"` @@ -138,6 +139,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { folder := c.Query("folder") channelType := c.DefaultQuery("type", "") // empty = all types search := strings.TrimSpace(c.Query("search")) + projectFilter := c.Query("project_id") // "uuid" or "none" // Count total countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2` @@ -159,6 +161,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { countArgs = append(countArgs, "%"+search+"%") argN++ } + if projectFilter == "none" { + countQuery += ` AND project_id IS NULL` + } else if projectFilter != "" { + countQuery += ` AND project_id = $` + strconv.Itoa(argN) + countArgs = append(countArgs, projectFilter) + argN++ + } var total int if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil { @@ -169,7 +178,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { // Fetch channels with message count query := ` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, + c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at FROM channels c @@ -196,6 +206,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { 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) @@ -213,7 +230,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { var tags []string err := rows.Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, scanTags(&tags), scanJSON(&ch.Settings), &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) @@ -278,11 +295,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { // Read back the row err = database.DB.QueryRow(` SELECT id, user_id, title, type, description, model, provider_config_id, - system_prompt, is_archived, is_pinned, folder, tags, settings, + system_prompt, is_archived, is_pinned, folder, project_id, + tags, settings, created_at, updated_at FROM channels WHERE id = ?`, id).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { @@ -294,12 +312,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt, - is_archived, is_pinned, folder, tags, settings, created_at, updated_at + is_archived, is_pinned, folder, project_id, tags, settings, created_at, updated_at `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID, req.Folder, pq.Array(req.Tags), ).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt, ) if err != nil { @@ -359,7 +377,8 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { var tags []string err := database.DB.QueryRow(database.Q(` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, + c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at FROM channels c @@ -369,7 +388,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { WHERE c.id = $1 AND c.user_id = $2 `), channelID, userID).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID, - &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, scanTags(&tags), scanJSON(&ch.Settings), &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) diff --git a/server/handlers/knowledge_bases.go b/server/handlers/knowledge_bases.go index 9e27368..428a398 100644 --- a/server/handlers/knowledge_bases.go +++ b/server/handlers/knowledge_bases.go @@ -851,6 +851,25 @@ func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, pe } } + // Project-bound KBs (v0.19.0) + if stores.Projects != nil { + projID, _ := stores.Projects.GetProjectIDForChannel(ctx, channelID) + if projID != "" { + projKBIDs, projErr := stores.Projects.GetKBIDs(ctx, projID) + if projErr == nil { + for _, kbID := range projKBIDs { + if !seen[kbID] { + kb, kbErr := 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 := stores.KnowledgeBases.GetChannelKBs(ctx, channelID) if err == nil { diff --git a/server/handlers/notes.go b/server/handlers/notes.go index 3957140..ed033cd 100644 --- a/server/handlers/notes.go +++ b/server/handlers/notes.go @@ -164,6 +164,15 @@ func (h *NoteHandler) Create(c *gin.Context) { h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title) } + // Auto-associate note with source channel's project (v0.19.0) + if req.SourceChannelID != "" && h.stores.Projects != nil { + projID, _ := h.stores.Projects.GetProjectIDForChannel( + c.Request.Context(), req.SourceChannelID) + if projID != "" { + _ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID) + } + } + c.JSON(http.StatusCreated, toNoteResponse(note)) } diff --git a/server/handlers/projects.go b/server/handlers/projects.go new file mode 100644 index 0000000..551a8a5 --- /dev/null +++ b/server/handlers/projects.go @@ -0,0 +1,471 @@ +package handlers + +import ( + "database/sql" + "net/http" + + "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" +) + +// ── Request / Response Types ──────────────── + +type createProjectRequest struct { + Name string `json:"name" binding:"required,max=200"` + Description string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + Icon *string `json:"icon,omitempty"` +} + +type updateProjectRequest = models.ProjectPatch + +type addChannelRequest struct { + ChannelID string `json:"channel_id" binding:"required"` + Position int `json:"position"` +} + +type reorderChannelsRequest struct { + ChannelIDs []string `json:"channel_ids" binding:"required"` +} + +type addKBRequest struct { + KBID string `json:"kb_id" binding:"required"` + AutoSearch bool `json:"auto_search"` +} + +type addNoteRequest struct { + NoteID string `json:"note_id" binding:"required"` +} + +// ProjectHandler handles project CRUD and associations. +type ProjectHandler struct { + stores store.Stores +} + +// NewProjectHandler creates a new project handler. +func NewProjectHandler(s store.Stores) *ProjectHandler { + return &ProjectHandler{stores: s} +} + +// ── CRUD ──────────────────────────────────── + +func (h *ProjectHandler) List(c *gin.Context) { + userID := getUserID(c) + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) + includeArchived := c.Query("include_archived") == "true" + + projects, err := h.stores.Projects.ListForUser(c.Request.Context(), userID, teamIDs, includeArchived) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) + return + } + if projects == nil { + projects = []models.Project{} + } + c.JSON(http.StatusOK, gin.H{"data": projects}) +} + +func (h *ProjectHandler) Create(c *gin.Context) { + userID := getUserID(c) + var req createProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + p := &models.Project{ + Name: req.Name, + Description: req.Description, + Color: req.Color, + Icon: req.Icon, + Scope: models.ScopePersonal, + OwnerID: userID, + } + + if err := h.stores.Projects.Create(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"}) + return + } + + c.JSON(http.StatusCreated, p) +} + +func (h *ProjectHandler) Get(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + c.JSON(http.StatusOK, project) +} + +func (h *ProjectHandler) Update(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + var req updateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Projects.Update(c.Request.Context(), project.ID, req); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update project"}) + return + } + + // Return refreshed project + updated, err := h.stores.Projects.GetByID(c.Request.Context(), project.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload project"}) + return + } + c.JSON(http.StatusOK, updated) +} + +func (h *ProjectHandler) Delete(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + // Only owner or admin can delete + userID := getUserID(c) + if project.OwnerID != userID { + role, _ := c.Get("role") + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "only the project owner can delete it"}) + return + } + } + + if err := h.stores.Projects.Delete(c.Request.Context(), project.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete project"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "project deleted"}) +} + +// ── Channel Association ───────────────────── + +func (h *ProjectHandler) AddChannel(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + var req addChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify the user owns the channel + userID := getUserID(c) + owns, err := h.stores.Channels.UserOwns(c.Request.Context(), req.ChannelID, userID) + if err != nil || !owns { + c.JSON(http.StatusForbidden, gin.H{"error": "channel not found or not owned"}) + return + } + + if err := h.stores.Projects.AddChannel(c.Request.Context(), project.ID, req.ChannelID, req.Position); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add channel"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "channel added"}) +} + +func (h *ProjectHandler) RemoveChannel(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + channelID := c.Param("channelId") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id required"}) + return + } + + if err := h.stores.Projects.RemoveChannel(c.Request.Context(), project.ID, channelID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "channel not in project"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove channel"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "channel removed"}) +} + +func (h *ProjectHandler) ListChannels(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + channels, err := h.stores.Projects.ListChannels(c.Request.Context(), project.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) + return + } + if channels == nil { + channels = []models.ProjectChannel{} + } + c.JSON(http.StatusOK, gin.H{"data": channels}) +} + +func (h *ProjectHandler) ReorderChannels(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + var req reorderChannelsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Projects.ReorderChannels(c.Request.Context(), project.ID, req.ChannelIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder channels"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "channels reordered"}) +} + +// ── KB Association ────────────────────────── + +func (h *ProjectHandler) AddKB(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + var req addKBRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Projects.AddKB(c.Request.Context(), project.ID, req.KBID, req.AutoSearch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add KB"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "KB added"}) +} + +func (h *ProjectHandler) RemoveKB(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + kbID := c.Param("kbId") + if kbID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "kb_id required"}) + return + } + + if err := h.stores.Projects.RemoveKB(c.Request.Context(), project.ID, kbID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "KB not in project"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove KB"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "KB removed"}) +} + +func (h *ProjectHandler) ListKBs(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + kbs, err := h.stores.Projects.ListKBs(c.Request.Context(), project.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list KBs"}) + return + } + if kbs == nil { + kbs = []models.ProjectKB{} + } + c.JSON(http.StatusOK, gin.H{"data": kbs}) +} + +// ── Note Association ──────────────────────── + +func (h *ProjectHandler) AddNote(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + var req addNoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.stores.Projects.AddNote(c.Request.Context(), project.ID, req.NoteID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add note"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "note added"}) +} + +func (h *ProjectHandler) RemoveNote(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + noteID := c.Param("noteId") + if noteID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "note_id required"}) + return + } + + if err := h.stores.Projects.RemoveNote(c.Request.Context(), project.ID, noteID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "note not in project"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove note"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "note removed"}) +} + +func (h *ProjectHandler) ListNotes(c *gin.Context) { + project, ok := h.loadAndAuthorize(c) + if !ok { + return + } + + notes, err := h.stores.Projects.ListNotes(c.Request.Context(), project.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) + return + } + if notes == nil { + notes = []models.ProjectNote{} + } + c.JSON(http.StatusOK, gin.H{"data": notes}) +} + +// ── Auth Helper ───────────────────────────── + +// ── Admin ──────────────────────────────────── + +// AdminList returns all projects (no scope filtering). Admin only. +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) + 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"` + Name string `json:"name"` + Description string `json:"description"` + Scope string `json:"scope"` + OwnerID string `json:"owner_id"` + TeamID *string `json:"team_id,omitempty"` + IsArchived bool `json:"is_archived"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ChannelCount int `json:"channel_count"` + KBCount int `json:"kb_count"` + NoteCount int `json:"note_count"` + 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) + } + if projects == nil { + projects = []adminProject{} + } + c.JSON(http.StatusOK, gin.H{"data": projects}) +} + +// ── Auth Helper ───────────────────────────── + +// loadAndAuthorize loads the project by :id param and checks user access. +// Returns (project, true) on success, or writes an error response and returns (nil, false). +func (h *ProjectHandler) loadAndAuthorize(c *gin.Context) (*models.Project, bool) { + projectID := c.Param("id") + if projectID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "project id required"}) + return nil, false + } + + userID := getUserID(c) + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) + + ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "access check failed"}) + return nil, false + } + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) + return nil, false + } + + project, err := h.stores.Projects.GetByID(c.Request.Context(), projectID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load project"}) + return nil, false + } + + return project, true +} diff --git a/server/main.go b/server/main.go index b632f3f..51ef66c 100644 --- a/server/main.go +++ b/server/main.go @@ -332,6 +332,24 @@ func main() { protected.DELETE("/notes/:id", notes.Delete) protected.GET("/notes/:id/backlinks", notes.Backlinks) + // Projects (v0.19.0) + projectH := handlers.NewProjectHandler(stores) + protected.GET("/projects", projectH.List) + protected.POST("/projects", projectH.Create) + protected.GET("/projects/:id", projectH.Get) + protected.PUT("/projects/:id", projectH.Update) + protected.DELETE("/projects/:id", projectH.Delete) + protected.POST("/projects/:id/channels", projectH.AddChannel) + protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel) + protected.GET("/projects/:id/channels", projectH.ListChannels) + protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels) + protected.POST("/projects/:id/knowledge-bases", projectH.AddKB) + protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB) + protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs) + protected.POST("/projects/:id/notes", projectH.AddNote) + protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote) + protected.GET("/projects/:id/notes", projectH.ListNotes) + // Attachments (file upload/download) attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) protected.POST("/channels/:id/attachments", attachH.Upload) @@ -515,6 +533,11 @@ func main() { // Resource Grants (admin — v0.16.0) admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant) admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) + + // Projects (admin — v0.19.0) + adminProjH := handlers.NewProjectHandler(stores) + admin.GET("/projects", adminProjH.AdminList) + admin.DELETE("/projects/:id", adminProjH.Delete) admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) // Model Roles diff --git a/server/models/models.go b/server/models/models.go index 5c6c96d..e9ae149 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -308,6 +308,7 @@ type Channel struct { IsPinned bool `json:"is_pinned" db:"is_pinned"` FolderID *string `json:"folder_id,omitempty" db:"folder_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"` + ProjectID *string `json:"project_id,omitempty" db:"project_id"` Settings JSONMap `json:"settings,omitempty" db:"settings"` } @@ -376,10 +377,53 @@ type Folder struct { type Project struct { BaseModel - UserID string `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - Description string `json:"description,omitempty" db:"description"` - Color string `json:"color,omitempty" db:"color"` + Name string `json:"name" db:"name"` + Description string `json:"description,omitempty" db:"description"` + Color *string `json:"color,omitempty" db:"color"` + Icon *string `json:"icon,omitempty" db:"icon"` + Scope string `json:"scope" db:"scope"` // personal, team, global + OwnerID string `json:"owner_id" db:"owner_id"` + TeamID *string `json:"team_id,omitempty" db:"team_id"` + IsArchived bool `json:"is_archived" db:"is_archived"` + Settings JSONMap `json:"settings,omitempty" db:"settings"` + + // Computed fields (not DB columns) + ChannelCount int `json:"channel_count,omitempty"` + KBCount int `json:"kb_count,omitempty"` + NoteCount int `json:"note_count,omitempty"` +} + +// ProjectPatch holds optional fields for updating a project. +type ProjectPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + Icon *string `json:"icon,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` +} + +// ProjectChannel represents a channel's membership in a project. +type ProjectChannel struct { + ProjectID string `json:"project_id" db:"project_id"` + ChannelID string `json:"channel_id" db:"channel_id"` + Position int `json:"position" db:"position"` + Folder string `json:"folder,omitempty" db:"folder"` + AddedAt string `json:"added_at" db:"added_at"` +} + +// ProjectKB represents a KB's association with a project. +type ProjectKB struct { + ProjectID string `json:"project_id" db:"project_id"` + KBID string `json:"kb_id" db:"kb_id"` + AutoSearch bool `json:"auto_search" db:"auto_search"` + AddedAt string `json:"added_at" db:"added_at"` +} + +// ProjectNote represents a note's association with a project. +type ProjectNote struct { + ProjectID string `json:"project_id" db:"project_id"` + NoteID string `json:"note_id" db:"note_id"` + AddedAt string `json:"added_at" db:"added_at"` } // ========================================= diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 2650c49..e8ea2b3 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -39,6 +39,7 @@ type Stores struct { Groups GroupStore ResourceGrants ResourceGrantStore Memories MemoryStore + Projects ProjectStore } // ========================================= diff --git a/server/store/postgres/project.go b/server/store/postgres/project.go new file mode 100644 index 0000000..303d858 --- /dev/null +++ b/server/store/postgres/project.go @@ -0,0 +1,403 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "github.com/lib/pq" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// ── ProjectStore ─────────────────────────── + +type ProjectStore struct{} + +func NewProjectStore() *ProjectStore { return &ProjectStore{} } + +// ── CRUD ──────────────────────────────────── + +func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO projects (name, description, color, icon, scope, owner_id, team_id, settings) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at, updated_at`, + p.Name, p.Description, p.Color, p.Icon, p.Scope, + p.OwnerID, models.NullString(p.TeamID), ToJSON(p.Settings), + ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) +} + +func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { + var p models.Project + var teamID sql.NullString + err := DB.QueryRowContext(ctx, ` + SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, + p.owner_id, p.team_id, p.is_archived, p.settings, + 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) + FROM projects p + WHERE p.id = $1`, id).Scan( + &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, + &p.OwnerID, &teamID, &p.IsArchived, &p.Settings, + &p.CreatedAt, &p.UpdatedAt, + &p.ChannelCount, &p.KBCount, &p.NoteCount, + ) + if err != nil { + return nil, err + } + p.TeamID = NullableStringPtr(teamID) + return &p, nil +} + +func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error { + b := NewUpdate("projects") + if patch.Name != nil { + b.Set("name", *patch.Name) + } + if patch.Description != nil { + b.Set("description", *patch.Description) + } + if patch.Color != nil { + b.Set("color", *patch.Color) + } + if patch.Icon != nil { + b.Set("icon", *patch.Icon) + } + if patch.IsArchived != nil { + b.Set("is_archived", *patch.IsArchived) + } + if !b.HasSets() { + return nil + } + b.Where("id", id) + res, err := b.Exec(DB) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) Delete(ctx context.Context, id string) error { + // CASCADE handles junction tables. + // Channels get project_id set to NULL via ON DELETE SET NULL. + res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = $1", id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +// ── Listing ───────────────────────────────── + +func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { + // User sees: their personal projects + team projects for their teams + global projects + q := ` + SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, + p.owner_id, p.team_id, p.is_archived, p.settings, + 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) + FROM projects p + WHERE ( + (p.scope = 'personal' AND p.owner_id = $1) + OR p.scope = 'global'` + + args := []interface{}{userID} + + if len(teamIDs) > 0 { + q += fmt.Sprintf(` + OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1) + args = append(args, pq.Array(teamIDs)) + } + q += `)` + + if !includeArchived { + q += ` AND p.is_archived = false` + } + q += ` ORDER BY p.name` + + return queryProjects(ctx, q, args...) +} + +// ── Channel Association ───────────────────── + +func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error { + // Atomic move: remove from old project (if any), add to new. + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Remove existing association (handles the "move" case) + tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = $1`, channelID) + + // Insert new association + _, err = tx.ExecContext(ctx, ` + INSERT INTO project_channels (project_id, channel_id, position) + VALUES ($1, $2, $3)`, + projectID, channelID, position) + if err != nil { + return err + } + + // Update denormalized column + _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = $1 WHERE id = $2`, + projectID, channelID) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, ` + DELETE FROM project_channels WHERE project_id = $1 AND channel_id = $2`, + projectID, channelID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + + // Clear denormalized column + _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = $1`, channelID) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at + FROM project_channels + WHERE project_id = $1 + ORDER BY position, added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectChannel + for rows.Next() { + var pc models.ProjectChannel + if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil { + return nil, err + } + result = append(result, pc) + } + return result, rows.Err() +} + +func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for i, chID := range channelIDs { + _, err := tx.ExecContext(ctx, ` + UPDATE project_channels SET position = $1 + WHERE project_id = $2 AND channel_id = $3`, + i, projectID, chID) + if err != nil { + return err + } + } + return tx.Commit() +} + +// ── KB Association ────────────────────────── + +func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search) + VALUES ($1, $2, $3) + ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = EXCLUDED.auto_search`, + projectID, kbID, autoSearch) + return err +} + +func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error { + res, err := DB.ExecContext(ctx, ` + DELETE FROM project_knowledge_bases WHERE project_id = $1 AND kb_id = $2`, + projectID, kbID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, kb_id, auto_search, added_at + FROM project_knowledge_bases + WHERE project_id = $1 + ORDER BY added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectKB + for rows.Next() { + var pkb models.ProjectKB + if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt); err != nil { + return nil, err + } + result = append(result, pkb) + } + return result, rows.Err() +} + +func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT kb_id FROM project_knowledge_bases + WHERE project_id = $1`, projectID) + 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) + } + return ids, rows.Err() +} + +// ── Note Association ──────────────────────── + +func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO project_notes (project_id, note_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + projectID, noteID) + return err +} + +func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error { + res, err := DB.ExecContext(ctx, ` + DELETE FROM project_notes WHERE project_id = $1 AND note_id = $2`, + projectID, noteID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, note_id, added_at + FROM project_notes + WHERE project_id = $1 + ORDER BY added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectNote + for rows.Next() { + var pn models.ProjectNote + if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { + return nil, err + } + result = append(result, pn) + } + return result, rows.Err() +} + +// ── Access Check ──────────────────────────── + +func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) { + q := ` + SELECT EXISTS( + SELECT 1 FROM projects p + WHERE p.id = $1 AND ( + (p.scope = 'personal' AND p.owner_id = $2) + OR p.scope = 'global'` + + args := []interface{}{projectID, userID} + + if len(teamIDs) > 0 { + q += fmt.Sprintf(` + OR (p.scope = 'team' AND p.team_id = ANY($%d))`, len(args)+1) + args = append(args, pq.Array(teamIDs)) + } + q += `))` + + var ok bool + err := DB.QueryRowContext(ctx, q, args...).Scan(&ok) + return ok, err +} + +func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) { + var projectID string + err := DB.QueryRowContext(ctx, ` + SELECT project_id FROM project_channels WHERE channel_id = $1`, + channelID).Scan(&projectID) + if err == sql.ErrNoRows { + return "", nil // no project — not an error + } + return projectID, err +} + +// ── Query Helper ──────────────────────────── + +func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("queryProjects: %w", err) + } + defer rows.Close() + + var result []models.Project + for rows.Next() { + var p models.Project + var teamID sql.NullString + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, + &p.OwnerID, &teamID, &p.IsArchived, &p.Settings, + &p.CreatedAt, &p.UpdatedAt, + &p.ChannelCount, &p.KBCount, &p.NoteCount, + ); err != nil { + return nil, err + } + p.TeamID = NullableStringPtr(teamID) + result = append(result, p) + } + return result, rows.Err() +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 6bbbb6b..f528aea 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores { Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), Memories: NewMemoryStore(), + Projects: NewProjectStore(), } } diff --git a/server/store/project_interface.go b/server/store/project_interface.go new file mode 100644 index 0000000..3fa633e --- /dev/null +++ b/server/store/project_interface.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + + "git.gobha.me/xcaliber/chat-switchboard/models" +) + +// ========================================= +// PROJECT STORE (v0.19.0) +// ========================================= + +type ProjectStore interface { + // CRUD + Create(ctx context.Context, p *models.Project) error + GetByID(ctx context.Context, id string) (*models.Project, error) + Update(ctx context.Context, id string, patch models.ProjectPatch) error + Delete(ctx context.Context, id string) error + + // Listing + ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) + + // Channel association (atomic move: removes old project if any) + AddChannel(ctx context.Context, projectID, channelID string, position int) error + RemoveChannel(ctx context.Context, projectID, channelID string) error + ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) + ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error + + // KB association + AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error + RemoveKB(ctx context.Context, projectID, kbID string) error + ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) + + // Note association + AddNote(ctx context.Context, projectID, noteID string) error + RemoveNote(ctx context.Context, projectID, noteID string) error + ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) + + // Access check + UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) + + // Get project ID for a channel (used during note auto-association) + GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) + + // Get KB IDs bound to a project (used for virtual injection at completion) + GetKBIDs(ctx context.Context, projectID string) ([]string, error) +} diff --git a/server/store/sqlite/project.go b/server/store/sqlite/project.go new file mode 100644 index 0000000..3ca320a --- /dev/null +++ b/server/store/sqlite/project.go @@ -0,0 +1,435 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// ── ProjectStore ─────────────────────────── + +type ProjectStore struct{} + +func NewProjectStore() *ProjectStore { return &ProjectStore{} } + +// ── CRUD ──────────────────────────────────── + +func (s *ProjectStore) Create(ctx context.Context, p *models.Project) error { + p.ID = store.NewID() + now := time.Now().UTC() + p.CreatedAt = now + p.UpdatedAt = now + _, err := DB.ExecContext(ctx, ` + INSERT INTO projects (id, name, description, color, icon, scope, owner_id, team_id, + is_archived, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Description, p.Color, p.Icon, p.Scope, + p.OwnerID, models.NullString(p.TeamID), + boolToInt(p.IsArchived), ToJSON(p.Settings), + now.Format(timeFmt), now.Format(timeFmt), + ) + return err +} + +func (s *ProjectStore) GetByID(ctx context.Context, id string) (*models.Project, error) { + var p models.Project + var teamID sql.NullString + var archived int + err := DB.QueryRowContext(ctx, ` + SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, + p.owner_id, p.team_id, p.is_archived, p.settings, + 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) + FROM projects p + WHERE p.id = ?`, id).Scan( + &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, + &p.OwnerID, &teamID, &archived, &p.Settings, + st(&p.CreatedAt), st(&p.UpdatedAt), + &p.ChannelCount, &p.KBCount, &p.NoteCount, + ) + if err != nil { + return nil, err + } + p.IsArchived = archived != 0 + p.TeamID = NullableStringPtr(teamID) + return &p, nil +} + +func (s *ProjectStore) Update(ctx context.Context, id string, patch models.ProjectPatch) error { + sets := []string{} + args := []interface{}{} + + add := func(col string, val interface{}) { + sets = append(sets, col+" = ?") + args = append(args, val) + } + + if patch.Name != nil { + add("name", *patch.Name) + } + if patch.Description != nil { + add("description", *patch.Description) + } + if patch.Color != nil { + add("color", *patch.Color) + } + if patch.Icon != nil { + add("icon", *patch.Icon) + } + if patch.IsArchived != nil { + add("is_archived", boolToInt(*patch.IsArchived)) + } + if len(sets) == 0 { + return nil + } + + query := "UPDATE projects SET " + for i, s := range sets { + if i > 0 { + query += ", " + } + query += s + } + query += " WHERE id = ?" + args = append(args, id) + + res, err := DB.ExecContext(ctx, query, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) Delete(ctx context.Context, id string) error { + // Clear denormalized project_id on channels first (SQLite FK cascade + // handles junction tables, but ON DELETE SET NULL requires PRAGMA + // foreign_keys = ON which may not cascade reliably on all builds). + DB.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE project_id = ?`, id) + + res, err := DB.ExecContext(ctx, "DELETE FROM projects WHERE id = ?", id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +// ── Listing ───────────────────────────────── + +func (s *ProjectStore) ListForUser(ctx context.Context, userID string, teamIDs []string, includeArchived bool) ([]models.Project, error) { + q := ` + SELECT p.id, p.name, p.description, p.color, p.icon, p.scope, + p.owner_id, p.team_id, p.is_archived, p.settings, + 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) + FROM projects p + WHERE ( + (p.scope = 'personal' AND p.owner_id = ?) + OR p.scope = 'global'` + + args := []interface{}{userID} + + for _, tid := range teamIDs { + q += ` OR (p.scope = 'team' AND p.team_id = ?)` + args = append(args, tid) + } + q += `)` + + if !includeArchived { + q += ` AND p.is_archived = 0` + } + q += ` ORDER BY p.name` + + return s.queryProjects(ctx, q, args...) +} + +// ── Channel Association ───────────────────── + +func (s *ProjectStore) AddChannel(ctx context.Context, projectID, channelID string, position int) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Remove existing (atomic move) + tx.ExecContext(ctx, `DELETE FROM project_channels WHERE channel_id = ?`, channelID) + + _, err = tx.ExecContext(ctx, ` + INSERT INTO project_channels (project_id, channel_id, position) + VALUES (?, ?, ?)`, + projectID, channelID, position) + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = ? WHERE id = ?`, + projectID, channelID) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *ProjectStore) RemoveChannel(ctx context.Context, projectID, channelID string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, ` + DELETE FROM project_channels WHERE project_id = ? AND channel_id = ?`, + projectID, channelID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + + _, err = tx.ExecContext(ctx, `UPDATE channels SET project_id = NULL WHERE id = ?`, channelID) + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *ProjectStore) ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, channel_id, position, COALESCE(folder, ''), added_at + FROM project_channels + WHERE project_id = ? + ORDER BY position, added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectChannel + for rows.Next() { + var pc models.ProjectChannel + if err := rows.Scan(&pc.ProjectID, &pc.ChannelID, &pc.Position, &pc.Folder, &pc.AddedAt); err != nil { + return nil, err + } + result = append(result, pc) + } + return result, rows.Err() +} + +func (s *ProjectStore) ReorderChannels(ctx context.Context, projectID string, channelIDs []string) error { + tx, err := DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + for i, chID := range channelIDs { + _, err := tx.ExecContext(ctx, ` + UPDATE project_channels SET position = ? + WHERE project_id = ? AND channel_id = ?`, + i, projectID, chID) + if err != nil { + return err + } + } + return tx.Commit() +} + +// ── KB Association ────────────────────────── + +func (s *ProjectStore) AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO project_knowledge_bases (project_id, kb_id, auto_search) + VALUES (?, ?, ?) + ON CONFLICT (project_id, kb_id) DO UPDATE SET auto_search = excluded.auto_search`, + projectID, kbID, boolToInt(autoSearch)) + return err +} + +func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) error { + res, err := DB.ExecContext(ctx, ` + DELETE FROM project_knowledge_bases WHERE project_id = ? AND kb_id = ?`, + projectID, kbID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, kb_id, auto_search, added_at + FROM project_knowledge_bases + WHERE project_id = ? + ORDER BY added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectKB + for rows.Next() { + var pkb models.ProjectKB + var autoSearch int + if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt); err != nil { + return nil, err + } + pkb.AutoSearch = autoSearch != 0 + result = append(result, pkb) + } + return result, rows.Err() +} + +func (s *ProjectStore) GetKBIDs(ctx context.Context, projectID string) ([]string, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT kb_id FROM project_knowledge_bases WHERE project_id = ?`, projectID) + 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) + } + return ids, rows.Err() +} + +// ── Note Association ──────────────────────── + +func (s *ProjectStore) AddNote(ctx context.Context, projectID, noteID string) error { + _, err := DB.ExecContext(ctx, ` + INSERT INTO project_notes (project_id, note_id) + VALUES (?, ?) + ON CONFLICT DO NOTHING`, + projectID, noteID) + return err +} + +func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) error { + res, err := DB.ExecContext(ctx, ` + DELETE FROM project_notes WHERE project_id = ? AND note_id = ?`, + projectID, noteID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { + rows, err := DB.QueryContext(ctx, ` + SELECT project_id, note_id, added_at + FROM project_notes + WHERE project_id = ? + ORDER BY added_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []models.ProjectNote + for rows.Next() { + var pn models.ProjectNote + if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { + return nil, err + } + result = append(result, pn) + } + return result, rows.Err() +} + +// ── Access Check ──────────────────────────── + +func (s *ProjectStore) UserCanAccess(ctx context.Context, userID, projectID string, teamIDs []string) (bool, error) { + q := ` + SELECT EXISTS( + SELECT 1 FROM projects p + WHERE p.id = ? AND ( + (p.scope = 'personal' AND p.owner_id = ?) + OR p.scope = 'global'` + + args := []interface{}{projectID, userID} + + for _, tid := range teamIDs { + q += ` OR (p.scope = 'team' AND p.team_id = ?)` + args = append(args, tid) + } + q += `))` + + var ok bool + err := DB.QueryRowContext(ctx, q, args...).Scan(&ok) + return ok, err +} + +func (s *ProjectStore) GetProjectIDForChannel(ctx context.Context, channelID string) (string, error) { + var projectID string + err := DB.QueryRowContext(ctx, ` + SELECT project_id FROM project_channels WHERE channel_id = ?`, + channelID).Scan(&projectID) + if err == sql.ErrNoRows { + return "", nil + } + return projectID, err +} + +// ── Helpers ───────────────────────────────── + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (s *ProjectStore) queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) { + rows, err := DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("queryProjects: %w", err) + } + defer rows.Close() + + var result []models.Project + for rows.Next() { + var p models.Project + var teamID sql.NullString + var archived int + if err := rows.Scan( + &p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope, + &p.OwnerID, &teamID, &archived, &p.Settings, + st(&p.CreatedAt), st(&p.UpdatedAt), + &p.ChannelCount, &p.KBCount, &p.NoteCount, + ); err != nil { + return nil, err + } + p.IsArchived = archived != 0 + p.TeamID = NullableStringPtr(teamID) + result = append(result, p) + } + return result, rows.Err() +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index a1676fc..678ebe9 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores { Groups: NewGroupStore(), ResourceGrants: NewResourceGrantStore(), Memories: NewMemoryStore(), + Projects: NewProjectStore(), } } diff --git a/server/tools/kbsearch.go b/server/tools/kbsearch.go index 66844ac..e33a116 100644 --- a/server/tools/kbsearch.go +++ b/server/tools/kbsearch.go @@ -83,6 +83,24 @@ func (t *kbSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, ar return "", fmt.Errorf("failed to look up active knowledge bases: %w", err) } + // Also include project-bound KBs (v0.19.0) + if t.stores.Projects != nil { + projID, _ := t.stores.Projects.GetProjectIDForChannel(ctx, execCtx.ChannelID) + if projID != "" { + projKBIDs, _ := t.stores.Projects.GetKBIDs(ctx, projID) + projSet := make(map[string]bool, len(kbIDs)) + for _, id := range kbIDs { + projSet[id] = true + } + for _, id := range projKBIDs { + if !projSet[id] { + kbIDs = append(kbIDs, id) + projSet[id] = true + } + } + } + } + // Also include personal KBs (always available to owner, even if not linked to channel) personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID) if err == nil { diff --git a/src/css/styles.css b/src/css/styles.css index 2e1a36f..6345f08 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -2364,3 +2364,93 @@ select option { background: var(--bg-surface); color: var(--text); } .admin-main { padding: 1rem; } .admin-cat { padding: 10px 12px; font-size: 13px; } } + +/* ========================================= + PROJECTS (v0.19.0) + ========================================= */ + +.project-group { + margin-bottom: 2px; + border-radius: var(--radius); + transition: background 0.15s; +} +.project-group.drag-over { + background: color-mix(in srgb, var(--accent) 12%, transparent); + outline: 1px dashed var(--accent); + outline-offset: -1px; + border-radius: var(--radius); +} + +.project-header { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; cursor: pointer; user-select: none; + border-radius: var(--radius); + transition: background var(--transition); +} +.project-header:hover { background: var(--bg-hover); } + +.project-arrow { + font-size: 10px; color: var(--text-3); width: 12px; text-align: center; flex-shrink: 0; +} +.project-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + background: var(--text-3); display: inline-block; +} +.project-name { + font-size: 12px; font-weight: 600; color: var(--text-2); + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.project-count { + font-size: 10px; color: var(--text-3); flex-shrink: 0; margin-left: auto; +} +.project-menu-btn { + opacity: 0; background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 14px; padding: 0 4px; border-radius: 4px; + transition: opacity var(--transition), background var(--transition); + line-height: 1; flex-shrink: 0; +} +.project-header:hover .project-menu-btn { opacity: 1; } +.project-header:hover .project-count { display: none; } +.project-menu-btn:hover { background: var(--bg-raised); color: var(--text); } + +.project-empty { + font-size: 11px; color: var(--text-3); padding: 4px 10px 8px 28px; + font-style: italic; +} + +.sidebar.collapsed .project-group { display: none; } +.sidebar.collapsed .recent-section .chat-group-label { display: none; } + +/* Recent section drop target */ +.recent-section.drag-over { + background: color-mix(in srgb, var(--accent) 8%, transparent); + border-radius: var(--radius); +} + +/* Dragging state */ +.chat-item.dragging { opacity: 0.4; } +.chat-item[draggable="true"] { cursor: grab; } +.chat-item[draggable="true"]:active { cursor: grabbing; } + +/* Context menu */ +.project-ctx-menu { + position: fixed; z-index: 9999; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow); + padding: 4px; min-width: 160px; max-width: 220px; +} +.ctx-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 10px; border: none; background: none; + color: var(--text-2); font-size: 13px; cursor: pointer; + border-radius: calc(var(--radius) - 2px); text-align: left; + white-space: nowrap; overflow: hidden; +} +.ctx-item:hover { background: var(--bg-hover); color: var(--text); } +.ctx-item.ctx-danger:hover { background: var(--danger-bg, #fef2f2); color: var(--danger, #ef4444); } +.ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 12px; } +.ctx-divider { height: 1px; background: var(--border); margin: 3px 0; } +.ctx-hint { font-size: 11px; color: var(--text-3); padding: 6px 10px; } + +/* Time labels inside project-grouped sidebar */ +.chat-time-label { padding-left: 12px; font-size: 10px; } diff --git a/src/index.html b/src/index.html index 2fc0a8d..65aeeff 100644 --- a/src/index.html +++ b/src/index.html @@ -53,6 +53,10 @@ New Chat + `; + items += '
'; + } + + App.projects.forEach(p => { + if (p.id === chat.projectId) return; // skip current + const dot = p.color ? `` : ''; + items += ``; + }); + + if (App.projects.length === 0 && !chat.projectId) { + items += '
No projects yet
'; + } + + items += '
'; + items += ``; + + menu.innerHTML = items; + + // Position near cursor + menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px'; + + document.body.appendChild(menu); + _ctxMenu = menu; + + // Dismiss on click outside + setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); +} + +function dismissChatContextMenu() { + if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; } +} + +async function createProjectAndMove(chatId) { + const name = prompt('New project name:'); + if (!name || !name.trim()) return; + try { + const p = await API.createProject({ name: name.trim() }); + App.projects.push({ + id: p.id, name: p.name, description: '', color: null, icon: null, + channelCount: 0, kbCount: 0, noteCount: 0, isArchived: false, + }); + await moveChatToProject(chatId, p.id); + } catch (e) { + UI.toast('Failed to create project', 'error'); + } +} + +// ── Drag and Drop ─────────────────────────── + +function onChatDragStart(e, chatId) { + e.dataTransfer.setData('text/plain', chatId); + e.dataTransfer.effectAllowed = 'move'; + e.target.classList.add('dragging'); +} + +function onChatDragEnd(e) { + e.target.classList.remove('dragging'); + document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over')); +} + +function onProjectDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + e.currentTarget.classList.add('drag-over'); +} + +function onProjectDragLeave(e) { + e.currentTarget.classList.remove('drag-over'); +} + +async function onProjectDrop(e, projectId) { + e.preventDefault(); + e.currentTarget.classList.remove('drag-over'); + const chatId = e.dataTransfer.getData('text/plain'); + if (!chatId) return; + await moveChatToProject(chatId, projectId); +} + +async function onRecentDrop(e) { + e.preventDefault(); + e.currentTarget.classList.remove('drag-over'); + const chatId = e.dataTransfer.getData('text/plain'); + if (!chatId) return; + await moveChatToProject(chatId, null); +} + +// ── Project Header Menu ───────────────────── + +function showProjectMenu(e, projectId) { + e.preventDefault(); + e.stopPropagation(); + dismissChatContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'project-ctx-menu'; + menu.innerHTML = ` + + +
+ `; + + menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px'; + + document.body.appendChild(menu); + _ctxMenu = menu; + setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); +} diff --git a/src/js/ui-core.js b/src/js/ui-core.js index a2888d7..681c2e2 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -272,16 +272,84 @@ const UI = { ); } - if (chats.length === 0 && query) { + const projects = App.projects || []; + + if (chats.length === 0 && projects.length === 0 && query) { el.innerHTML = ``; return; } - if (chats.length === 0) { + if (chats.length === 0 && projects.length === 0) { el.innerHTML = ''; return; } - // Group by time + let html = ''; + + // ── Chat item renderer ────────────────── + const renderChatItem = (c) => { + const time = _relativeTime(c.updatedAt); + return ` +
+ ${esc(c.title)} + ${time} + +
`; + }; + + // ── Project groups ────────────────────── + if (projects.length > 0) { + projects.forEach(proj => { + const projChats = chats.filter(c => c.projectId === proj.id); + if (projChats.length === 0 && query) return; // hide empty projects during search + const isCollapsed = App.collapsedProjects[proj.id]; + const colorDot = proj.color + ? `` + : ''; + const arrow = isCollapsed ? '▸' : '▾'; + + html += `
+
+ ${arrow} + ${colorDot} + ${esc(proj.name)} + ${projChats.length} + +
`; + + if (!isCollapsed) { + if (projChats.length === 0) { + html += '
Drop chats here
'; + } else { + projChats.forEach(c => { html += renderChatItem(c); }); + } + } + html += '
'; + }); + } + + // ── Recent (unassigned) chats ─────────── + const recentChats = chats.filter(c => !c.projectId); + + if (recentChats.length > 0 || projects.length > 0) { + // Only show "Recent" label if there are also projects + if (projects.length > 0) { + html += `
+
Recent
`; + } + } + + // Group recent by time const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today - 86400000); @@ -289,7 +357,7 @@ const UI = { const groups = { today: [], yesterday: [], week: [], older: [] }; - chats.forEach(c => { + recentChats.forEach(c => { const d = new Date(c.updatedAt); if (d >= today) groups.today.push(c); else if (d >= yesterday) groups.yesterday.push(c); @@ -297,20 +365,16 @@ const UI = { else groups.older.push(c); }); - let html = ''; - const renderGroup = (label, chats) => { - if (chats.length === 0) return; - html += `
${label}
`; - chats.forEach(c => { - const time = _relativeTime(c.updatedAt); - html += ` -
- ${esc(c.title)} - ${time} - -
`; - }); + const renderGroup = (label, items) => { + if (items.length === 0) return; + // Only show time labels when there are no projects (preserve original look) + // or when inside the Recent section + if (projects.length > 0) { + html += `
${label}
`; + } else { + html += `
${label}
`; + } + items.forEach(c => { html += renderChatItem(c); }); }; renderGroup('Today', groups.today); @@ -318,6 +382,13 @@ const UI = { renderGroup('Previous 7 days', groups.week); renderGroup('Older', groups.older); + if (projects.length > 0 && recentChats.length > 0) { + html += '
'; // close recent-section + } else if (projects.length > 0 && recentChats.length === 0) { + // Close the recent-section div we opened + html += ''; + } + el.innerHTML = html; },