- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
465 lines
13 KiB
Go
465 lines
13 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"github.com/lib/pq"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── 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, workspaceID 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.workspace_id, 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, &workspaceID, &p.Settings,
|
|
&p.CreatedAt, &p.UpdatedAt,
|
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.TeamID = NullableStringPtr(teamID)
|
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
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 patch.WorkspaceID != nil {
|
|
b.Set("workspace_id", models.NullString(patch.WorkspaceID))
|
|
}
|
|
if len(patch.Settings) > 0 {
|
|
// Merge: read existing settings, overlay with patch values, write back.
|
|
var existing models.JSONMap
|
|
_ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = $1", id).Scan(&existing)
|
|
if existing == nil {
|
|
existing = make(models.JSONMap)
|
|
}
|
|
for k, v := range patch.Settings {
|
|
existing[k] = v
|
|
}
|
|
b.Set("settings", ToJSON(existing))
|
|
}
|
|
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.workspace_id, 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 pk.project_id, pk.kb_id, pk.auto_search, pk.added_at,
|
|
COALESCE(kb.name, '') AS name
|
|
FROM project_knowledge_bases pk
|
|
LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id
|
|
WHERE pk.project_id = $1
|
|
ORDER BY pk.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, &pkb.Name); 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 pn.project_id, pn.note_id, pn.added_at,
|
|
COALESCE(n.title, '') AS title
|
|
FROM project_notes pn
|
|
LEFT JOIN notes n ON n.id = pn.note_id
|
|
WHERE pn.project_id = $1
|
|
ORDER BY pn.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, &pn.Title); 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
|
|
}
|
|
|
|
// ── CS5c additions (v0.29.0) ──────────────────────────────────────────
|
|
|
|
func (s *ProjectStore) AdminList(ctx context.Context, includeArchived bool) ([]store.AdminProject, error) {
|
|
rows, err := DB.QueryContext(ctx, `
|
|
SELECT p.id, p.name, p.description, p.scope,
|
|
p.owner_id, p.team_id, p.is_archived,
|
|
p.created_at, p.updated_at,
|
|
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
|
|
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
|
|
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id),
|
|
COALESCE(u.username, '')
|
|
FROM projects p
|
|
LEFT JOIN users u ON u.id = p.owner_id
|
|
WHERE ($1 OR p.is_archived = false)
|
|
ORDER BY p.updated_at DESC`, includeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
results := make([]store.AdminProject, 0)
|
|
for rows.Next() {
|
|
var p store.AdminProject
|
|
var teamID sql.NullString
|
|
if err := rows.Scan(
|
|
&p.ID, &p.Name, &p.Description, &p.Scope,
|
|
&p.OwnerID, &teamID, &p.IsArchived,
|
|
&p.CreatedAt, &p.UpdatedAt,
|
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
&p.OwnerName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TeamID = NullableStringPtr(teamID)
|
|
results = append(results, p)
|
|
}
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// ── Query Helper ────────────────────────────
|
|
|
|
func queryProjects(ctx context.Context, q string, args ...interface{}) ([]models.Project, error) {
|
|
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, workspaceID sql.NullString
|
|
if err := rows.Scan(
|
|
&p.ID, &p.Name, &p.Description, &p.Color, &p.Icon, &p.Scope,
|
|
&p.OwnerID, &teamID, &p.IsArchived, &workspaceID, &p.Settings,
|
|
&p.CreatedAt, &p.UpdatedAt,
|
|
&p.ChannelCount, &p.KBCount, &p.NoteCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
p.TeamID = NullableStringPtr(teamID)
|
|
p.WorkspaceID = NullableStringPtr(workspaceID)
|
|
result = append(result, p)
|
|
}
|
|
return result, rows.Err()
|
|
}
|