Changeset 0.19.0.1 (#82)
This commit is contained in:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user