Changeset 0.19.0.1 (#82)
This commit is contained in:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,6 +2,68 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
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=<uuid>` 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
|
## [0.18.1] — 2026-02-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
343
README.md
343
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
|
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.
|
||||||
except 1 and 2 which must be applied together.
|
|
||||||
|
|
||||||
---
|
## Quick Start
|
||||||
|
|
||||||
## Patch 1+2: Harden JSON scanning + SafeJSON (P0 — fixes broken chat list)
|
```bash
|
||||||
|
# Clone and start
|
||||||
|
git clone <repo-url> && cd chat-switchboard
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
**Root cause:** Corrupt `\x02` byte in a channel's `settings` column.
|
# Access at http://localhost:3000
|
||||||
`scanJSON` accepts it, Gin's `c.JSON()` starts writing 200 + headers,
|
# Default admin: admin / admin
|
||||||
`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
|
## Features
|
||||||
with `SafeJSON(c, http.StatusOK, paginatedResponse{`
|
|
||||||
|
|
||||||
c. Replace `c.JSON(http.StatusCreated, ch)` at end of CreateChannel
|
- **Multi-Provider**: Anthropic, OpenAI, OpenRouter, Venice AI — with BYOK support
|
||||||
with `SafeJSON(c, http.StatusCreated, ch)`
|
- **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
|
||||||
|
|
||||||
d. Replace `c.JSON(http.StatusOK, ch)` at end of GetChannel
|
## Architecture
|
||||||
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:
|
│ Browser │────▶│ nginx (port 80) │
|
||||||
```sql
|
│ (vanilla JS)│◀────│ ├─ /api/* → Go backend:8080 │
|
||||||
-- Find it
|
└─────────────┘ │ ├─ /ws → WebSocket proxy │
|
||||||
SELECT id, title, length(settings::text),
|
│ └─ /* → static files │
|
||||||
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]';
|
│ Go Backend │
|
||||||
|
│ ├─ handlers/ │
|
||||||
-- Fix it (reset to empty settings)
|
│ ├─ store/postgres/ │
|
||||||
UPDATE channels SET settings = '{}'
|
│ ├─ store/sqlite/ │
|
||||||
WHERE settings IS NOT NULL
|
│ ├─ providers/ │
|
||||||
AND settings::text ~ '[^\x20-\x7E\t\n\r]';
|
│ └─ capabilities/ │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ PostgreSQL 16 or │
|
||||||
|
│ SQLite (embedded) │
|
||||||
|
└───────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**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.
|
||||||
|
|
||||||
## Patch 3: Service Worker scheme guard (P1 — console noise)
|
**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`.
|
||||||
|
|
||||||
**Root cause:** SW fetch handler tries to cache `chrome-extension://`
|
## Configuration
|
||||||
URLs. `Cache.put()` rejects non-http(s) schemes.
|
|
||||||
|
|
||||||
**File:** `src/sw.js`
|
All configuration via environment variables. See `server/.env.example` for the full list.
|
||||||
|
|
||||||
Add scheme guard after `const url = new URL(event.request.url);`:
|
| Variable | Default | Description |
|
||||||
```js
|
|----------|---------|-------------|
|
||||||
// Only handle http/https — ignore chrome-extension://, moz-extension://, etc.
|
| `PORT` | `8080` | Backend listen port |
|
||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
| `BASE_PATH` | ` ` | URL prefix (e.g. `/chat`) |
|
||||||
return;
|
| `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) |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Three Docker images support different scenarios:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
### Docker Compose (development — unified image)
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
Or copy `patches/src/sw.js`.
|
### Kubernetes (split images)
|
||||||
|
|
||||||
---
|
Build and push both images:
|
||||||
|
|
||||||
## Patch 4: Settings debounce (P2 — cosmetic waste)
|
```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 .
|
||||||
|
```
|
||||||
|
|
||||||
Not included as a patch file — straightforward to implement:
|
**Backend deployment:**
|
||||||
- In `app.js` init sequence, debounce `API.updateSettings()` calls
|
|
||||||
- A 100ms `setTimeout` wrapper that coalesces multiple rapid saves
|
|
||||||
|
|
||||||
---
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
## Future work (not in this hotfix)
|
**Frontend deployment:**
|
||||||
|
|
||||||
- Apply `SafeJSON` to extension list endpoints (lines 47, 115 of
|
```yaml
|
||||||
extensions.go) — same vulnerability pattern, lower risk since
|
containers:
|
||||||
extension data is seeded from known-good JSON.
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
- Harden `JSONMap.Scan` in `models/models.go` to warn-and-default
|
**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`.
|
||||||
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.
|
|
||||||
|
|
||||||
- Startup JSON integrity check (design doc §17.1 Layer 4) — scan
|
### Path-Based Routing
|
||||||
all JSONB columns on boot, log warnings for corrupt data. Not
|
|
||||||
blocking for hotfix but valuable for post-upgrade diagnostics.
|
|
||||||
|
|
||||||
- JWT redaction in Gin log formatter for WebSocket URLs.
|
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 <token>` 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=<jwt>` — 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.
|
||||||
|
|||||||
@@ -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.
|
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)
|
The platform's existing primitives (teams, personas, channels, tools, notes)
|
||||||
compose into a workflow engine where the channel is the execution context
|
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
|
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
|
## Schema Migration
|
||||||
|
|
||||||
@@ -276,6 +302,7 @@ src/
|
|||||||
│ ├── attachments.js # File upload, paste-to-file, lightbox
|
│ ├── attachments.js # File upload, paste-to-file, lightbox
|
||||||
│ ├── notes.js # Notes panel CRUD + graph + daily notes
|
│ ├── notes.js # Notes panel CRUD + graph + daily notes
|
||||||
│ ├── note-graph.js # Canvas force-directed graph visualization
|
│ ├── note-graph.js # Canvas force-directed graph visualization
|
||||||
|
│ ├── projects-ui.js # Project sidebar, context menu, drag-and-drop
|
||||||
│ ├── knowledge.js # Knowledge base UI
|
│ ├── knowledge.js # Knowledge base UI
|
||||||
│ ├── memory-ui.js # Memory settings tab + admin review panel
|
│ ├── memory-ui.js # Memory settings tab + admin review panel
|
||||||
│ └── __tests__/ # Node.js test suite (node --test)
|
│ └── __tests__/ # Node.js test suite (node --test)
|
||||||
|
|||||||
1151
docs/DESIGN-0.19.0.md
Normal file
1151
docs/DESIGN-0.19.0.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.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)
|
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
|
Organizational containers that group related conversations, KBs, and
|
||||||
notes into a single workspace with shared access controls. Plus the
|
notes into a single workspace with shared access controls.
|
||||||
notification infrastructure that makes collaboration real-time.
|
|
||||||
|
|
||||||
Depends on: user groups (v0.16.0), knowledge bases (v0.14.0).
|
Depends on: user groups (v0.16.0), knowledge bases (v0.14.0).
|
||||||
|
|
||||||
**Data Model**
|
**Data Model**
|
||||||
- [ ] `projects` table: `id`, `name`, `description`, `scope` (personal, team, global), `owner_id`, `team_id`, `settings` (JSONB), `created_at`, `updated_at`
|
- [x] `projects` table: `id`, `name`, `description`, `color`, `icon`, `scope` (personal/team/global), `owner_id`, `team_id`, `is_archived`, `settings` (JSONB)
|
||||||
- [ ] `project_resources` table: `project_id`, `resource_type` (channel, knowledge_base, note), `resource_id`, `added_at`
|
- [x] `project_channels` junction: ordered membership with UNIQUE `channel_id`, denormalized FK on `channels.project_id`
|
||||||
- [ ] Projects use the same grant model (v0.16.0): team_only / global / groups
|
- [x] `project_knowledge_bases` junction: with `auto_search` flag
|
||||||
|
- [x] `project_notes` junction
|
||||||
|
- [x] `resource_grants` CHECK extended to include `'project'`
|
||||||
|
|
||||||
**Features**
|
**Features**
|
||||||
- [ ] Channels can belong to a project (optional `project_id` FK)
|
- [x] Channels can belong to a project (optional `project_id` FK)
|
||||||
- [ ] KBs can belong to a project → auto-linked to all channels in the project
|
- [x] KBs can belong to a project → auto-linked to all channels in the project (BuildKBHint + kbsearch)
|
||||||
- [ ] Notes can belong to a project → visible to all project members
|
- [x] Notes can belong to a project → auto-associated when created from project channel
|
||||||
- [ ] Project-level Persona default: all new channels in the project start with this Persona
|
- [x] KB resolution chain: Persona → Project → Channel → Personal
|
||||||
- [ ] Project sidebar section: grouped view of conversations with folder-like nesting
|
- [x] Project sidebar section: collapsible groups above time-based Recent section
|
||||||
|
|
||||||
**UI**
|
**UI**
|
||||||
- [ ] Sidebar: projects as collapsible groups above/integrated with chat history
|
- [x] Sidebar: projects as collapsible groups with color dots, counts, options menu
|
||||||
- [ ] Project creation dialog (name, description, default Persona, default KBs)
|
- [x] Right-click context menu: move chat to project / remove / create-and-assign
|
||||||
- [ ] Drag channels/notes into projects
|
- [x] Drag-and-drop channels between project groups and Recent
|
||||||
- [ ] Folders within projects (lightweight — just a `path` column on project_resources)
|
- [x] New Project button in split dropdown
|
||||||
- [ ] Project settings: manage resources, access, defaults
|
- [x] Channel list `project_id` filter (`?project_id=uuid` or `?project_id=none`)
|
||||||
|
- [x] Collapse state persisted in localStorage
|
||||||
|
|
||||||
**Enterprise Use**
|
**Deferred to later versions:**
|
||||||
- [ ] Team admins create projects for their teams
|
- Project creation dialog (name, description, default Persona, default KBs) — using prompt() for now
|
||||||
- [ ] Project templates: pre-configured with specific Personas, KBs, and settings
|
- Project detail panel (KB + note management within a project)
|
||||||
- [ ] Cross-team projects via group grants
|
- 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**
|
||||||
- [ ] `notifications` table: `id`, `user_id`, `type`, `title`, `body`, `resource_type`, `resource_id`, `is_read`, `created_at`
|
- [ ] `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
|
- [ ] Admin settings: enable/disable email, SMTP config, default notification preferences
|
||||||
- [ ] User preferences: per-type toggles (in-app, email, off)
|
- [ ] User preferences: per-type toggles (in-app, email, off)
|
||||||
|
|
||||||
---
|
**@mention Routing**
|
||||||
|
|
||||||
## 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 parsing in messages (users and AI models)
|
- [ ] @mention parsing in messages (users and AI models)
|
||||||
- [ ] Resolve mentions against `channel_models`
|
- [ ] Resolve mentions against `channel_models`
|
||||||
- [ ] Multi-model channels: fire completions per mentioned model
|
- [ ] Multi-model channels: fire completions per mentioned model
|
||||||
|
|||||||
@@ -195,11 +195,13 @@ check_table "kb_documents"
|
|||||||
check_table "kb_chunks"
|
check_table "kb_chunks"
|
||||||
check_table "channel_knowledge_bases"
|
check_table "channel_knowledge_bases"
|
||||||
|
|
||||||
# ── Dropped tables (v0.16.0 cleanup) ────
|
# ── Projects (v0.19.0) ───────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "Dropped tables (v0.16.0 cleanup):"
|
echo "Projects (v0.19.0):"
|
||||||
check_table_absent "projects"
|
check_table "projects"
|
||||||
check_table_absent "project_channels"
|
check_table "project_channels"
|
||||||
|
check_table "project_knowledge_bases"
|
||||||
|
check_table "project_notes"
|
||||||
|
|
||||||
# ── Users (vault) ────────────────────────
|
# ── Users (vault) ────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -266,6 +268,7 @@ echo "Channels & Messages:"
|
|||||||
check_column "channels" "settings"
|
check_column "channels" "settings"
|
||||||
check_column "channels" "folder_id"
|
check_column "channels" "folder_id"
|
||||||
check_column "channels" "team_id"
|
check_column "channels" "team_id"
|
||||||
|
check_column "channels" "project_id"
|
||||||
check_column "messages" "parent_id"
|
check_column "messages" "parent_id"
|
||||||
check_column "messages" "sibling_index"
|
check_column "messages" "sibling_index"
|
||||||
check_column "messages" "tool_calls"
|
check_column "messages" "tool_calls"
|
||||||
|
|||||||
157
server/database/migrations/006_v0190_projects.sql
Normal file
157
server/database/migrations/006_v0190_projects.sql
Normal file
@@ -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
|
||||||
|
$$;
|
||||||
131
server/database/migrations/sqlite/005_v0190_projects.sql
Normal file
131
server/database/migrations/sqlite/005_v0190_projects.sql
Normal file
@@ -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;
|
||||||
@@ -266,10 +266,14 @@ func TruncateAll(t *testing.T) {
|
|||||||
"channel_members",
|
"channel_members",
|
||||||
"channel_knowledge_bases",
|
"channel_knowledge_bases",
|
||||||
"persona_knowledge_bases",
|
"persona_knowledge_bases",
|
||||||
|
"project_notes",
|
||||||
|
"project_knowledge_bases",
|
||||||
|
"project_channels",
|
||||||
"kb_chunks",
|
"kb_chunks",
|
||||||
"kb_documents",
|
"kb_documents",
|
||||||
"knowledge_bases",
|
"knowledge_bases",
|
||||||
"channels",
|
"channels",
|
||||||
|
"projects",
|
||||||
"user_model_settings",
|
"user_model_settings",
|
||||||
"model_catalog",
|
"model_catalog",
|
||||||
"persona_grants",
|
"persona_grants",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type channelResponse struct {
|
|||||||
IsArchived bool `json:"is_archived"`
|
IsArchived bool `json:"is_archived"`
|
||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
Folder *string `json:"folder"`
|
Folder *string `json:"folder"`
|
||||||
|
ProjectID *string `json:"project_id,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Settings json.RawMessage `json:"settings,omitempty"`
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
@@ -138,6 +139,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
folder := c.Query("folder")
|
folder := c.Query("folder")
|
||||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
projectFilter := c.Query("project_id") // "uuid" or "none"
|
||||||
|
|
||||||
// Count total
|
// Count total
|
||||||
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
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+"%")
|
countArgs = append(countArgs, "%"+search+"%")
|
||||||
argN++
|
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
|
var total int
|
||||||
if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil {
|
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
|
// Fetch channels with message count
|
||||||
query := `
|
query := `
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
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,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
@@ -196,6 +206,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
args = append(args, "%"+search+"%")
|
args = append(args, "%"+search+"%")
|
||||||
argN++
|
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)
|
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)
|
args = append(args, perPage, offset)
|
||||||
@@ -213,7 +230,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
var tags []string
|
var tags []string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&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),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -278,11 +295,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
// Read back the row
|
// Read back the row
|
||||||
err = database.DB.QueryRow(`
|
err = database.DB.QueryRow(`
|
||||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
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
|
created_at, updated_at
|
||||||
FROM channels WHERE id = ?`, id).Scan(
|
FROM channels WHERE id = ?`, id).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&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,
|
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
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,
|
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||||
req.Folder, pq.Array(req.Tags),
|
req.Folder, pq.Array(req.Tags),
|
||||||
).Scan(
|
).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&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,
|
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -359,7 +377,8 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
var tags []string
|
var tags []string
|
||||||
err := database.DB.QueryRow(database.Q(`
|
err := database.DB.QueryRow(database.Q(`
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
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,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
@@ -369,7 +388,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
WHERE c.id = $1 AND c.user_id = $2
|
WHERE c.id = $1 AND c.user_id = $2
|
||||||
`), channelID, userID).Scan(
|
`), channelID, userID).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&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),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
// Channel-linked KBs
|
||||||
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
|||||||
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
|
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))
|
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
471
server/handlers/projects.go
Normal file
471
server/handlers/projects.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -332,6 +332,24 @@ func main() {
|
|||||||
protected.DELETE("/notes/:id", notes.Delete)
|
protected.DELETE("/notes/:id", notes.Delete)
|
||||||
protected.GET("/notes/:id/backlinks", notes.Backlinks)
|
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)
|
// Attachments (file upload/download)
|
||||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
@@ -515,6 +533,11 @@ func main() {
|
|||||||
// Resource Grants (admin — v0.16.0)
|
// Resource Grants (admin — v0.16.0)
|
||||||
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
|
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
|
||||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
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)
|
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||||
|
|
||||||
// Model Roles
|
// Model Roles
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ type Channel struct {
|
|||||||
IsPinned bool `json:"is_pinned" db:"is_pinned"`
|
IsPinned bool `json:"is_pinned" db:"is_pinned"`
|
||||||
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
||||||
TeamID *string `json:"team_id,omitempty" db:"team_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"`
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,10 +377,53 @@ type Folder struct {
|
|||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
UserID string `json:"user_id" db:"user_id"`
|
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
Description string `json:"description,omitempty" db:"description"`
|
Description string `json:"description,omitempty" db:"description"`
|
||||||
Color string `json:"color,omitempty" db:"color"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Stores struct {
|
|||||||
Groups GroupStore
|
Groups GroupStore
|
||||||
ResourceGrants ResourceGrantStore
|
ResourceGrants ResourceGrantStore
|
||||||
Memories MemoryStore
|
Memories MemoryStore
|
||||||
|
Projects ProjectStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
403
server/store/postgres/project.go
Normal file
403
server/store/postgres/project.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
ResourceGrants: NewResourceGrantStore(),
|
ResourceGrants: NewResourceGrantStore(),
|
||||||
Memories: NewMemoryStore(),
|
Memories: NewMemoryStore(),
|
||||||
|
Projects: NewProjectStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
server/store/project_interface.go
Normal file
47
server/store/project_interface.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
435
server/store/sqlite/project.go
Normal file
435
server/store/sqlite/project.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Groups: NewGroupStore(),
|
Groups: NewGroupStore(),
|
||||||
ResourceGrants: NewResourceGrantStore(),
|
ResourceGrants: NewResourceGrantStore(),
|
||||||
Memories: NewMemoryStore(),
|
Memories: NewMemoryStore(),
|
||||||
|
Projects: NewProjectStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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)
|
// Also include personal KBs (always available to owner, even if not linked to channel)
|
||||||
personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID)
|
personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -2364,3 +2364,93 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.admin-main { padding: 1rem; }
|
.admin-main { padding: 1rem; }
|
||||||
.admin-cat { padding: 10px 12px; font-size: 13px; }
|
.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; }
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
New Chat
|
New Chat
|
||||||
</button>
|
</button>
|
||||||
|
<button class="split-dropdown-item" onclick="createProject()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
Group Chat <span class="dd-hint">soon</span>
|
Group Chat <span class="dd-hint">soon</span>
|
||||||
@@ -1126,6 +1130,7 @@
|
|||||||
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/memory-ui.js?v=%%APP_VERSION%%"></script>
|
<script src="js/memory-ui.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
|
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/projects-ui.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
|
|||||||
@@ -161,6 +161,40 @@ const API = {
|
|||||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||||
|
|
||||||
|
// Projects (v0.19.0)
|
||||||
|
listProjects(includeArchived) {
|
||||||
|
const q = includeArchived ? '?include_archived=true' : '';
|
||||||
|
return this._get(`/api/v1/projects${q}`);
|
||||||
|
},
|
||||||
|
createProject(data) { return this._post('/api/v1/projects', data); },
|
||||||
|
getProject(id) { return this._get(`/api/v1/projects/${id}`); },
|
||||||
|
updateProject(id, data) { return this._put(`/api/v1/projects/${id}`, data); },
|
||||||
|
deleteProject(id) { return this._del(`/api/v1/projects/${id}`); },
|
||||||
|
addChannelToProject(projectId, channelId, position) {
|
||||||
|
return this._post(`/api/v1/projects/${projectId}/channels`, { channel_id: channelId, position: position || 0 });
|
||||||
|
},
|
||||||
|
removeChannelFromProject(projectId, channelId) {
|
||||||
|
return this._del(`/api/v1/projects/${projectId}/channels/${channelId}`);
|
||||||
|
},
|
||||||
|
reorderProjectChannels(projectId, channelIds) {
|
||||||
|
return this._put(`/api/v1/projects/${projectId}/channels/reorder`, { channel_ids: channelIds });
|
||||||
|
},
|
||||||
|
listProjectChannels(projectId) { return this._get(`/api/v1/projects/${projectId}/channels`); },
|
||||||
|
addKBToProject(projectId, kbId, autoSearch) {
|
||||||
|
return this._post(`/api/v1/projects/${projectId}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false });
|
||||||
|
},
|
||||||
|
removeKBFromProject(projectId, kbId) {
|
||||||
|
return this._del(`/api/v1/projects/${projectId}/knowledge-bases/${kbId}`);
|
||||||
|
},
|
||||||
|
listProjectKBs(projectId) { return this._get(`/api/v1/projects/${projectId}/knowledge-bases`); },
|
||||||
|
addNoteToProject(projectId, noteId) {
|
||||||
|
return this._post(`/api/v1/projects/${projectId}/notes`, { note_id: noteId });
|
||||||
|
},
|
||||||
|
removeNoteFromProject(projectId, noteId) {
|
||||||
|
return this._del(`/api/v1/projects/${projectId}/notes/${noteId}`);
|
||||||
|
},
|
||||||
|
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||||
|
|
||||||
// ── Messages ─────────────────────────────
|
// ── Messages ─────────────────────────────
|
||||||
|
|
||||||
listMessages(channelId, page = 1, perPage = 200) {
|
listMessages(channelId, page = 1, perPage = 200) {
|
||||||
@@ -429,6 +463,13 @@ const API = {
|
|||||||
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
|
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
|
||||||
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
|
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
|
||||||
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
|
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
|
||||||
|
|
||||||
|
// Admin — Projects (v0.19.0)
|
||||||
|
adminListProjects(includeArchived) {
|
||||||
|
const q = includeArchived ? '?include_archived=true' : '';
|
||||||
|
return this._get(`/api/v1/admin/projects${q}`);
|
||||||
|
},
|
||||||
|
adminDeleteProject(id) { return this._del(`/api/v1/admin/projects/${id}`); },
|
||||||
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
|
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
|
||||||
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
|
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const App = {
|
|||||||
stagedAttachments: [],
|
stagedAttachments: [],
|
||||||
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||||||
storageConfigured: false,
|
storageConfigured: false,
|
||||||
|
projects: [], // v0.19.0: loaded from /api/v1/projects
|
||||||
|
collapsedProjects: {}, // projectId → bool — sidebar collapse state
|
||||||
|
|
||||||
// Find model by composite ID, with fallback to bare model_id match
|
// Find model by composite ID, with fallback to bare model_id match
|
||||||
findModel(id) {
|
findModel(id) {
|
||||||
@@ -179,6 +181,7 @@ async function startApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadChats();
|
await loadChats();
|
||||||
|
await loadProjects();
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
|
|
||||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ async function loadChats() {
|
|||||||
type: c.type || 'direct',
|
type: c.type || 'direct',
|
||||||
model: c.model || '',
|
model: c.model || '',
|
||||||
messageCount: c.message_count || 0,
|
messageCount: c.message_count || 0,
|
||||||
|
projectId: c.project_id || null,
|
||||||
messages: [],
|
messages: [],
|
||||||
updatedAt: c.updated_at,
|
updatedAt: c.updated_at,
|
||||||
settings: c.settings || {},
|
settings: c.settings || {},
|
||||||
@@ -467,7 +468,7 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
|
||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.currentChatId = chat.id;
|
App.currentChatId = chat.id;
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
|
|||||||
288
src/js/projects-ui.js
Normal file
288
src/js/projects-ui.js
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Projects UI (v0.19.0)
|
||||||
|
// ==========================================
|
||||||
|
// Manages project CRUD, sidebar grouping, and
|
||||||
|
// chat-to-project association via context menu
|
||||||
|
// and drag-and-drop.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const resp = await API.listProjects();
|
||||||
|
App.projects = (resp.data || []).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description || '',
|
||||||
|
color: p.color || null,
|
||||||
|
icon: p.icon || null,
|
||||||
|
channelCount: p.channel_count || 0,
|
||||||
|
kbCount: p.kb_count || 0,
|
||||||
|
noteCount: p.note_count || 0,
|
||||||
|
isArchived: p.is_archived || false,
|
||||||
|
}));
|
||||||
|
// Restore collapse state from localStorage
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}');
|
||||||
|
App.collapsedProjects = saved;
|
||||||
|
} catch (_) {}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load projects:', e.message);
|
||||||
|
App.projects = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveCollapseState() {
|
||||||
|
try { localStorage.setItem('cs-collapsed-projects', JSON.stringify(App.collapsedProjects)); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProjectCollapse(projectId) {
|
||||||
|
App.collapsedProjects[projectId] = !App.collapsedProjects[projectId];
|
||||||
|
_saveCollapseState();
|
||||||
|
UI.renderChatList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Project CRUD ────────────────────────────
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
const name = prompt('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: p.description || '',
|
||||||
|
color: p.color || null,
|
||||||
|
icon: p.icon || null,
|
||||||
|
channelCount: 0, kbCount: 0, noteCount: 0,
|
||||||
|
isArchived: false,
|
||||||
|
});
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.toast('Project created', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to create project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameProject(projectId) {
|
||||||
|
const proj = App.projects.find(p => p.id === projectId);
|
||||||
|
if (!proj) return;
|
||||||
|
const name = prompt('Rename project:', proj.name);
|
||||||
|
if (!name || !name.trim() || name.trim() === proj.name) return;
|
||||||
|
try {
|
||||||
|
await API.updateProject(projectId, { name: name.trim() });
|
||||||
|
proj.name = name.trim();
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to rename project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(projectId) {
|
||||||
|
const proj = App.projects.find(p => p.id === projectId);
|
||||||
|
if (!proj) return;
|
||||||
|
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
|
||||||
|
try {
|
||||||
|
await API.deleteProject(projectId);
|
||||||
|
App.projects = App.projects.filter(p => p.id !== projectId);
|
||||||
|
// Unassign chats client-side
|
||||||
|
App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; });
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.toast('Project deleted', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to delete project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setProjectColor(projectId) {
|
||||||
|
const proj = App.projects.find(p => p.id === projectId);
|
||||||
|
if (!proj) return;
|
||||||
|
// Simple color picker via an invisible input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'color';
|
||||||
|
input.value = proj.color || '#3B82F6';
|
||||||
|
input.style.position = 'fixed';
|
||||||
|
input.style.opacity = '0';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
await API.updateProject(projectId, { color: input.value });
|
||||||
|
proj.color = input.value;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to set color', 'error');
|
||||||
|
}
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => setTimeout(() => input.remove(), 200));
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Move Chat to Project ────────────────────
|
||||||
|
|
||||||
|
async function moveChatToProject(chatId, projectId) {
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
const oldProjectId = chat.projectId;
|
||||||
|
|
||||||
|
if (projectId === null) {
|
||||||
|
// Remove from project
|
||||||
|
if (!oldProjectId) return;
|
||||||
|
try {
|
||||||
|
await API.removeChannelFromProject(oldProjectId, chatId);
|
||||||
|
chat.projectId = null;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to remove from project', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await API.addChannelToProject(projectId, chatId, 0);
|
||||||
|
chat.projectId = projectId;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to move to project', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Context Menu ────────────────────────────
|
||||||
|
|
||||||
|
let _ctxMenu = null;
|
||||||
|
|
||||||
|
function showChatContextMenu(e, chatId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dismissChatContextMenu();
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'project-ctx-menu';
|
||||||
|
|
||||||
|
// Move to project submenu
|
||||||
|
let items = '';
|
||||||
|
if (chat.projectId) {
|
||||||
|
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">↩</span> Remove from project
|
||||||
|
</button>`;
|
||||||
|
items += '<div class="ctx-divider"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
App.projects.forEach(p => {
|
||||||
|
if (p.id === chat.projectId) return; // skip current
|
||||||
|
const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : '';
|
||||||
|
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}','${p.id}');dismissChatContextMenu()">
|
||||||
|
${dot}<span>${esc(p.name)}</span>
|
||||||
|
</button>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (App.projects.length === 0 && !chat.projectId) {
|
||||||
|
items += '<div class="ctx-hint">No projects yet</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
items += '<div class="ctx-divider"></div>';
|
||||||
|
items += `<button class="ctx-item" onclick="createProjectAndMove('${chatId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">+</span> New project…
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">✏</span> Rename
|
||||||
|
</button>
|
||||||
|
<button class="ctx-item" onclick="setProjectColor('${projectId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">🎨</span> Color
|
||||||
|
</button>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<button class="ctx-item ctx-danger" onclick="deleteProject('${projectId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">🗑</span> Delete project
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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 = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chats.length === 0) {
|
if (chats.length === 0 && projects.length === 0) {
|
||||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by time
|
let html = '';
|
||||||
|
|
||||||
|
// ── Chat item renderer ──────────────────
|
||||||
|
const renderChatItem = (c) => {
|
||||||
|
const time = _relativeTime(c.updatedAt);
|
||||||
|
return `
|
||||||
|
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||||
|
onclick="selectChat('${c.id}')"
|
||||||
|
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||||
|
ondragend="onChatDragEnd(event)">
|
||||||
|
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
||||||
|
<span class="chat-item-time">${time}</span>
|
||||||
|
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
|
||||||
|
: '<span class="project-dot"></span>';
|
||||||
|
const arrow = isCollapsed ? '▸' : '▾';
|
||||||
|
|
||||||
|
html += `<div class="project-group"
|
||||||
|
ondragover="onProjectDragOver(event)"
|
||||||
|
ondragleave="onProjectDragLeave(event)"
|
||||||
|
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||||
|
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||||||
|
<span class="project-arrow">${arrow}</span>
|
||||||
|
${colorDot}
|
||||||
|
<span class="project-name">${esc(proj.name)}</span>
|
||||||
|
<span class="project-count">${projChats.length}</span>
|
||||||
|
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (!isCollapsed) {
|
||||||
|
if (projChats.length === 0) {
|
||||||
|
html += '<div class="project-empty">Drop chats here</div>';
|
||||||
|
} else {
|
||||||
|
projChats.forEach(c => { html += renderChatItem(c); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 += `<div class="recent-section"
|
||||||
|
ondragover="onProjectDragOver(event)"
|
||||||
|
ondragleave="onProjectDragLeave(event)"
|
||||||
|
ondrop="onRecentDrop(event)">
|
||||||
|
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group recent by time
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const yesterday = new Date(today - 86400000);
|
const yesterday = new Date(today - 86400000);
|
||||||
@@ -289,7 +357,7 @@ const UI = {
|
|||||||
|
|
||||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||||
|
|
||||||
chats.forEach(c => {
|
recentChats.forEach(c => {
|
||||||
const d = new Date(c.updatedAt);
|
const d = new Date(c.updatedAt);
|
||||||
if (d >= today) groups.today.push(c);
|
if (d >= today) groups.today.push(c);
|
||||||
else if (d >= yesterday) groups.yesterday.push(c);
|
else if (d >= yesterday) groups.yesterday.push(c);
|
||||||
@@ -297,20 +365,16 @@ const UI = {
|
|||||||
else groups.older.push(c);
|
else groups.older.push(c);
|
||||||
});
|
});
|
||||||
|
|
||||||
let html = '';
|
const renderGroup = (label, items) => {
|
||||||
const renderGroup = (label, chats) => {
|
if (items.length === 0) return;
|
||||||
if (chats.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 += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
||||||
|
} else {
|
||||||
html += `<div class="chat-group-label">${label}</div>`;
|
html += `<div class="chat-group-label">${label}</div>`;
|
||||||
chats.forEach(c => {
|
}
|
||||||
const time = _relativeTime(c.updatedAt);
|
items.forEach(c => { html += renderChatItem(c); });
|
||||||
html += `
|
|
||||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
|
||||||
onclick="selectChat('${c.id}')">
|
|
||||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
|
||||||
<span class="chat-item-time">${time}</span>
|
|
||||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderGroup('Today', groups.today);
|
renderGroup('Today', groups.today);
|
||||||
@@ -318,6 +382,13 @@ const UI = {
|
|||||||
renderGroup('Previous 7 days', groups.week);
|
renderGroup('Previous 7 days', groups.week);
|
||||||
renderGroup('Older', groups.older);
|
renderGroup('Older', groups.older);
|
||||||
|
|
||||||
|
if (projects.length > 0 && recentChats.length > 0) {
|
||||||
|
html += '</div>'; // close recent-section
|
||||||
|
} else if (projects.length > 0 && recentChats.length === 0) {
|
||||||
|
// Close the recent-section div we opened
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user