Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
9.1 KiB
Go
324 lines
9.1 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}
|
|
}
|
|
|
|
// List returns all projects sorted by name.
|
|
func (s *ProjectStore) List() ([]project.Project, error) {
|
|
rows, err := s.db.SQL.Query(`SELECT id, name 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() {
|
|
var p project.Project
|
|
if err := rows.Scan(&p.ID, &p.Name); 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) VALUES(?, ?, ?, ?)`,
|
|
id, name, now, now)
|
|
if err != nil {
|
|
return project.Project{}, fmt.Errorf("create project: %w", err)
|
|
}
|
|
return project.Project{ID: id, Name: name}, 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) {
|
|
var p project.Project
|
|
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
|
|
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 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() {
|
|
var p project.Project
|
|
if err := rows.Scan(&p.ID, &p.Name); 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)
|
|
}
|