Add tm_server with SQLite, Svelte UI, and task-master TUI sync.

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>
This commit is contained in:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+280
View File
@@ -0,0 +1,280 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
// Role constants for project membership.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
)
// User is a local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
}
// Member is a project membership.
type Member struct {
ProjectID string `json:"projectId"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
Role string `json:"role"`
}
// HashPassword hashes a password with argon2id.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// CheckPassword verifies a password against a stored hash.
func CheckPassword(password, encoded string) bool {
parts := strings.SplitN(encoded, "$", 2)
if len(parts) != 2 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
if len(got) != len(want) {
return false
}
var v byte
for i := range got {
v |= got[i] ^ want[i]
}
return v == 0
}
// CreateUser inserts a user.
func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return User{}, fmt.Errorf("username and password required")
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{
ID: uuid.NewString(),
Username: username,
PasswordHash: hash,
IsAdmin: isAdmin,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`,
u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt))
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// CountUsers returns the number of users.
func (db *DB) CountUsers() (int, error) {
var n int
err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// GetUserByUsername looks up a user.
func (db *DB) GetUserByUsername(username string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username))
}
// GetUserByID looks up a user by id.
func (db *DB) GetUserByID(id string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id))
}
// ListUsers returns all users.
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := db.scanUser(rows)
if err != nil {
return nil, err
}
u.PasswordHash = ""
out = append(out, u)
}
if out == nil {
out = []User{}
}
return out, rows.Err()
}
func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) {
var u User
var admin int
var created string
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("user not found")
}
if err != nil {
return User{}, err
}
u.IsAdmin = admin != 0
u.CreatedAt = parseTime(created)
return u, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// CreateSession creates a session and returns the raw token (only once).
func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
rawToken = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
expiresAt = time.Now().UTC().Add(ttl)
_, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`,
uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC()))
return rawToken, expiresAt, err
}
// UserFromSession resolves a session token to a user.
func (db *DB) UserFromSession(rawToken string) (User, error) {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
var userID, expires string
err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash).
Scan(&userID, &expires)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("invalid session")
}
if err != nil {
return User{}, err
}
if parseTime(expires).Before(time.Now().UTC()) {
_, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return User{}, fmt.Errorf("session expired")
}
return db.GetUserByID(userID)
}
// DeleteSession removes a session by raw token.
func (db *DB) DeleteSession(rawToken string) error {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
_, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// SetMember sets or updates a project membership.
func (db *DB) SetMember(projectID, userID, role string) error {
switch role {
case RoleOwner, RoleAdmin, RoleMember, RoleViewer:
default:
return fmt.Errorf("invalid role %q", role)
}
_, err := db.SQL.Exec(`
INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?)
ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`,
projectID, userID, role)
return err
}
// RemoveMember deletes a membership.
func (db *DB) RemoveMember(projectID, userID string) error {
_, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
return err
}
// ListMembers lists members of a project.
func (db *DB) ListMembers(projectID string) ([]Member, error) {
rows, err := db.SQL.Query(`
SELECT m.project_id, m.user_id, u.username, m.role
FROM project_members m
JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY u.username COLLATE NOCASE`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Member
for rows.Next() {
var m Member
if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil {
return nil, err
}
out = append(out, m)
}
if out == nil {
out = []Member{}
}
return out, rows.Err()
}
// MemberRole returns the role for a user on a project, or empty if none.
func (db *DB) MemberRole(projectID, userID string) (string, error) {
var role string
err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`,
projectID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", nil
}
return role, err
}
// RoleAtLeast reports whether role meets the required minimum.
func RoleAtLeast(role, required string) bool {
rank := map[string]int{
RoleViewer: 1,
RoleMember: 2,
RoleAdmin: 3,
RoleOwner: 4,
}
return rank[role] >= rank[required]
}
+118
View File
@@ -0,0 +1,118 @@
package sqlite
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Connection is a remote backend credential (instance or project scope).
type Connection struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Scope string `json:"scope"` // instance | project
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
Token string `json:"-"` // only populated when decrypted for sync
}
// CreateConnection stores an encrypted token.
func (db *DB) CreateConnection(name, kind, baseURL, scope, token, createdBy string) (Connection, error) {
name = strings.TrimSpace(name)
kind = strings.TrimSpace(kind)
baseURL = strings.TrimSpace(baseURL)
scope = strings.TrimSpace(scope)
if name == "" || kind == "" || baseURL == "" || token == "" {
return Connection{}, fmt.Errorf("name, kind, baseUrl and token required")
}
if scope == "" {
scope = "instance"
}
enc, err := db.EncryptToken(token)
if err != nil {
return Connection{}, err
}
c := Connection{
ID: uuid.NewString(),
Name: name,
Kind: kind,
BaseURL: baseURL,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
VALUES(?,?,?,?,?,?,?,?)`,
c.ID, c.Name, c.Kind, c.BaseURL, c.Scope, enc, c.CreatedBy, formatTime(c.CreatedAt))
if err != nil {
return Connection{}, err
}
return c, nil
}
// ListConnections returns connections without tokens.
func (db *DB) ListConnections() ([]Connection, error) {
rows, err := db.SQL.Query(`
SELECT id, name, kind, base_url, scope, created_by, created_at
FROM connections ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Connection
for rows.Next() {
var c Connection
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &c.CreatedBy, &created); err != nil {
return nil, err
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
if out == nil {
out = []Connection{}
}
return out, rows.Err()
}
// GetConnection loads a connection and decrypts its token.
func (db *DB) GetConnection(id string) (Connection, error) {
var c Connection
var created string
var blob []byte
err := db.SQL.QueryRow(`
SELECT id, name, kind, base_url, scope, encrypted_token, created_by, created_at
FROM connections WHERE id = ?`, id).
Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &blob, &c.CreatedBy, &created)
if err == sql.ErrNoRows {
return Connection{}, fmt.Errorf("connection %s: not found", id)
}
if err != nil {
return Connection{}, err
}
c.CreatedAt = parseTime(created)
c.Token, err = db.DecryptToken(blob)
if err != nil {
return Connection{}, err
}
return c, nil
}
// DeleteConnection removes a connection.
func (db *DB) DeleteConnection(id string) error {
res, err := db.SQL.Exec(`DELETE FROM connections WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("connection %s: not found", id)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package sqlite
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
)
func normalizeSecret(secret []byte) []byte {
if len(secret) == 0 {
// Deterministic fallback for local/dev; production should set TASK_MASTER_SECRET.
sum := sha256.Sum256([]byte("task-master-dev-secret"))
return sum[:]
}
sum := sha256.Sum256(secret)
return sum[:]
}
// EncryptToken seals plaintext with AES-GCM.
func (db *DB) EncryptToken(plain string) ([]byte, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
}
// DecryptToken opens an AES-GCM blob.
func (db *DB) DecryptToken(blob []byte) (string, error) {
block, err := aes.NewCipher(db.Secret)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(blob) < gcm.NonceSize() {
return "", fmt.Errorf("token ciphertext too short")
}
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
+129
View File
@@ -0,0 +1,129 @@
package sqlite
import (
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// DB wraps a SQLite connection and domain helpers.
type DB struct {
SQL *sql.DB
Secret []byte // AES-256 key material for connection tokens (derived/padded)
}
// Open opens (or creates) the database at path and runs migrations.
func Open(path string, secret []byte) (*DB, error) {
if path == "" {
return nil, fmt.Errorf("sqlite: path required")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("sqlite: create dir: %w", err)
}
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("sqlite: open: %w", err)
}
sqlDB.SetMaxOpenConns(1) // SQLite writer safety
if err := sqlDB.Ping(); err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("sqlite: ping: %w", err)
}
db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)}
if err := db.migrate(); err != nil {
_ = sqlDB.Close()
return nil, err
}
return db, nil
}
// Close closes the underlying connection.
func (db *DB) Close() error {
if db == nil || db.SQL == nil {
return nil
}
return db.SQL.Close()
}
func (db *DB) migrate() error {
if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("sqlite: schema_migrations: %w", err)
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("sqlite: list migrations: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var exists string
err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists)
if err == nil {
continue
}
if err != sql.ErrNoRows {
return fmt.Errorf("sqlite: check migration %s: %w", name, err)
}
body, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("sqlite: read migration %s: %w", name, err)
}
tx, err := db.SQL.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: apply %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`,
name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("sqlite: record %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+115
View File
@@ -0,0 +1,115 @@
-- schema version tracked in app_meta
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_members_user ON project_members(user_id);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
external_url TEXT NOT NULL DEFAULT '',
synced_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_tickets_external ON tickets(external_id);
CREATE TABLE IF NOT EXISTS time_entries (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
duration_ns INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_time_ticket ON time_entries(ticket_id);
CREATE INDEX IF NOT EXISTS idx_time_project ON time_entries(project_id);
CREATE TABLE IF NOT EXISTS active_timer (
id INTEGER PRIMARY KEY CHECK (id = 1),
ticket_id TEXT NOT NULL,
project_id TEXT NOT NULL,
started_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
base_url TEXT NOT NULL,
scope TEXT NOT NULL,
encrypted_token BLOB NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_backends (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'none',
base_url TEXT NOT NULL DEFAULT '',
project_key TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
connection_id TEXT REFERENCES connections(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
rev INTEGER NOT NULL,
at TEXT NOT NULL,
actor_user_id TEXT,
op TEXT NOT NULL,
snapshot_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_revisions_project ON revisions(project_id);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS project_tokens (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_project_tokens_project ON project_tokens(project_id);
CREATE INDEX IF NOT EXISTS idx_project_tokens_hash ON project_tokens(token_hash);
+323
View File
@@ -0,0 +1,323 @@
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)
}
+120
View File
@@ -0,0 +1,120 @@
package sqlite
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Revision is a versioned snapshot of an entity.
type Revision struct {
ID int64 `json:"id"`
EntityType string `json:"entityType"`
EntityID string `json:"entityId"`
ProjectID string `json:"projectId"`
Rev int `json:"rev"`
At time.Time `json:"at"`
ActorUserID string `json:"actorUserId,omitempty"`
Op string `json:"op"`
Snapshot json.RawMessage `json:"snapshot"`
}
// AppendRevision stores a new revision snapshot.
func (db *DB) AppendRevision(entityType, entityID, projectID, op, actorUserID string, snapshot any) (Revision, error) {
data, err := json.Marshal(snapshot)
if err != nil {
return Revision{}, err
}
var maxRev sql.NullInt64
_ = db.SQL.QueryRow(`SELECT MAX(rev) FROM revisions WHERE entity_type = ? AND entity_id = ?`,
entityType, entityID).Scan(&maxRev)
rev := 1
if maxRev.Valid {
rev = int(maxRev.Int64) + 1
}
at := time.Now().UTC()
var actor any
if actorUserID != "" {
actor = actorUserID
}
res, err := db.SQL.Exec(`
INSERT INTO revisions(entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json)
VALUES(?,?,?,?,?,?,?,?)`,
entityType, entityID, projectID, rev, formatTime(at), actor, op, string(data))
if err != nil {
return Revision{}, err
}
id, _ := res.LastInsertId()
return Revision{
ID: id,
EntityType: entityType,
EntityID: entityID,
ProjectID: projectID,
Rev: rev,
At: at,
ActorUserID: actorUserID,
Op: op,
Snapshot: data,
}, nil
}
// ListRevisions returns revisions for an entity, newest first.
func (db *DB) ListRevisions(entityType, entityID string) ([]Revision, error) {
rows, err := db.SQL.Query(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE entity_type = ? AND entity_id = ?
ORDER BY rev DESC`, entityType, entityID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRevisions(rows)
}
// GetRevision returns one revision by id.
func (db *DB) GetRevision(id int64) (Revision, error) {
row := db.SQL.QueryRow(`
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
FROM revisions WHERE id = ?`, id)
var r Revision
var at string
var actor sql.NullString
var snap string
err := row.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap)
if err == sql.ErrNoRows {
return Revision{}, fmt.Errorf("revision %d: not found", id)
}
if err != nil {
return Revision{}, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
return r, nil
}
func scanRevisions(rows *sql.Rows) ([]Revision, error) {
var out []Revision
for rows.Next() {
var r Revision
var at string
var actor sql.NullString
var snap string
if err := rows.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap); err != nil {
return nil, err
}
r.At = parseTime(at)
if actor.Valid {
r.ActorUserID = actor.String
}
r.Snapshot = json.RawMessage(snap)
out = append(out, r)
}
if out == nil {
out = []Revision{}
}
return out, rows.Err()
}
+98
View File
@@ -0,0 +1,98 @@
package sqlite
import (
"path/filepath"
"testing"
"time"
"task/internal/ticket"
)
func TestStoresRoundTrip(t *testing.T) {
db, err := Open(filepath.Join(t.TempDir(), "test.db"), []byte("test-secret"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
ps := NewProjectStore(db)
if err := ps.Ensure("inbox"); err != nil {
t.Fatal(err)
}
ts := NewTicketStore(db)
created, err := ts.Create(ticket.Ticket{Title: "Hello", ProjectID: "inbox", Status: ticket.StatusTodo})
if err != nil {
t.Fatal(err)
}
got, err := ts.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if got.Title != "Hello" {
t.Fatalf("title=%q", got.Title)
}
got.Title = "Updated"
if err := ts.Update(got); err != nil {
t.Fatal(err)
}
if err := ts.MarkSynced(got.ID); err != nil {
t.Fatal(err)
}
synced, _ := ts.Get(got.ID)
if synced.SyncedAt.IsZero() || !synced.SyncedAt.Equal(synced.UpdatedAt) {
t.Fatalf("syncedAt=%v updatedAt=%v", synced.SyncedAt, synced.UpdatedAt)
}
timeStore := NewTimeStore(db)
_, err = timeStore.Create(ticket.TimeEntry{
TicketID: created.ID,
ProjectID: "inbox",
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
sum, err := timeStore.SumByTicket(created.ID)
if err != nil || sum != time.Hour {
t.Fatalf("sum=%v err=%v", sum, err)
}
u, err := db.CreateUser("admin", "secret", true)
if err != nil {
t.Fatal(err)
}
token, _, err := db.CreateSession(u.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
me, err := db.UserFromSession(token)
if err != nil || me.Username != "admin" {
t.Fatalf("session user=%v err=%v", me, err)
}
if err := db.SetMember("inbox", u.ID, RoleOwner); err != nil {
t.Fatal(err)
}
role, _ := db.MemberRole("inbox", u.ID)
if role != RoleOwner {
t.Fatalf("role=%s", role)
}
_, err = db.AppendRevision("ticket", created.ID, "inbox", "update", u.ID, synced)
if err != nil {
t.Fatal(err)
}
revs, err := db.ListRevisions("ticket", created.ID)
if err != nil || len(revs) != 1 {
t.Fatalf("revs=%v err=%v", revs, err)
}
conn, err := db.CreateConnection("YT", "youtrack", "https://yt.example", "instance", "tok-123", u.ID)
if err != nil {
t.Fatal(err)
}
loaded, err := db.GetConnection(conn.ID)
if err != nil || loaded.Token != "tok-123" {
t.Fatalf("token=%q err=%v", loaded.Token, err)
}
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TicketStore implements ticket.Store on SQLite.
type TicketStore struct {
db *DB
}
// NewTicketStore returns a ticket store.
func NewTicketStore(db *DB) *TicketStore {
return &TicketStore{db: db}
}
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
var t ticket.Ticket
var created, updated, synced string
var status string
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
)
if err != nil {
return ticket.Ticket{}, err
}
t.Status = ticket.Status(status)
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
// List returns tickets for a project.
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ticket.Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ticket.Ticket{}
}
return out, rows.Err()
}
// Get finds a ticket by ID.
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
row := s.db.SQL.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
t, err := scanTicket(row)
if err == sql.ErrNoRows {
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
if err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Create inserts a ticket.
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
if t.ProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return ticket.Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = ticket.StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
_, err := s.db.SQL.Exec(`
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt.
func (s *TicketStore) Update(t ticket.Ticket) error {
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
var created string
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
if err == sql.ErrNoRows {
return fmt.Errorf("ticket %s: not found", t.ID)
}
if err != nil {
return err
}
t.CreatedAt = parseTime(created)
t.UpdatedAt = time.Now().UTC()
res, err := s.db.SQL.Exec(`
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
source=?, external_id=?, external_url=?, synced_at=?
WHERE id=?`,
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return nil
}
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
func (s *TicketStore) MarkSynced(id string) error {
if id == "" {
return fmt.Errorf("ticket id is required")
}
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
// Move relocates a ticket to another project.
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
if id == "" {
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
}
if toProjectID == "" {
return ticket.Ticket{}, fmt.Errorf("projectId is required")
}
t, err := s.Get(id)
if err != nil {
return ticket.Ticket{}, err
}
t.ProjectID = toProjectID
t.UpdatedAt = time.Now().UTC()
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
toProjectID, formatTime(t.UpdatedAt), id)
if err != nil {
return ticket.Ticket{}, err
}
return t, nil
}
// Delete removes a ticket.
func (s *TicketStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", id)
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TimeStore implements ticket.TimeStore on SQLite.
type TimeStore struct {
db *DB
}
// NewTimeStore returns a time store.
func NewTimeStore(db *DB) *TimeStore {
return &TimeStore{db: db}
}
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
var e ticket.TimeEntry
var start, end string
var dur int64
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
e.Start = parseTime(start)
e.End = parseTime(end)
e.Duration = time.Duration(dur)
return e, nil
}
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
// List returns all time entries.
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByTicket returns entries for one ticket.
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByProject returns entries for a project.
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
if out == nil {
out = []ticket.TimeEntry{}
}
return out, rows.Err()
}
// Create adds a time entry.
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
if e.TicketID == "" {
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
}
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
e.Duration = e.End.Sub(e.Start)
}
if e.Duration <= 0 {
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
}
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Source == "" {
e.Source = ticket.SourceManual
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
if e.End.IsZero() {
e.End = e.Start.Add(e.Duration)
}
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
int64(e.Duration), e.Source, e.Note, e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
return e, nil
}
// Update replaces a time entry.
func (s *TimeStore) Update(e ticket.TimeEntry) error {
if e.ID == "" {
return fmt.Errorf("time entry id is required")
}
if e.Duration <= 0 {
return fmt.Errorf("duration must be positive")
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
e.End = e.Start.Add(e.Duration)
res, err := s.db.SQL.Exec(`
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
WHERE id=?`,
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", e.ID)
}
return nil
}
// Delete removes a time entry.
func (s *TimeStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", id)
}
return nil
}
// RetargetTicket updates project_id for all entries of a ticket.
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
if ticketID == "" || projectID == "" {
return fmt.Errorf("ticketId and projectId are required")
}
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
return err
}
// DeleteByProject removes all entries for a project.
func (s *TimeStore) DeleteByProject(projectID string) error {
if projectID == "" {
return fmt.Errorf("projectId is required")
}
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
return err
}
// SumByTicket returns total duration.
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var sum sql.NullInt64
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
if err != nil {
return 0, err
}
if !sum.Valid {
return 0, nil
}
return time.Duration(sum.Int64), nil
}
// LoadActiveTimer returns the running timer or nil.
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var t ticket.ActiveTimer
var started string
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
Scan(&t.TicketID, &t.ProjectID, &started)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t.StartedAt = parseTime(started)
if t.TicketID == "" {
return nil, nil
}
return &t, nil
}
// SaveActiveTimer writes or clears the active timer.
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
return err
}
_, err := s.db.SQL.Exec(`
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
return err
}
+226
View File
@@ -0,0 +1,226 @@
package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
TokenScopeRead = "read"
TokenScopeWrite = "write" // includes read
)
// ProjectToken is a project-scoped API credential (secret only on create).
type ProjectToken struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Scope string `json:"scope"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
Token string `json:"token,omitempty"` // raw secret, only set on create
}
// CreateProjectToken issues a new token; raw value is returned once in Token.
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
name = strings.TrimSpace(name)
scope = strings.TrimSpace(scope)
if projectID == "" || name == "" {
return ProjectToken{}, fmt.Errorf("projectId and name required")
}
if scope != TokenScopeRead && scope != TokenScopeWrite {
return ProjectToken{}, fmt.Errorf("scope must be read or write")
}
raw, err := randomToken()
if err != nil {
return ProjectToken{}, err
}
hash := hashToken(raw)
t := ProjectToken{
ID: uuid.NewString(),
ProjectID: projectID,
Name: name,
Scope: scope,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
Token: raw,
}
_, err = db.SQL.Exec(`
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
VALUES(?,?,?,?,?,?,?)`,
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
if err != nil {
return ProjectToken{}, err
}
return t, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return base64.RawStdEncoding.EncodeToString(sum[:])
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// ListProjectTokens returns non-secret token metadata for a project.
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
rows, err := db.SQL.Query(`
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
FROM project_tokens WHERE project_id = ?
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProjectToken
for rows.Next() {
t, err := scanProjectToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
if out == nil {
out = []ProjectToken{}
}
return out, rows.Err()
}
// RevokeProjectToken soft-revokes a token.
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
res, err := db.SQL.Exec(`
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
formatTime(time.Now().UTC()), tokenID, projectID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("token not found or already revoked")
}
return nil
}
// TokenAuth is the resolved bearer identity.
type TokenAuth struct {
User User
ProjectID string
Scope string
TokenID string
}
// ResolveBearerToken validates a raw API token.
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return TokenAuth{}, fmt.Errorf("empty token")
}
hash := hashToken(raw)
var (
tokenID, projectID, scope string
createdBy sql.NullString
revoked sql.NullString
)
err := db.SQL.QueryRow(`
SELECT id, project_id, scope, created_by, revoked_at
FROM project_tokens WHERE token_hash = ?`, hash).
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
if err == sql.ErrNoRows {
return TokenAuth{}, fmt.Errorf("invalid token")
}
if err != nil {
return TokenAuth{}, err
}
if revoked.Valid && revoked.String != "" {
return TokenAuth{}, fmt.Errorf("token revoked")
}
var u User
if createdBy.Valid && createdBy.String != "" {
u, err = db.GetUserByID(createdBy.String)
if err != nil {
// creator deleted — synthetic limited principal
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
}
} else {
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
}
u.PasswordHash = ""
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
}
// TokenAllows reports whether scope permits the operation (write implies read).
func TokenAllows(scope, need string) bool {
if need == TokenScopeRead {
return scope == TokenScopeRead || scope == TokenScopeWrite
}
return scope == TokenScopeWrite
}
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
var t ProjectToken
var createdBy sql.NullString
var created, revoked sql.NullString
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
if err != nil {
return ProjectToken{}, err
}
if createdBy.Valid {
t.CreatedBy = createdBy.String
}
t.CreatedAt = parseTime(created.String)
if revoked.Valid && revoked.String != "" {
tm := parseTime(revoked.String)
t.RevokedAt = &tm
}
return t, nil
}
// DeleteUser removes a user and their sessions.
func (db *DB) DeleteUser(id string) error {
if id == "" {
return fmt.Errorf("user id required")
}
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}
// SetUserAdmin updates the is_admin flag.
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("user not found")
}
return nil
}