Replace UUID-looking sync labels in TUI and web with keys like TAS-1, while keeping the remote UUID for task-master matching. Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
11 KiB
Go
394 lines
11 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, &p.Code, &groupID); err != nil {
|
|
return project.Project{}, err
|
|
}
|
|
if groupID.Valid {
|
|
p.GroupID = groupID.String
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
const projectCols = `id, name, code, group_id`
|
|
|
|
// List returns all projects sorted by name.
|
|
func (s *ProjectStore) List() ([]project.Project, error) {
|
|
rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` 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()
|
|
}
|
|
|
|
func (s *ProjectStore) usedCodes() (map[string]bool, error) {
|
|
rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
used := map[string]bool{}
|
|
for rows.Next() {
|
|
var c string
|
|
if err := rows.Scan(&c); err != nil {
|
|
return nil, err
|
|
}
|
|
used[c] = true
|
|
}
|
|
return used, 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")
|
|
}
|
|
used, err := s.usedCodes()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
code := nextUniqueCode(id, used)
|
|
now := formatTime(time.Now().UTC())
|
|
_, err = s.db.SQL.Exec(`
|
|
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure project %s: %w", id, err)
|
|
}
|
|
var existing string
|
|
_ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing)
|
|
if existing == "" {
|
|
return s.db.backfillProjectCodes()
|
|
}
|
|
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()
|
|
}
|
|
used, err := s.usedCodes()
|
|
if err != nil {
|
|
return project.Project{}, err
|
|
}
|
|
code := nextUniqueCode(name, used)
|
|
now := formatTime(time.Now().UTC())
|
|
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
|
|
id, name, code, now, now)
|
|
if err != nil {
|
|
return project.Project{}, fmt.Errorf("create project: %w", err)
|
|
}
|
|
return project.Project{ID: id, Name: name, Code: code}, 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 `+projectCols+` 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.code, 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)
|
|
}
|