Files
task-master/internal/sqlite/projects.go
T
skrueckenandCursor 472a2fa72e Add global web nav with project groups and tree sidebar.
Restructure the UI into top-level tabs and a scrollable project tree, and persist optional project groups so demos and settings can organize many projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:43:03 +01:00

360 lines
10 KiB
Go

package sqlite
import (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"task/internal/project"
)
// ProjectStore implements project.Store on SQLite.
type ProjectStore struct {
db *DB
}
// NewProjectStore returns a project store.
func NewProjectStore(db *DB) *ProjectStore {
return &ProjectStore{db: db}
}
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
var p project.Project
var groupID sql.NullString
if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
return project.Project{}, err
}
if groupID.Valid {
p.GroupID = groupID.String
}
return p, nil
}
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
defer rows.Close()
var out []project.Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
return out, rows.Err()
}
// Ensure creates the project if missing.
func (s *ProjectStore) Ensure(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("project id is required")
}
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
if err != nil {
return fmt.Errorf("ensure project %s: %w", id, err)
}
return nil
}
// Create creates a project. Empty id is replaced with a new UUID.
func (s *ProjectStore) Create(id, name string) (project.Project, error) {
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
if name == "" {
return project.Project{}, fmt.Errorf("project name is required")
}
if id == "" {
id = uuid.NewString()
}
now := formatTime(time.Now().UTC())
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
id, name, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
}
return project.Project{ID: id, Name: name}, nil
}
// SetGroup assigns a project to a group. Empty groupID clears the assignment.
func (s *ProjectStore) SetGroup(id, groupID string) error {
id = strings.TrimSpace(id)
groupID = strings.TrimSpace(groupID)
if id == "" {
return fmt.Errorf("project id is required")
}
var arg any
if groupID == "" {
arg = nil
} else {
arg = groupID
}
res, err := s.db.SQL.Exec(`UPDATE projects SET group_id = ?, updated_at = ? WHERE id = ?`,
arg, formatTime(time.Now().UTC()), id)
if err != nil {
return fmt.Errorf("set project group: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Rename updates the display name.
func (s *ProjectStore) Rename(id, name string) error {
name = strings.TrimSpace(name)
if id == "" || name == "" {
return fmt.Errorf("id and name are required")
}
res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`,
name, formatTime(time.Now().UTC()), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Delete removes a project and cascaded rows.
func (s *ProjectStore) Delete(id string) error {
if id == "" {
return fmt.Errorf("project id is required")
}
res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete project %s: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: not found", id)
}
return nil
}
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
if err != nil {
return project.Project{}, err
}
return p, nil
}
// LoadMeta maps sync_backends (+ connection token) into project.Meta.
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
if id == "" {
return project.Meta{}, fmt.Errorf("project id is required")
}
var kind, baseURL, projectKey, query string
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, id).
Scan(&kind, &baseURL, &projectKey, &query, &connID)
if err == sql.ErrNoRows {
return project.Meta{}, nil
}
if err != nil {
return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err)
}
if kind != "youtrack" {
return project.Meta{}, nil
}
yt := &project.YouTrack{
BaseURL: baseURL,
ProjectKey: projectKey,
Query: query,
}
if connID.Valid && connID.String != "" {
token, err := s.db.connectionToken(connID.String)
if err != nil {
return project.Meta{}, err
}
yt.Token = token
}
return project.Meta{YouTrack: yt}, nil
}
// SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set).
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
if id == "" {
return fmt.Errorf("project id is required")
}
if err := s.Ensure(id); err != nil {
return err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" {
_, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id)
return err
}
yt := meta.YouTrack
var connID sql.NullString
if strings.TrimSpace(yt.Token) != "" {
cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token)
if err != nil {
return err
}
connID = sql.NullString{String: cid, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, 'youtrack', ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`,
id, yt.BaseURL, yt.ProjectKey, yt.Query, connID)
return err
}
// SyncBackend is the persisted per-project sync config (API-facing).
type SyncBackend struct {
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
ProjectKey string `json:"projectKey"`
Query string `json:"query,omitempty"`
ConnectionID string `json:"connectionId,omitempty"`
}
// GetSyncBackend returns sync config for a project.
func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) {
var b SyncBackend
var connID sql.NullString
err := s.db.SQL.QueryRow(`
SELECT project_id, kind, base_url, project_key, query, connection_id
FROM sync_backends WHERE project_id = ?`, projectID).
Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID)
if err == sql.ErrNoRows {
return SyncBackend{ProjectID: projectID, Kind: "none"}, nil
}
if err != nil {
return SyncBackend{}, err
}
if connID.Valid {
b.ConnectionID = connID.String
}
return b, nil
}
// SetSyncBackend upserts sync config.
func (s *ProjectStore) SetSyncBackend(b SyncBackend) error {
if b.ProjectID == "" {
return fmt.Errorf("projectId required")
}
if b.Kind == "" {
b.Kind = "none"
}
var connID sql.NullString
if b.ConnectionID != "" {
connID = sql.NullString{String: b.ConnectionID, Valid: true}
}
_, err := s.db.SQL.Exec(`
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
VALUES(?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
kind=excluded.kind,
base_url=excluded.base_url,
project_key=excluded.project_key,
query=excluded.query,
connection_id=excluded.connection_id`,
b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID)
return err
}
// ListIDsForUser returns project IDs the user can access (all if admin).
func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) {
if isAdmin {
return s.List()
}
rows, err := s.db.SQL.Query(`
SELECT p.id, p.name, p.group_id FROM projects p
INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []project.Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
if out == nil {
out = []project.Project{}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, rows.Err()
}
func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) {
// Reuse existing sync_backends connection if present.
var existing sql.NullString
_ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing)
if existing.Valid && existing.String != "" {
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`,
enc, baseURL, existing.String)
if err != nil {
return "", err
}
return existing.String, nil
}
id := "conn_" + projectID
enc, err := db.EncryptToken(token)
if err != nil {
return "", err
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?)
ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`,
id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC()))
if err != nil {
return "", err
}
return id, nil
}
func (db *DB) connectionToken(id string) (string, error) {
var blob []byte
err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob)
if err == sql.ErrNoRows {
return "", fmt.Errorf("connection %s not found", id)
}
if err != nil {
return "", err
}
return db.DecryptToken(blob)
}