Changeset 0.19.0.1 (#82)
This commit is contained in:
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_knowledge_bases",
|
||||
"persona_knowledge_bases",
|
||||
"project_notes",
|
||||
"project_knowledge_bases",
|
||||
"project_channels",
|
||||
"kb_chunks",
|
||||
"kb_documents",
|
||||
"knowledge_bases",
|
||||
"channels",
|
||||
"projects",
|
||||
"user_model_settings",
|
||||
"model_catalog",
|
||||
"persona_grants",
|
||||
|
||||
@@ -53,6 +53,7 @@ type channelResponse struct {
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Folder *string `json:"folder"`
|
||||
ProjectID *string `json:"project_id,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
@@ -138,6 +139,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
folder := c.Query("folder")
|
||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
projectFilter := c.Query("project_id") // "uuid" or "none"
|
||||
|
||||
// Count total
|
||||
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
||||
@@ -159,6 +161,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
countArgs = append(countArgs, "%"+search+"%")
|
||||
argN++
|
||||
}
|
||||
if projectFilter == "none" {
|
||||
countQuery += ` AND project_id IS NULL`
|
||||
} else if projectFilter != "" {
|
||||
countQuery += ` AND project_id = $` + strconv.Itoa(argN)
|
||||
countArgs = append(countArgs, projectFilter)
|
||||
argN++
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := database.DB.QueryRow(database.Q(countQuery), countArgs...).Scan(&total); err != nil {
|
||||
@@ -169,7 +178,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
// Fetch channels with message count
|
||||
query := `
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id,
|
||||
c.tags, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
@@ -196,6 +206,13 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
args = append(args, "%"+search+"%")
|
||||
argN++
|
||||
}
|
||||
if projectFilter == "none" {
|
||||
query += ` AND c.project_id IS NULL`
|
||||
} else if projectFilter != "" {
|
||||
query += ` AND c.project_id = $` + strconv.Itoa(argN)
|
||||
args = append(args, projectFilter)
|
||||
argN++
|
||||
}
|
||||
|
||||
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||
args = append(args, perPage, offset)
|
||||
@@ -213,7 +230,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
var tags []string
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
@@ -278,11 +295,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
// Read back the row
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
||||
system_prompt, is_archived, is_pinned, folder, tags, settings,
|
||||
system_prompt, is_archived, is_pinned, folder, project_id,
|
||||
tags, settings,
|
||||
created_at, updated_at
|
||||
FROM channels WHERE id = ?`, id).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -294,12 +312,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||
is_archived, is_pinned, folder, project_id, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
||||
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -359,7 +377,8 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id,
|
||||
c.tags, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
@@ -369,7 +388,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
WHERE c.id = $1 AND c.user_id = $2
|
||||
`), channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -851,6 +851,25 @@ func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, pe
|
||||
}
|
||||
}
|
||||
|
||||
// Project-bound KBs (v0.19.0)
|
||||
if stores.Projects != nil {
|
||||
projID, _ := stores.Projects.GetProjectIDForChannel(ctx, channelID)
|
||||
if projID != "" {
|
||||
projKBIDs, projErr := stores.Projects.GetKBIDs(ctx, projID)
|
||||
if projErr == nil {
|
||||
for _, kbID := range projKBIDs {
|
||||
if !seen[kbID] {
|
||||
kb, kbErr := stores.KnowledgeBases.GetByID(ctx, kbID)
|
||||
if kbErr == nil && kb.ChunkCount > 0 {
|
||||
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
|
||||
seen[kbID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel-linked KBs
|
||||
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
||||
if err == nil {
|
||||
|
||||
@@ -164,6 +164,15 @@ func (h *NoteHandler) Create(c *gin.Context) {
|
||||
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
|
||||
}
|
||||
|
||||
// Auto-associate note with source channel's project (v0.19.0)
|
||||
if req.SourceChannelID != "" && h.stores.Projects != nil {
|
||||
projID, _ := h.stores.Projects.GetProjectIDForChannel(
|
||||
c.Request.Context(), req.SourceChannelID)
|
||||
if projID != "" {
|
||||
_ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, toNoteResponse(note))
|
||||
}
|
||||
|
||||
|
||||
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.GET("/notes/:id/backlinks", notes.Backlinks)
|
||||
|
||||
// Projects (v0.19.0)
|
||||
projectH := handlers.NewProjectHandler(stores)
|
||||
protected.GET("/projects", projectH.List)
|
||||
protected.POST("/projects", projectH.Create)
|
||||
protected.GET("/projects/:id", projectH.Get)
|
||||
protected.PUT("/projects/:id", projectH.Update)
|
||||
protected.DELETE("/projects/:id", projectH.Delete)
|
||||
protected.POST("/projects/:id/channels", projectH.AddChannel)
|
||||
protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel)
|
||||
protected.GET("/projects/:id/channels", projectH.ListChannels)
|
||||
protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels)
|
||||
protected.POST("/projects/:id/knowledge-bases", projectH.AddKB)
|
||||
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB)
|
||||
protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs)
|
||||
protected.POST("/projects/:id/notes", projectH.AddNote)
|
||||
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
|
||||
protected.GET("/projects/:id/notes", projectH.ListNotes)
|
||||
|
||||
// Attachments (file upload/download)
|
||||
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||
@@ -515,6 +533,11 @@ func main() {
|
||||
// Resource Grants (admin — v0.16.0)
|
||||
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
|
||||
// Projects (admin — v0.19.0)
|
||||
adminProjH := handlers.NewProjectHandler(stores)
|
||||
admin.GET("/projects", adminProjH.AdminList)
|
||||
admin.DELETE("/projects/:id", adminProjH.Delete)
|
||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||
|
||||
// Model Roles
|
||||
|
||||
@@ -308,6 +308,7 @@ type Channel struct {
|
||||
IsPinned bool `json:"is_pinned" db:"is_pinned"`
|
||||
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
|
||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||
}
|
||||
|
||||
@@ -376,10 +377,53 @@ type Folder struct {
|
||||
|
||||
type Project struct {
|
||||
BaseModel
|
||||
UserID string `json:"user_id" db:"user_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Color string `json:"color,omitempty" db:"color"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description,omitempty" db:"description"`
|
||||
Color *string `json:"color,omitempty" db:"color"`
|
||||
Icon *string `json:"icon,omitempty" db:"icon"`
|
||||
Scope string `json:"scope" db:"scope"` // personal, team, global
|
||||
OwnerID string `json:"owner_id" db:"owner_id"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
IsArchived bool `json:"is_archived" db:"is_archived"`
|
||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
||||
|
||||
// Computed fields (not DB columns)
|
||||
ChannelCount int `json:"channel_count,omitempty"`
|
||||
KBCount int `json:"kb_count,omitempty"`
|
||||
NoteCount int `json:"note_count,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectPatch holds optional fields for updating a project.
|
||||
type ProjectPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectChannel represents a channel's membership in a project.
|
||||
type ProjectChannel struct {
|
||||
ProjectID string `json:"project_id" db:"project_id"`
|
||||
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||
Position int `json:"position" db:"position"`
|
||||
Folder string `json:"folder,omitempty" db:"folder"`
|
||||
AddedAt string `json:"added_at" db:"added_at"`
|
||||
}
|
||||
|
||||
// ProjectKB represents a KB's association with a project.
|
||||
type ProjectKB struct {
|
||||
ProjectID string `json:"project_id" db:"project_id"`
|
||||
KBID string `json:"kb_id" db:"kb_id"`
|
||||
AutoSearch bool `json:"auto_search" db:"auto_search"`
|
||||
AddedAt string `json:"added_at" db:"added_at"`
|
||||
}
|
||||
|
||||
// ProjectNote represents a note's association with a project.
|
||||
type ProjectNote struct {
|
||||
ProjectID string `json:"project_id" db:"project_id"`
|
||||
NoteID string `json:"note_id" db:"note_id"`
|
||||
AddedAt string `json:"added_at" db:"added_at"`
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -39,6 +39,7 @@ type Stores struct {
|
||||
Groups GroupStore
|
||||
ResourceGrants ResourceGrantStore
|
||||
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(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
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(),
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
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)
|
||||
}
|
||||
|
||||
// Also include project-bound KBs (v0.19.0)
|
||||
if t.stores.Projects != nil {
|
||||
projID, _ := t.stores.Projects.GetProjectIDForChannel(ctx, execCtx.ChannelID)
|
||||
if projID != "" {
|
||||
projKBIDs, _ := t.stores.Projects.GetKBIDs(ctx, projID)
|
||||
projSet := make(map[string]bool, len(kbIDs))
|
||||
for _, id := range kbIDs {
|
||||
projSet[id] = true
|
||||
}
|
||||
for _, id := range projKBIDs {
|
||||
if !projSet[id] {
|
||||
kbIDs = append(kbIDs, id)
|
||||
projSet[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also include personal KBs (always available to owner, even if not linked to channel)
|
||||
personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID)
|
||||
if err == nil {
|
||||
|
||||
Reference in New Issue
Block a user