Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbf86dcf2 | ||
|
|
925bedf259 | ||
|
|
472a2fa72e | ||
|
|
74066649e4 | ||
|
|
4adfcdb95a |
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Build server
|
||||
run: make build-server
|
||||
|
||||
- name: Version
|
||||
run: make version
|
||||
|
||||
@@ -36,7 +39,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: tm
|
||||
path: ./bin/tm
|
||||
path: |
|
||||
./bin/tm
|
||||
./bin/tm_server
|
||||
|
||||
- run: echo "This job's status is ${{ job.status }}."
|
||||
|
||||
|
||||
@@ -6,3 +6,11 @@
|
||||
# Generated by `make demo` / CI — commit demo.gif so the README preview works on Gitea
|
||||
demo.mp4
|
||||
demo.webm
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
# Vite output embedded via go:embed — rebuild with `make web` / `make build-server`
|
||||
internal/webembed/dist/**
|
||||
!internal/webembed/dist/.gitkeep
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
BINARY := tm
|
||||
CMD := ./cmd/task-master
|
||||
VERSION_PKG := task/internal/version
|
||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||
BINARY := tm
|
||||
BINARY_SERVER := tm_server
|
||||
CMD := ./cmd/task-master
|
||||
CMD_SERVER := ./cmd/tm-server
|
||||
VERSION_PKG := task/internal/version
|
||||
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
|
||||
|
||||
# Default: Go workspace bin (e.g. ~/go/bin). Override with: make install PREFIX=...
|
||||
GOPATH ?= $(shell go env GOPATH)
|
||||
@@ -10,18 +12,49 @@ PREFIX ?= $(GOPATH)
|
||||
INSTALL_BIN := $(PREFIX)/bin
|
||||
DEV_HOME := $(CURDIR)/.dev-home
|
||||
|
||||
.PHONY: build run run-dev install uninstall test version demo
|
||||
.PHONY: build build-server build-all web web-build run run-dev run-server-dev install uninstall test version demo
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
|
||||
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
|
||||
|
||||
# Rebuild frontend only (assumes web/node_modules already installed).
|
||||
web-build:
|
||||
cd web && npm run build
|
||||
@mkdir -p internal/webembed/dist && touch internal/webembed/dist/.gitkeep
|
||||
|
||||
build-server: web
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
|
||||
|
||||
# Build server binary without rebuilding frontend (uses local webembed/dist).
|
||||
build-server-go:
|
||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_SERVER) $(CMD_SERVER)
|
||||
|
||||
build-all: build build-server
|
||||
|
||||
run: build
|
||||
./bin/$(BINARY)
|
||||
|
||||
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config)
|
||||
# Sandbox: isolated config/data under ./.dev-home (does not touch ~/.config).
|
||||
# YouTrack is not part of the local test environment — use tm_server instead.
|
||||
run-dev: build
|
||||
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
|
||||
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
||||
|
||||
# Web test environment: SQLite demo against tm_server only (no YouTrack).
|
||||
# Seeds 50 demo projects (Test + samples); login admin/admin (alice/alice as member).
|
||||
run-server-dev: web-build build-server-go
|
||||
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
|
||||
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
|
||||
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
|
||||
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
|
||||
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
|
||||
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
|
||||
./bin/$(BINARY_SERVER) -listen :8080
|
||||
|
||||
install: build
|
||||
install -d $(INSTALL_BIN)
|
||||
install -m 755 bin/$(BINARY) $(INSTALL_BIN)/$(BINARY)
|
||||
|
||||
@@ -8,18 +8,20 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch
|
||||
|
||||
## Features
|
||||
|
||||
- Projekte & Tickets lokal als JSON (kein Server)
|
||||
- Projekte & Tickets lokal als JSON (kein Server) — Binary `tm`
|
||||
- Web-Server `tm_server` mit eingebettetem Svelte-UI, SQLite, Auth und Rechte pro Projekt
|
||||
- Board-Filter: All / Backlog / In Progress / Done
|
||||
- Tickets in `$EDITOR` bearbeiten (Markdown + Checklisten)
|
||||
- Timer, manuelle Zeitbuchungen, Zeiteinträge verwalten
|
||||
- Report mit Group-by (Ticket / Tag)
|
||||
- Undo / Redo über eine Command-History
|
||||
- Vim-Style Keybindings
|
||||
- Undo / Redo über eine Command-History (TUI)
|
||||
- Vim-Style Keybindings (TUI)
|
||||
- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt)
|
||||
- Server: Sync-Backend pro Projekt, Projekt-Tokens für die TUI, Ticket-Revisionen
|
||||
|
||||
## Install
|
||||
|
||||
Voraussetzung: Go (siehe `go.mod`).
|
||||
Voraussetzung: Go (siehe `go.mod`). Für `tm_server` zusätzlich Node.js (Frontend-Build).
|
||||
|
||||
```bash
|
||||
git clone git@git.aloxdrim.de:skruecken/task-master.git
|
||||
@@ -27,6 +29,7 @@ cd task-master
|
||||
make install # → $(go env GOPATH)/bin/tm
|
||||
# oder nur bauen:
|
||||
make build # → ./bin/tm
|
||||
make build-server # Frontend + → ./bin/tm_server
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
@@ -40,6 +43,27 @@ tm version # Build-Version (Git-Hash)
|
||||
tm help
|
||||
```
|
||||
|
||||
### Web-Server (`tm_server`)
|
||||
|
||||
```bash
|
||||
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekt „Test“ + Samples
|
||||
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
|
||||
|
||||
# Produktion / manuell:
|
||||
export TM_ADMIN_USER=admin
|
||||
export TM_ADMIN_PASSWORD='change-me'
|
||||
export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
|
||||
./bin/tm_server -listen :8080
|
||||
|
||||
# JSON-Daten der TUI einmalig importieren:
|
||||
./bin/tm_server import-json -owner admin
|
||||
```
|
||||
|
||||
UI: http://localhost:8080 — Dashboard, Board, Report, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||
|
||||
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
|
||||
|
||||
Sandbox ohne echte Config/Daten:
|
||||
|
||||
```bash
|
||||
|
||||
+32
-14
@@ -14,6 +14,7 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
@@ -111,19 +112,36 @@ func runSync(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "push":
|
||||
res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
meta, err := projects.LoadMeta(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res ytsync.Result
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
|
||||
return fmt.Errorf("taskmaster not configured for %s", projectID)
|
||||
}
|
||||
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
|
||||
if action == "push" {
|
||||
res, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
|
||||
} else {
|
||||
res, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
if action == "push" {
|
||||
res, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
} else {
|
||||
res, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == "push" {
|
||||
fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n",
|
||||
projectID, res.Created, res.Updated, res.TimePushed)
|
||||
default:
|
||||
res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n",
|
||||
projectID, res.Created, res.Updated, res.TimePulled, res.Total)
|
||||
}
|
||||
@@ -135,8 +153,8 @@ func printHelp() {
|
||||
|
||||
Usage:
|
||||
%s Start the TUI
|
||||
%s sync pull [-p ID] Pull issues from YouTrack
|
||||
%s sync push [-p ID] Push local changes to YouTrack
|
||||
%s sync pull [-p ID] Pull from configured backend (YouTrack or task-master)
|
||||
%s sync push [-p ID] Push to configured backend
|
||||
%s version Print build version
|
||||
%s help Show this help
|
||||
|
||||
@@ -144,8 +162,8 @@ Env:
|
||||
TASK_MASTER_HOME Override home for config/data (dev/demo sandbox)
|
||||
TASK_MASTER_DATA_DIR Override data directory only
|
||||
|
||||
YouTrack:
|
||||
In the TUI: p → project → e to set remote URL, token, project key.
|
||||
Sync backends:
|
||||
In the TUI: p → project → e, choose youtrack or taskmaster, set URL/token/id.
|
||||
Then Y → Pull or Push (manual only; edits stay local until you push).
|
||||
|
||||
`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"task/internal/config"
|
||||
"task/internal/server"
|
||||
"task/internal/sqlite"
|
||||
"task/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", ":8080", "HTTP listen address")
|
||||
dbPath := flag.String("db", "", "SQLite database path (default: <dataDir>/task-master.db)")
|
||||
secure := flag.Bool("secure", false, "Set Secure flag on session cookies")
|
||||
flag.Parse()
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "version", "--version", "-v":
|
||||
fmt.Printf("tm_server %s\n", version.BuildVersion)
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
case "import-json":
|
||||
if err := runImportJSON(args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
|
||||
printHelp()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runServer(*listen, *dbPath, *secure); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func openDB(dbPath string) (*sqlite.DB, *config.Config, error) {
|
||||
cfg, err := config.LoadOrCreate()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
path := dbPath
|
||||
if path == "" {
|
||||
path = filepath.Join(cfg.DataDir, "task-master.db")
|
||||
}
|
||||
secret := []byte(os.Getenv("TASK_MASTER_SECRET"))
|
||||
db, err := sqlite.Open(path, secret)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return db, cfg, nil
|
||||
}
|
||||
|
||||
func bootstrapAdmin(db *sqlite.DB) error {
|
||||
n, err := db.CountUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
user := strings.TrimSpace(os.Getenv("TM_ADMIN_USER"))
|
||||
pass := os.Getenv("TM_ADMIN_PASSWORD")
|
||||
if user == "" || pass == "" {
|
||||
fmt.Fprintln(os.Stderr, "no users in database; set TM_ADMIN_USER and TM_ADMIN_PASSWORD to create the first admin")
|
||||
return nil
|
||||
}
|
||||
u, err := db.CreateUser(user, pass, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "created bootstrap admin %q (%s)\n", u.Username, u.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runServer(listen, dbPath string, secure bool) error {
|
||||
db, _, err := openDB(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := bootstrapAdmin(db); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := server.New(db)
|
||||
srv.Secure = secure
|
||||
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
||||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
return fmt.Errorf("seed: %w", err)
|
||||
}
|
||||
projects, _ := srv.Projects.List()
|
||||
fmt.Fprintf(os.Stderr, "demo seed ready (%d projects; login admin/admin, alice/alice)\n", len(projects))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "tm_server %s listening on %s\n", version.BuildVersion, listen)
|
||||
return http.ListenAndServe(listen, srv.Handler())
|
||||
}
|
||||
|
||||
func runImportJSON(args []string) error {
|
||||
fs := flag.NewFlagSet("import-json", flag.ExitOnError)
|
||||
dataDir := fs.String("data", "", "JSON DataDir to import (default from config)")
|
||||
dbPath := fs.String("db", "", "SQLite database path")
|
||||
owner := fs.String("owner", "", "username to own imported projects")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
db, cfg, err := openDB(*dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := bootstrapAdmin(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := *dataDir
|
||||
if dir == "" {
|
||||
dir = cfg.DataDir
|
||||
}
|
||||
var ownerID string
|
||||
if *owner != "" {
|
||||
u, err := db.GetUserByUsername(*owner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("owner: %w", err)
|
||||
}
|
||||
ownerID = u.ID
|
||||
} else {
|
||||
users, err := db.ListUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("no users; create admin first (TM_ADMIN_USER/TM_ADMIN_PASSWORD)")
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
ownerID = u.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if ownerID == "" {
|
||||
ownerID = users[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
srv := server.New(db)
|
||||
fmt.Fprintf(os.Stderr, "importing from %s …\n", dir)
|
||||
if err := srv.ImportJSONDataDir(dir, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "import done")
|
||||
return nil
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`tm_server — task-master web server
|
||||
|
||||
Usage:
|
||||
tm_server [flags]
|
||||
tm_server import-json [-data DIR] [-db PATH] [-owner USER]
|
||||
tm_server version
|
||||
tm_server help
|
||||
|
||||
Flags:
|
||||
-listen ADDR HTTP listen address (default :8080)
|
||||
-db PATH SQLite path (default <dataDir>/task-master.db)
|
||||
-secure Set Secure cookie flag
|
||||
|
||||
Env:
|
||||
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
|
||||
TASK_MASTER_SECRET encrypts remote tokens
|
||||
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
|
||||
TM_SEED_DEMO=1 seed local Test projects when DB empty (no YouTrack)
|
||||
`)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ require (
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
modernc.org/sqlite v1.61.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -25,23 +27,29 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/term v0.47.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -40,20 +40,26 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
@@ -69,6 +75,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -81,15 +91,50 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
|
||||
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -10,6 +10,12 @@ import (
|
||||
|
||||
const metaFileName = "meta.json"
|
||||
|
||||
const (
|
||||
BackendNone = "none"
|
||||
BackendYouTrack = "youtrack"
|
||||
BackendTaskMaster = "taskmaster"
|
||||
)
|
||||
|
||||
// YouTrack holds remote sync settings for a project.
|
||||
type YouTrack struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
@@ -34,9 +40,59 @@ func (y YouTrack) IssueQuery() string {
|
||||
return "project: " + strings.TrimSpace(y.ProjectKey)
|
||||
}
|
||||
|
||||
// TaskMaster holds sync settings for a remote tm_server project.
|
||||
type TaskMaster struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Token string `json:"token"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// Configured reports whether task-master sync can run.
|
||||
func (t TaskMaster) Configured() bool {
|
||||
return strings.TrimSpace(t.BaseURL) != "" &&
|
||||
strings.TrimSpace(t.Token) != "" &&
|
||||
strings.TrimSpace(t.ProjectID) != ""
|
||||
}
|
||||
|
||||
// Meta is per-project metadata stored beside tickets.json.
|
||||
type Meta struct {
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||
func (m Meta) EffectiveBackend() string {
|
||||
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
||||
switch b {
|
||||
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
||||
return b
|
||||
}
|
||||
if m.YouTrack != nil && m.YouTrack.Configured() {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
if m.YouTrack != nil {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
return BackendNone
|
||||
}
|
||||
|
||||
// SyncConfigured reports whether the active backend is fully set up.
|
||||
func (m Meta) SyncConfigured() bool {
|
||||
switch m.EffectiveBackend() {
|
||||
case BackendYouTrack:
|
||||
return m.YouTrack != nil && m.YouTrack.Configured()
|
||||
case BackendTaskMaster:
|
||||
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DirStore) metaPath(id string) string {
|
||||
@@ -63,6 +119,12 @@ func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
||||
}
|
||||
if meta.Backend == "" && meta.YouTrack != nil {
|
||||
meta.Backend = BackendYouTrack
|
||||
}
|
||||
if meta.Backend == "" && meta.TaskMaster != nil {
|
||||
meta.Backend = BackendTaskMaster
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -74,6 +136,9 @@ func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Backend == "" {
|
||||
meta.Backend = meta.EffectiveBackend()
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package project
|
||||
|
||||
// Project is a lightweight workspace identified by a directory name.
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
GroupID string `json:"groupId,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
u, err := s.DB.GetUserByUsername(body.Username)
|
||||
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "session failed")
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, exp)
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
_ = s.DB.DeleteSession(c.Value)
|
||||
}
|
||||
s.clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.DB.ListUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.DB.ListProjectGroups()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, groups)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
g, err := s.DB.CreateProjectGroup(body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, g)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RenameProjectGroup(id, body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
g, err := s.DB.GetProjectGroup(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, g)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.DB.DeleteProjectGroup(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
|
||||
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
|
||||
ps := project.NewDirStore(dataDir)
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(dataDir)
|
||||
|
||||
projects, err := ps.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range projects {
|
||||
if err := s.Projects.Ensure(p.ID); err != nil {
|
||||
return fmt.Errorf("project %s: %w", p.ID, err)
|
||||
}
|
||||
if ownerUserID != "" {
|
||||
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
|
||||
}
|
||||
meta, err := ps.LoadMeta(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
|
||||
return fmt.Errorf("meta %s: %w", p.ID, err)
|
||||
}
|
||||
}
|
||||
tickets, err := ts.List(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tickets {
|
||||
if _, err := s.Tickets.Get(t.ID); err == nil {
|
||||
continue // already imported
|
||||
}
|
||||
if _, err := s.Tickets.Create(t); err != nil {
|
||||
return fmt.Errorf("ticket %s: %w", t.ID, err)
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := timeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, _ := s.Times.List()
|
||||
have := map[string]bool{}
|
||||
for _, e := range existing {
|
||||
have[e.ID] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if have[e.ID] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Times.Create(e); err != nil {
|
||||
return fmt.Errorf("time %s: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
|
||||
_ = s.Times.SaveActiveTimer(active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestImportJSONDataDir(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
ps := project.NewDirStore(dataDir)
|
||||
if err := ps.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "import.db")
|
||||
db, err := sqlite.Open(dbPath, []byte("sec"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
u, err := db.CreateUser("admin", "x", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := srv.Tickets.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "From JSON" {
|
||||
t.Fatalf("title=%q", got.Title)
|
||||
}
|
||||
role, _ := db.MemberRole("inbox", u.ID)
|
||||
if role != sqlite.RoleOwner {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
// second import is idempotent
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, dest any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
userKey ctxKey = 1
|
||||
tokenAuthKey ctxKey = 2
|
||||
)
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
|
||||
u, ok := r.Context().Value(userKey).(sqlite.User)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
|
||||
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
raw := strings.TrimSpace(auth[len("Bearer "):])
|
||||
ta, err := s.DB.ResolveBearerToken(raw)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, ta.User)
|
||||
ctx = context.WithValue(ctx, tokenAuthKey, ta)
|
||||
return ctx, true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return r.Context(), false
|
||||
}
|
||||
u, err := s.DB.UserFromSession(c.Value)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return context.WithValue(r.Context(), userKey, u), true
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
u, _ := s.currentUser(r)
|
||||
if !u.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
|
||||
u, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
return sqlite.User{}, false, http.StatusUnauthorized
|
||||
}
|
||||
if ta, ok := s.currentTokenAuth(r); ok {
|
||||
if ta.ProjectID != projectID {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
need := sqlite.TokenScopeRead
|
||||
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
|
||||
need = sqlite.TokenScopeWrite
|
||||
}
|
||||
// viewer → read; member+ → write
|
||||
if minRole == sqlite.RoleViewer {
|
||||
need = sqlite.TokenScopeRead
|
||||
}
|
||||
if !sqlite.TokenAllows(ta.Scope, need) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
if u.IsAdmin {
|
||||
return u, true, 0
|
||||
}
|
||||
role, err := s.DB.MemberRole(projectID, u.ID)
|
||||
if err != nil || role == "" {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
if !sqlite.RoleAtLeast(role, minRole) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
GroupID string `json:"groupId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Create("", body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
if gid := strings.TrimSpace(body.GroupID); gid != "" {
|
||||
if err := s.Projects.SetGroup(p.ID, gid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
p.GroupID = gid
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
GroupID *string `json:"groupId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
if err := s.Projects.Rename(id, *body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.GroupID != nil {
|
||||
if err := s.Projects.SetGroup(id, strings.TrimSpace(*body.GroupID)); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
p, err := s.Projects.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
_ = s.Times.DeleteByProject(id)
|
||||
if err := s.Projects.Delete(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
members, err := s.DB.ListMembers(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RemoveMember(id, userID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
|
||||
if groupBy == "" {
|
||||
groupBy = ticket.GroupByTicket
|
||||
}
|
||||
valid := false
|
||||
for _, g := range ticket.AllGroupBys {
|
||||
if g == groupBy {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, http.StatusBadRequest, "invalid groupBy")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tickets, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byID := make(map[string]ticket.Ticket, len(tickets))
|
||||
for _, t := range tickets {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
rows := ticket.Aggregate(entries, byID, groupBy)
|
||||
total := ticket.TotalDuration(entries)
|
||||
|
||||
type rowJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
out := make([]rowJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowJSON{
|
||||
Key: row.Key,
|
||||
Label: row.Label,
|
||||
DurationNs: int64(row.Duration),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"groupBy": groupBy,
|
||||
"totalDurationNs": int64(total),
|
||||
"rows": out,
|
||||
"entryCount": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
|
||||
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
|
||||
func (s *Server) SeedDemo() error {
|
||||
projects, err := s.Projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
users, err := s.DB.ListUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("seed: no users (bootstrap admin first)")
|
||||
}
|
||||
admin := users[0]
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
admin = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
member, err := s.DB.CreateUser("alice", "alice", false)
|
||||
if err != nil {
|
||||
member, err = s.DB.GetUserByUsername("alice")
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed alice: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
type seedTicket struct {
|
||||
Title, Desc string
|
||||
Status ticket.Status
|
||||
}
|
||||
demos := []struct {
|
||||
Name string
|
||||
Tickets []seedTicket
|
||||
}{
|
||||
{
|
||||
Name: "Test",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
|
||||
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
|
||||
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
|
||||
{Title: "TUI anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", Status: ticket.StatusTodo},
|
||||
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Website Relaunch",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
|
||||
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
|
||||
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "API Platform",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
|
||||
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
|
||||
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
for i := len(demos) + 1; i <= 50; i++ {
|
||||
demos = append(demos, struct {
|
||||
Name string
|
||||
Tickets []seedTicket
|
||||
}{
|
||||
Name: fmt.Sprintf("Sample %02d", i),
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Starter ticket", Desc: "Seed-Platzhalter.", Status: ticket.StatusTodo},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
created := make([]struct{ ID, Name string }, 0, len(demos))
|
||||
for _, d := range demos {
|
||||
p, err := s.Projects.Create("", d.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created = append(created, struct{ ID, Name string }{p.ID, p.Name})
|
||||
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
|
||||
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
|
||||
// Explicit local-only sync (no remote backend in the test environment).
|
||||
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
||||
ProjectID: p.ID,
|
||||
Kind: "none",
|
||||
})
|
||||
|
||||
var firstID string
|
||||
for i, st := range d.Tickets {
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: st.Title,
|
||||
Description: st.Desc,
|
||||
Status: st.Status,
|
||||
ProjectID: p.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
|
||||
if i == 0 {
|
||||
firstID = t.ID
|
||||
}
|
||||
}
|
||||
if firstID != "" {
|
||||
_, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: firstID,
|
||||
ProjectID: p.ID,
|
||||
Start: now.Add(-2 * time.Hour),
|
||||
End: now.Add(-90 * time.Minute),
|
||||
Duration: 30 * time.Minute,
|
||||
Source: ticket.SourceManual,
|
||||
Note: "seed",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kunden, err := s.DB.CreateProjectGroup("Kunden")
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed group Kunden: %w", err)
|
||||
}
|
||||
intern, err := s.DB.CreateProjectGroup("Intern")
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed group Intern: %w", err)
|
||||
}
|
||||
for _, c := range created {
|
||||
switch {
|
||||
case c.Name == "Website Relaunch" || c.Name == "API Platform" || strings.HasPrefix(c.Name, "Sample 0"):
|
||||
_ = s.Projects.SetGroup(c.ID, kunden.ID)
|
||||
case c.Name == "Test" || strings.HasPrefix(c.Name, "Sample 1") || strings.HasPrefix(c.Name, "Sample 2"):
|
||||
_ = s.Projects.SetGroup(c.ID, intern.ID)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestSeedDemo(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "seed.db"), []byte("s"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "admin", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := srv.Projects.List()
|
||||
if err != nil || len(projects) != 50 {
|
||||
t.Fatalf("want 50 projects, got %d err=%v", len(projects), err)
|
||||
}
|
||||
var testID string
|
||||
for _, p := range projects {
|
||||
if p.Name == "Test" {
|
||||
testID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if testID == "" {
|
||||
t.Fatal("missing Test project")
|
||||
}
|
||||
groups, err := db.ListProjectGroups()
|
||||
if err != nil || len(groups) != 2 {
|
||||
t.Fatalf("want 2 groups, got %d err=%v", len(groups), err)
|
||||
}
|
||||
p, err := srv.Projects.Get(testID)
|
||||
if err != nil || p.GroupID == "" {
|
||||
t.Fatalf("Test project should be grouped, got %+v err=%v", p, err)
|
||||
}
|
||||
sync, err := srv.Projects.GetSyncBackend(testID)
|
||||
if err != nil || sync.Kind != "none" {
|
||||
t.Fatalf("sync=%+v err=%v", sync, err)
|
||||
}
|
||||
tickets, err := srv.Tickets.List(testID)
|
||||
if err != nil || len(tickets) == 0 {
|
||||
t.Fatalf("tickets empty")
|
||||
}
|
||||
conns, err := db.ListConnections()
|
||||
if err != nil || len(conns) != 0 {
|
||||
t.Fatalf("expected no remote connections, got %v", conns)
|
||||
}
|
||||
// idempotent when projects already exist
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects2, _ := srv.Projects.List()
|
||||
if len(projects2) != len(projects) {
|
||||
t.Fatalf("re-seed changed project count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
"task/internal/webembed"
|
||||
)
|
||||
|
||||
// Server is the HTTP API + embedded UI.
|
||||
type Server struct {
|
||||
DB *sqlite.DB
|
||||
Projects *sqlite.ProjectStore
|
||||
Tickets *sqlite.TicketStore
|
||||
Times *sqlite.TimeStore
|
||||
Sync *syncbackend.Registry
|
||||
Secure bool // set Secure on cookies when behind TLS
|
||||
Static fs.FS
|
||||
}
|
||||
|
||||
// New constructs a server from an open DB.
|
||||
func New(db *sqlite.DB) *Server {
|
||||
return &Server{
|
||||
DB: db,
|
||||
Projects: sqlite.NewProjectStore(db),
|
||||
Tickets: sqlite.NewTicketStore(db),
|
||||
Times: sqlite.NewTimeStore(db),
|
||||
Sync: syncbackend.NewRegistry(),
|
||||
Static: webembed.FS(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
api.HandleFunc("POST /auth/login", s.handleLogin)
|
||||
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
|
||||
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
|
||||
|
||||
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
|
||||
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
|
||||
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
|
||||
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
|
||||
|
||||
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
|
||||
|
||||
api.HandleFunc("GET /project-groups", s.requireAuth(s.handleListGroups))
|
||||
api.HandleFunc("POST /project-groups", s.requireAuth(s.handleCreateGroup))
|
||||
api.HandleFunc("PATCH /project-groups/{id}", s.requireAuth(s.handlePatchGroup))
|
||||
api.HandleFunc("DELETE /project-groups/{id}", s.requireAuth(s.handleDeleteGroup))
|
||||
|
||||
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
|
||||
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
|
||||
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
|
||||
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
|
||||
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
|
||||
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
|
||||
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
|
||||
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
|
||||
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
|
||||
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
|
||||
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
|
||||
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
|
||||
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
|
||||
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
|
||||
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
|
||||
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
|
||||
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
|
||||
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
|
||||
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
|
||||
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
|
||||
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
|
||||
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
|
||||
|
||||
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
|
||||
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
|
||||
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
|
||||
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
|
||||
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
|
||||
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
|
||||
mux.Handle("/", s.spaHandler())
|
||||
}
|
||||
|
||||
const sessionCookie = "tm_session"
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) spaHandler() http.Handler {
|
||||
static := s.Static
|
||||
if static == nil {
|
||||
static = webembed.FS()
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(static))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if f, err := static.Open(path); err == nil {
|
||||
_ = f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestAPIAuthAndTickets(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()
|
||||
if len(cookie) == 0 {
|
||||
t.Fatal("no session cookie")
|
||||
}
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
if pid == "" {
|
||||
t.Fatal("expected auto project id")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ticket map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||
tid, _ := ticket["id"].(string)
|
||||
if tid == "" {
|
||||
t.Fatal("no ticket id")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revisions %d", rec.Code)
|
||||
}
|
||||
var revs []any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
|
||||
if len(revs) < 1 {
|
||||
t.Fatal("expected revision")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("health %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
b, err := s.Projects.GetSyncBackend(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body sqlite.SyncBackend
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.ProjectID = id
|
||||
if body.Kind == "" {
|
||||
body.Kind = "none"
|
||||
}
|
||||
if err := s.Projects.SetSyncBackend(body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
|
||||
b, err := s.Projects.GetSyncBackend(projectID)
|
||||
if err != nil {
|
||||
return syncbackend.ProjectConfig{}, err
|
||||
}
|
||||
cfg := syncbackend.ProjectConfig{
|
||||
Kind: b.Kind,
|
||||
BaseURL: b.BaseURL,
|
||||
ProjectKey: b.ProjectKey,
|
||||
Query: b.Query,
|
||||
}
|
||||
if b.ConnectionID != "" {
|
||||
conn, err := s.DB.GetConnection(b.ConnectionID)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.Token = conn.Token
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = conn.BaseURL
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
cfg, err := s.projectSyncConfig(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(cfg.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var res syncbackend.Result
|
||||
if pull {
|
||||
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
} else {
|
||||
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
op := "sync_push"
|
||||
if pull {
|
||||
op = "sync_pull"
|
||||
}
|
||||
tickets, _ := s.Tickets.List(id)
|
||||
for _, t := range tickets {
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.DB.ListConnections()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Scope string `json:"scope"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Kind == "" {
|
||||
body.Kind = "youtrack"
|
||||
}
|
||||
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.DB.DeleteConnection(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
conn, err := s.DB.GetConnection(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(conn.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
|
||||
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
connID := r.PathValue("id")
|
||||
var body struct {
|
||||
RemoteKey string `json:"remoteKey"`
|
||||
LocalID string `json:"localId"`
|
||||
Name string `json:"name"`
|
||||
Pull bool `json:"pull"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
|
||||
if body.RemoteKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "remoteKey required")
|
||||
return
|
||||
}
|
||||
conn, err := s.DB.GetConnection(connID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.RemoteKey
|
||||
}
|
||||
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
|
||||
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
||||
ProjectID: p.ID,
|
||||
Kind: conn.Kind,
|
||||
BaseURL: conn.BaseURL,
|
||||
ProjectKey: body.RemoteKey,
|
||||
ConnectionID: conn.ID,
|
||||
})
|
||||
result := map[string]any{"project": p}
|
||||
if body.Pull {
|
||||
cfg, err := s.projectSyncConfig(p.ID)
|
||||
if err == nil {
|
||||
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
|
||||
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
|
||||
if err != nil {
|
||||
result["pullError"] = err.Error()
|
||||
} else {
|
||||
result["pull"] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Status: body.Status,
|
||||
ProjectID: id,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
t.Title = *body.Title
|
||||
}
|
||||
if body.Description != nil {
|
||||
t.Description = *body.Description
|
||||
}
|
||||
if body.Status != nil {
|
||||
t.Status = *body.Status
|
||||
}
|
||||
if err := s.Tickets.Update(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
t, _ = s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
|
||||
if err := s.Tickets.Delete(tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden target project")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
moved, err := s.Tickets.Move(tid, body.ToProjectID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
|
||||
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
|
||||
writeJSON(w, http.StatusOK, moved)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
revs, err := s.DB.ListRevisions("ticket", tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
rid := r.PathValue("rid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var revID int64
|
||||
if _, err := parseInt64(rid, &revID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid revision id")
|
||||
return
|
||||
}
|
||||
rev, err := s.DB.GetRevision(revID)
|
||||
if err != nil || rev.EntityID != tid {
|
||||
writeError(w, http.StatusNotFound, "revision not found")
|
||||
return
|
||||
}
|
||||
var snap ticket.Ticket
|
||||
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "bad snapshot")
|
||||
return
|
||||
}
|
||||
snap.ID = tid
|
||||
snap.ProjectID = id
|
||||
if err := s.Tickets.Update(snap); err != nil {
|
||||
// recreate if deleted
|
||||
if _, cerr := s.Tickets.Create(snap); cerr != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t, _ := s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func parseInt64(s string, dest *int64) (int64, error) {
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errInvalid
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
*dest = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type invalidErr struct{}
|
||||
|
||||
func (invalidErr) Error() string { return "invalid" }
|
||||
|
||||
var errInvalid = invalidErr{}
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: id,
|
||||
Duration: time.Duration(body.Duration),
|
||||
Note: body.Note,
|
||||
Source: body.Source,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusCreated, e)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var found *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == eid {
|
||||
found = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if body.Duration > 0 {
|
||||
found.Duration = time.Duration(body.Duration)
|
||||
}
|
||||
found.Note = body.Note
|
||||
if err := s.Times.Update(*found); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(id)
|
||||
for _, e := range entries {
|
||||
if e.ID == eid {
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := s.Times.Delete(eid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
|
||||
}
|
||||
|
||||
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t := &ticket.ActiveTimer{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: body.ProjectID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.Times.SaveActiveTimer(t); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = u
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
active, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if active == nil {
|
||||
writeError(w, http.StatusBadRequest, "no active timer")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
dur := time.Since(active.StartedAt)
|
||||
if dur < time.Second {
|
||||
dur = time.Second
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: active.TicketID,
|
||||
ProjectID: active.ProjectID,
|
||||
Start: active.StartedAt,
|
||||
End: time.Now().UTC(),
|
||||
Duration: dur,
|
||||
Source: ticket.SourceTimer,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.SaveActiveTimer(nil)
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
list, err := s.DB.ListProjectTokens(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Scope == "" {
|
||||
body.Scope = sqlite.TokenScopeWrite
|
||||
}
|
||||
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.IsAdmin == nil {
|
||||
writeError(w, http.StatusBadRequest, "isAdmin required")
|
||||
return
|
||||
}
|
||||
if id == me.ID && !*body.IsAdmin {
|
||||
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.DB.GetUserByID(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
if id == me.ID {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
if err := s.DB.DeleteUser(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleDashboard returns a lightweight overview for the front page.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
type openTicket struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Project string `json:"projectName"`
|
||||
}
|
||||
type projSum struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OpenCount int `json:"openCount"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
open := make([]openTicket, 0)
|
||||
sums := make([]projSum, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
tickets, err := s.Tickets.List(p.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
openCount := 0
|
||||
for _, t := range tickets {
|
||||
if string(t.Status) != "done" {
|
||||
openCount++
|
||||
if len(open) < 20 {
|
||||
open = append(open, openTicket{
|
||||
ID: t.ID, Title: t.Title, Status: string(t.Status),
|
||||
ProjectID: p.ID, Project: p.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(p.ID)
|
||||
var dur int64
|
||||
for _, e := range entries {
|
||||
dur += int64(e.Duration)
|
||||
}
|
||||
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"projects": sums,
|
||||
"openTickets": open,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestProjectTokenBearerAuth(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
admin, err := db.CreateUser("admin", "pass", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid := proj["id"].(string)
|
||||
|
||||
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create token %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tok map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
|
||||
raw := tok["token"].(string)
|
||||
if raw == "" {
|
||||
t.Fatal("expected raw token")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer list %d", rec.Code)
|
||||
}
|
||||
|
||||
// delete other user
|
||||
_, _ = db.CreateUser("bob", "x", false)
|
||||
users, _ := db.ListUsers()
|
||||
var bobID string
|
||||
for _, u := range users {
|
||||
if u.Username == "bob" {
|
||||
bobID = u.ID
|
||||
}
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("should not demote self")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ProjectGroup is a folder for organizing projects in the UI tree.
|
||||
type ProjectGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// ListProjectGroups returns all groups sorted by sort_order then name.
|
||||
func (db *DB) ListProjectGroups() ([]ProjectGroup, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, name, sort_order FROM project_groups
|
||||
ORDER BY sort_order ASC, name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list project groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProjectGroup
|
||||
for rows.Next() {
|
||||
var g ProjectGroup
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProjectGroup{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateProjectGroup inserts a new group.
|
||||
func (db *DB) CreateProjectGroup(name string) (ProjectGroup, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ProjectGroup{}, fmt.Errorf("name is required")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
var maxOrd sql.NullInt64
|
||||
_ = db.SQL.QueryRow(`SELECT MAX(sort_order) FROM project_groups`).Scan(&maxOrd)
|
||||
ord := 0
|
||||
if maxOrd.Valid {
|
||||
ord = int(maxOrd.Int64) + 1
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := db.SQL.Exec(`INSERT INTO project_groups(id, name, sort_order, created_at) VALUES(?, ?, ?, ?)`,
|
||||
id, name, ord, now)
|
||||
if err != nil {
|
||||
return ProjectGroup{}, fmt.Errorf("create project group: %w", err)
|
||||
}
|
||||
return ProjectGroup{ID: id, Name: name, SortOrder: ord}, nil
|
||||
}
|
||||
|
||||
// RenameProjectGroup updates the group name.
|
||||
func (db *DB) RenameProjectGroup(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if id == "" || name == "" {
|
||||
return fmt.Errorf("id and name are required")
|
||||
}
|
||||
res, err := db.SQL.Exec(`UPDATE project_groups SET name = ? WHERE id = ?`, name, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProjectGroup removes a group; projects in it become ungrouped.
|
||||
func (db *DB) DeleteProjectGroup(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
tx, err := db.SQL.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.Exec(`UPDATE projects SET group_id = NULL WHERE group_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("ungroup projects: %w", err)
|
||||
}
|
||||
res, err := tx.Exec(`DELETE FROM project_groups WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete group %s: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetProjectGroup returns one group.
|
||||
func (db *DB) GetProjectGroup(id string) (ProjectGroup, error) {
|
||||
var g ProjectGroup
|
||||
err := db.SQL.QueryRow(`SELECT id, name, sort_order FROM project_groups WHERE id = ?`, id).
|
||||
Scan(&g.ID, &g.Name, &g.SortOrder)
|
||||
if err == sql.ErrNoRows {
|
||||
return ProjectGroup{}, fmt.Errorf("group %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return ProjectGroup{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS project_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_groups_sort ON project_groups(sort_order, name COLLATE NOCASE);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_group ON projects(group_id);
|
||||
@@ -0,0 +1,359 @@
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package syncbackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// RemoteProject is a project discovered on a remote backend.
|
||||
type RemoteProject struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// Connection credentials for a remote instance or project.
|
||||
type Connection struct {
|
||||
Kind string
|
||||
BaseURL string
|
||||
Token string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// ProjectConfig is the per-local-project sync settings.
|
||||
type ProjectConfig struct {
|
||||
Kind string
|
||||
BaseURL string
|
||||
Token string
|
||||
ProjectKey string
|
||||
Query string
|
||||
}
|
||||
|
||||
// Result reuses ytsync.Result for pull/push summaries.
|
||||
type Result = ytsync.Result
|
||||
|
||||
// Backend syncs tickets with an external system.
|
||||
type Backend interface {
|
||||
Kind() string
|
||||
ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error)
|
||||
Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
|
||||
Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
|
||||
}
|
||||
|
||||
// Registry maps kind → Backend.
|
||||
type Registry struct {
|
||||
backends map[string]Backend
|
||||
}
|
||||
|
||||
// NewRegistry returns a registry with built-in backends.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{backends: map[string]Backend{}}
|
||||
r.Register(&NoneBackend{})
|
||||
r.Register(&YouTrackBackend{})
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds a backend.
|
||||
func (r *Registry) Register(b Backend) {
|
||||
r.backends[b.Kind()] = b
|
||||
}
|
||||
|
||||
// Get returns a backend by kind.
|
||||
func (r *Registry) Get(kind string) (Backend, error) {
|
||||
if kind == "" {
|
||||
kind = "none"
|
||||
}
|
||||
b, ok := r.backends[kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown sync backend %q", kind)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// NoneBackend disables sync.
|
||||
type NoneBackend struct{}
|
||||
|
||||
func (NoneBackend) Kind() string { return "none" }
|
||||
|
||||
func (NoneBackend) ListProjects(context.Context, Connection) ([]RemoteProject, error) {
|
||||
return nil, fmt.Errorf("no remote projects for kind none")
|
||||
}
|
||||
|
||||
func (NoneBackend) Pull(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
|
||||
return Result{}, fmt.Errorf("sync disabled")
|
||||
}
|
||||
|
||||
func (NoneBackend) Push(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
|
||||
return Result{}, fmt.Errorf("sync disabled")
|
||||
}
|
||||
|
||||
// YouTrackBackend wraps existing ytsync.
|
||||
type YouTrackBackend struct{}
|
||||
|
||||
func (YouTrackBackend) Kind() string { return "youtrack" }
|
||||
|
||||
func (YouTrackBackend) ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error) {
|
||||
return ListYouTrackProjects(ctx, conn.BaseURL, conn.Token)
|
||||
}
|
||||
|
||||
func (YouTrackBackend) Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
|
||||
yt := toYouTrack(cfg)
|
||||
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
|
||||
return s.Pull(ctx, projectID, yt)
|
||||
}
|
||||
|
||||
func (YouTrackBackend) Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
|
||||
yt := toYouTrack(cfg)
|
||||
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
|
||||
return s.Push(ctx, projectID, yt)
|
||||
}
|
||||
|
||||
func toYouTrack(cfg ProjectConfig) project.YouTrack {
|
||||
return project.YouTrack{
|
||||
BaseURL: cfg.BaseURL,
|
||||
Token: cfg.Token,
|
||||
ProjectKey: cfg.ProjectKey,
|
||||
Query: cfg.Query,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package syncbackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"task/internal/youtrack"
|
||||
)
|
||||
|
||||
// ListYouTrackProjects lists projects via a master/instance token.
|
||||
func ListYouTrackProjects(ctx context.Context, baseURL, token string) ([]RemoteProject, error) {
|
||||
client := &youtrack.Client{BaseURL: baseURL, Token: token}
|
||||
projects, err := client.ListProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RemoteProject, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = p.ShortName
|
||||
}
|
||||
out = append(out, RemoteProject{
|
||||
Key: p.ShortName,
|
||||
Name: name,
|
||||
ID: p.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -53,6 +53,8 @@ func (s Status) Index() int {
|
||||
const (
|
||||
// SourceYouTrack marks tickets imported from YouTrack.
|
||||
SourceYouTrack = "youtrack"
|
||||
// SourceTaskMaster marks tickets synced from a task-master server.
|
||||
SourceTaskMaster = "taskmaster"
|
||||
)
|
||||
|
||||
// Ticket is the domain model for a work item.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package tmsync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// Result reuses ytsync.Result for consistent CLI/UI messaging.
|
||||
type Result = ytsync.Result
|
||||
|
||||
// Syncer syncs a local project with a remote tm_server project.
|
||||
type Syncer struct {
|
||||
Store ticket.Store
|
||||
TimeStore ticket.TimeStore
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (s *Syncer) http() *http.Client {
|
||||
if s.HTTP != nil {
|
||||
return s.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// NeedsPush reports whether a local ticket should be pushed to task-master.
|
||||
func NeedsPush(t ticket.Ticket) bool {
|
||||
if t.ExternalID == "" {
|
||||
return true
|
||||
}
|
||||
if t.Source != ticket.SourceTaskMaster {
|
||||
return false
|
||||
}
|
||||
if t.SyncedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return t.UpdatedAt.After(t.SyncedAt)
|
||||
}
|
||||
|
||||
// Pull imports remote tickets/time into localProjectID.
|
||||
func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||
if !tm.Configured() {
|
||||
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||
}
|
||||
if s.Store == nil {
|
||||
return Result{}, fmt.Errorf("ticket store required")
|
||||
}
|
||||
remote, err := s.listTickets(ctx, tm)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
existing, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
byExt := map[string]ticket.Ticket{}
|
||||
for _, t := range existing {
|
||||
if t.ExternalID != "" {
|
||||
byExt[t.ExternalID] = t
|
||||
}
|
||||
}
|
||||
var res Result
|
||||
res.Total = len(remote)
|
||||
for _, rt := range remote {
|
||||
if rt.ID == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID
|
||||
if prev, ok := byExt[rt.ID]; ok {
|
||||
if NeedsPush(prev) {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
next := prev
|
||||
next.Title = rt.Title
|
||||
next.Description = rt.Description
|
||||
next.Status = rt.Status
|
||||
next.Source = ticket.SourceTaskMaster
|
||||
next.ExternalURL = extURL
|
||||
if sameFields(prev, next) {
|
||||
_ = s.Store.MarkSynced(prev.ID)
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.Store.Update(next); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(next.ID)
|
||||
res.Updated++
|
||||
continue
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err := s.Store.Create(ticket.Ticket{
|
||||
Title: rt.Title, Description: rt.Description, Status: rt.Status,
|
||||
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
||||
ExternalID: rt.ID, ExternalURL: extURL,
|
||||
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Created++
|
||||
}
|
||||
if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Push sends dirty local tickets/time to the remote project.
|
||||
func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||
if !tm.Configured() {
|
||||
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||
}
|
||||
if s.Store == nil {
|
||||
return Result{}, fmt.Errorf("ticket store required")
|
||||
}
|
||||
existing, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var res Result
|
||||
for _, t := range existing {
|
||||
if !NeedsPush(t) {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
res.Total++
|
||||
if t.ExternalID == "" {
|
||||
created, err := s.createRemote(ctx, tm, t)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
t.ExternalID = created.ID
|
||||
t.Source = ticket.SourceTaskMaster
|
||||
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
||||
if err := s.Store.Update(t); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(t.ID)
|
||||
res.Created++
|
||||
res.Pushed++
|
||||
continue
|
||||
}
|
||||
if err := s.patchRemote(ctx, tm, t); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(t.ID)
|
||||
res.Updated++
|
||||
res.Pushed++
|
||||
}
|
||||
if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sameFields(a, b ticket.Ticket) bool {
|
||||
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
|
||||
}
|
||||
|
||||
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
|
||||
var out []ticket.Ticket
|
||||
err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) {
|
||||
body := map[string]any{
|
||||
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||
}
|
||||
var created ticket.Ticket
|
||||
err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created)
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error {
|
||||
body := map[string]any{
|
||||
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||
}
|
||||
return s.doJSON(ctx, tm, http.MethodPatch,
|
||||
"/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil)
|
||||
}
|
||||
|
||||
func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||
if s.TimeStore == nil {
|
||||
return nil
|
||||
}
|
||||
var remote []ticket.TimeEntry
|
||||
if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil {
|
||||
return err
|
||||
}
|
||||
localTickets, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localByExt := map[string]ticket.Ticket{}
|
||||
for _, t := range localTickets {
|
||||
if t.ExternalID != "" {
|
||||
localByExt[t.ExternalID] = t
|
||||
}
|
||||
}
|
||||
existing, err := s.TimeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, e := range existing {
|
||||
if e.ExternalID != "" {
|
||||
have[e.ExternalID] = true
|
||||
}
|
||||
}
|
||||
for _, e := range remote {
|
||||
if e.ID == "" || have[e.ID] {
|
||||
continue
|
||||
}
|
||||
lt, ok := localByExt[e.TicketID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := s.TimeStore.Create(ticket.TimeEntry{
|
||||
TicketID: lt.ID, ProjectID: localProjectID,
|
||||
Start: e.Start, End: e.End, Duration: e.Duration,
|
||||
Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.TimePulled++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||
if s.TimeStore == nil {
|
||||
return nil
|
||||
}
|
||||
tickets, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byID := map[string]ticket.Ticket{}
|
||||
for _, t := range tickets {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
entries, err := s.TimeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ProjectID != localProjectID || e.ExternalID != "" {
|
||||
continue
|
||||
}
|
||||
t, ok := byID[e.TicketID]
|
||||
if !ok || t.ExternalID == "" {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{
|
||||
"ticketId": t.ExternalID,
|
||||
"durationNs": int64(e.Duration),
|
||||
"note": e.Note,
|
||||
"source": e.Source,
|
||||
}
|
||||
var created ticket.TimeEntry
|
||||
if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
e.ExternalID = created.ID
|
||||
if err := s.TimeStore.Update(e); err != nil {
|
||||
return err
|
||||
}
|
||||
res.TimePushed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(data)
|
||||
}
|
||||
url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tm.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := s.http().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("taskmaster: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var eresp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &eresp)
|
||||
msg := eresp.Error
|
||||
if msg == "" {
|
||||
msg = string(data)
|
||||
}
|
||||
return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg)
|
||||
}
|
||||
if dest == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, dest)
|
||||
}
|
||||
+44
-10
@@ -19,6 +19,7 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
@@ -826,6 +827,14 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.projectEdit.PrevField()
|
||||
return m, textinput.Blink
|
||||
|
||||
case msg.Type == tea.KeyLeft:
|
||||
m.projectEdit.CycleBackend(-1)
|
||||
return m, nil
|
||||
|
||||
case msg.Type == tea.KeyRight:
|
||||
m.projectEdit.CycleBackend(1)
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
|
||||
m.setError(err)
|
||||
@@ -842,10 +851,10 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeProjects
|
||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
|
||||
if meta.SyncConfigured() {
|
||||
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
|
||||
} else {
|
||||
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id))
|
||||
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -866,12 +875,21 @@ func (m *Model) openProjectEdit(projectID string) error {
|
||||
|
||||
func (m *Model) openSyncMenu(projectID string) {
|
||||
m.syncProjectID = projectID
|
||||
label := "Sync"
|
||||
if meta, err := m.projects.LoadMeta(projectID); err == nil {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
label = "task-master"
|
||||
case project.BackendYouTrack:
|
||||
label = "YouTrack"
|
||||
}
|
||||
}
|
||||
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
}, label)
|
||||
m.mode = ModeSync
|
||||
}
|
||||
|
||||
@@ -925,11 +943,27 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
return m, func() tea.Msg {
|
||||
var pull, push ytsync.Result
|
||||
var err error
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
default:
|
||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
meta, merr := projects.LoadMeta(projectID)
|
||||
if merr != nil {
|
||||
err = merr
|
||||
} else {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
|
||||
default:
|
||||
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
default:
|
||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
||||
}
|
||||
@@ -1451,7 +1485,7 @@ func (m *Model) openProjects() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured()
|
||||
yt[p.ID] = meta.SyncConfigured()
|
||||
}
|
||||
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
||||
return nil
|
||||
|
||||
@@ -34,7 +34,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"enter", "select"},
|
||||
{"n", "new project"},
|
||||
{"e", "edit"},
|
||||
{"Y", "YouTrack"},
|
||||
{"Y", "Sync"},
|
||||
{"x", "delete"},
|
||||
{"X", "force delete"},
|
||||
{"esc", "cancel"},
|
||||
@@ -121,7 +121,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"t/T", "timer/log"},
|
||||
{"m", "entries"},
|
||||
{"r", "report"},
|
||||
{"Y", "YouTrack"},
|
||||
{"Y", "Sync"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectEdit field indices.
|
||||
// ProjectEdit field indices (backend picker is separate).
|
||||
const (
|
||||
FieldRemoteURL = iota
|
||||
FieldToken
|
||||
@@ -19,6 +19,12 @@ const (
|
||||
ProjectEditFieldCount
|
||||
)
|
||||
|
||||
var backendOptions = []string{
|
||||
project.BackendNone,
|
||||
project.BackendYouTrack,
|
||||
project.BackendTaskMaster,
|
||||
}
|
||||
|
||||
// ProjectEditStyles styles the project edit form.
|
||||
type ProjectEditStyles struct {
|
||||
Label lipgloss.Style
|
||||
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectEditForm is a multi-field editor for YouTrack project settings.
|
||||
// ProjectEditForm is a multi-field editor for sync backend settings.
|
||||
type ProjectEditForm struct {
|
||||
ProjectID string
|
||||
Backend string
|
||||
BackendIdx int
|
||||
FocusBackend bool // true when selecting backend
|
||||
Inputs []textinput.Model
|
||||
Focus int
|
||||
Styles ProjectEditStyles
|
||||
@@ -67,34 +76,82 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
|
||||
return ti
|
||||
}
|
||||
|
||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||
backend := meta.EffectiveBackend()
|
||||
if backend == "" {
|
||||
backend = project.BackendNone
|
||||
}
|
||||
bIdx := 0
|
||||
for i, b := range backendOptions {
|
||||
if b == backend {
|
||||
bIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hadToken := false
|
||||
if meta.YouTrack != nil {
|
||||
yt := meta.YouTrack
|
||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||
if yt.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
switch backend {
|
||||
case project.BackendTaskMaster:
|
||||
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
|
||||
inputs[FieldToken] = mk("tm_… project token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
|
||||
if meta.TaskMaster != nil {
|
||||
tm := meta.TaskMaster
|
||||
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(tm.ProjectID)
|
||||
if tm.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
}
|
||||
}
|
||||
default:
|
||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||
if meta.YouTrack != nil {
|
||||
yt := meta.YouTrack
|
||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||
if yt.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f := ProjectEditForm{
|
||||
ProjectID: projectID,
|
||||
Inputs: inputs,
|
||||
Focus: FieldRemoteURL,
|
||||
Styles: styles,
|
||||
Width: width,
|
||||
hadToken: hadToken,
|
||||
ProjectID: projectID,
|
||||
Backend: backend,
|
||||
BackendIdx: bIdx,
|
||||
FocusBackend: true,
|
||||
Inputs: inputs,
|
||||
Focus: FieldRemoteURL,
|
||||
Styles: styles,
|
||||
Width: width,
|
||||
hadToken: hadToken,
|
||||
}
|
||||
f.focusIndex(FieldRemoteURL)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) applyBackendPlaceholders() {
|
||||
f.Backend = backendOptions[f.BackendIdx]
|
||||
switch f.Backend {
|
||||
case project.BackendTaskMaster:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
|
||||
f.Inputs[FieldToken].Placeholder = "tm_… project token"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
|
||||
case project.BackendYouTrack:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
|
||||
f.Inputs[FieldToken].Placeholder = "permanent token"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
|
||||
default:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
|
||||
f.Inputs[FieldToken].Placeholder = "(unused)"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) focusIndex(i int) {
|
||||
f.FocusBackend = false
|
||||
f.Focus = i
|
||||
for idx := range f.Inputs {
|
||||
if idx == i {
|
||||
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
|
||||
|
||||
// NextField moves focus to the next field.
|
||||
func (f *ProjectEditForm) NextField() {
|
||||
if f.FocusBackend {
|
||||
if f.Backend == project.BackendNone {
|
||||
return
|
||||
}
|
||||
f.focusIndex(FieldRemoteURL)
|
||||
return
|
||||
}
|
||||
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
// PrevField moves focus to the previous field.
|
||||
func (f *ProjectEditForm) PrevField() {
|
||||
if f.FocusBackend {
|
||||
return
|
||||
}
|
||||
if f.Focus == FieldRemoteURL {
|
||||
f.blurInputs()
|
||||
f.FocusBackend = true
|
||||
return
|
||||
}
|
||||
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) blurInputs() {
|
||||
for idx := range f.Inputs {
|
||||
f.Inputs[idx].Blur()
|
||||
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||
}
|
||||
}
|
||||
|
||||
// CycleBackend changes the selected backend when focusing the picker.
|
||||
func (f *ProjectEditForm) CycleBackend(delta int) {
|
||||
if !f.FocusBackend {
|
||||
return
|
||||
}
|
||||
n := len(backendOptions)
|
||||
f.BackendIdx = (f.BackendIdx + delta + n) % n
|
||||
f.applyBackendPlaceholders()
|
||||
}
|
||||
|
||||
// Update forwards a message to the focused text input.
|
||||
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||
if f.FocusBackend || f.Backend == project.BackendNone {
|
||||
return nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
||||
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||
|
||||
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
||||
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
||||
backend := backendOptions[f.BackendIdx]
|
||||
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
||||
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
||||
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
||||
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{YouTrack: nil}
|
||||
switch backend {
|
||||
case project.BackendNone:
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
case project.BackendTaskMaster:
|
||||
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
|
||||
if token == "" && previous.TaskMaster != nil {
|
||||
tm.Token = previous.TaskMaster.Token
|
||||
}
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
}
|
||||
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
|
||||
default:
|
||||
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
|
||||
if token == "" && previous.YouTrack != nil {
|
||||
yt.Token = previous.YouTrack.Token
|
||||
}
|
||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||
yt.Query = previous.YouTrack.Query
|
||||
}
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
}
|
||||
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
|
||||
}
|
||||
|
||||
yt := &project.YouTrack{
|
||||
BaseURL: baseURL,
|
||||
ProjectKey: key,
|
||||
Token: token,
|
||||
}
|
||||
if token == "" && previous.YouTrack != nil {
|
||||
yt.Token = previous.YouTrack.Token
|
||||
}
|
||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||
yt.Query = previous.YouTrack.Query
|
||||
}
|
||||
return project.Meta{YouTrack: yt}
|
||||
}
|
||||
|
||||
// Validate ensures partial YouTrack configs are rejected.
|
||||
// Validate ensures partial configs are rejected.
|
||||
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
||||
meta := f.Meta(previous)
|
||||
if meta.YouTrack == nil {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendNone:
|
||||
return nil
|
||||
case project.BackendTaskMaster:
|
||||
tm := meta.TaskMaster
|
||||
if tm == nil {
|
||||
return nil
|
||||
}
|
||||
if !tm.Configured() {
|
||||
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
yt := meta.YouTrack
|
||||
if yt == nil {
|
||||
return nil
|
||||
}
|
||||
if !yt.Configured() {
|
||||
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
yt := meta.YouTrack
|
||||
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
|
||||
if any && !yt.Configured() {
|
||||
missing := []string{}
|
||||
if yt.BaseURL == "" {
|
||||
missing = append(missing, "remote URL")
|
||||
}
|
||||
if yt.Token == "" {
|
||||
missing = append(missing, "token")
|
||||
}
|
||||
if yt.ProjectKey == "" {
|
||||
missing = append(missing, "project key")
|
||||
}
|
||||
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (f ProjectEditForm) View() string {
|
||||
labels := []string{"Remote URL", "Token", "Project key"}
|
||||
var b strings.Builder
|
||||
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
|
||||
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
backendLine := "Backend: "
|
||||
for i, opt := range backendOptions {
|
||||
label := opt
|
||||
if i == f.BackendIdx {
|
||||
if f.FocusBackend {
|
||||
label = f.Styles.Focused.Render("[" + opt + "]")
|
||||
} else {
|
||||
label = f.Styles.Focused.Render(opt)
|
||||
}
|
||||
} else {
|
||||
label = f.Styles.Blurred.Render(opt)
|
||||
}
|
||||
if i > 0 {
|
||||
backendLine += " "
|
||||
}
|
||||
backendLine += label
|
||||
}
|
||||
b.WriteString(backendLine)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if f.Backend == project.BackendNone {
|
||||
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
labels := []string{"Remote URL", "Token", "Project key"}
|
||||
if f.Backend == project.BackendTaskMaster {
|
||||
labels = []string{"Server URL", "Project token", "Remote project ID"}
|
||||
}
|
||||
for i, label := range labels {
|
||||
b.WriteString(f.Styles.Label.Render(label + ":"))
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||
prev := project.Meta{
|
||||
Backend: project.BackendYouTrack,
|
||||
YouTrack: &project.YouTrack{
|
||||
BaseURL: "https://yt.example",
|
||||
Token: "secret",
|
||||
@@ -31,10 +32,27 @@ func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProjectEditValidateIncomplete(t *testing.T) {
|
||||
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
|
||||
f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
|
||||
f.BackendIdx = 1 // youtrack
|
||||
f.Backend = project.BackendYouTrack
|
||||
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||
// missing token + key
|
||||
if err := f.Validate(project.Meta{}); err == nil {
|
||||
t.Fatal("expected incomplete error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEditTaskMaster(t *testing.T) {
|
||||
prev := project.Meta{
|
||||
Backend: project.BackendTaskMaster,
|
||||
TaskMaster: &project.TaskMaster{
|
||||
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
|
||||
},
|
||||
}
|
||||
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||
f.Inputs[views.FieldToken].SetValue("")
|
||||
meta := f.Meta(prev)
|
||||
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
|
||||
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SyncAction is a YouTrack sync menu choice.
|
||||
// SyncAction is a sync menu choice.
|
||||
type SyncAction string
|
||||
|
||||
const (
|
||||
@@ -21,27 +21,30 @@ type SyncOption struct {
|
||||
Shortcut rune
|
||||
}
|
||||
|
||||
// AllSyncOptions is the ordered sync menu.
|
||||
var AllSyncOptions = []SyncOption{
|
||||
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
|
||||
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
|
||||
}
|
||||
|
||||
// SyncMenu renders a pull/push picker overlay.
|
||||
type SyncMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
Cursor int
|
||||
Width int
|
||||
Title string
|
||||
Options []SyncOption
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// NewSyncMenu creates a sync action menu.
|
||||
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
|
||||
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
|
||||
// NewSyncMenu creates a sync action menu for the given backend label.
|
||||
func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
|
||||
if backendLabel == "" {
|
||||
backendLabel = "Sync"
|
||||
}
|
||||
opts := []SyncOption{
|
||||
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
|
||||
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
|
||||
}
|
||||
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *SyncMenu) MoveDown() {
|
||||
if m.Cursor < len(AllSyncOptions)-1 {
|
||||
if m.Cursor < len(m.Options)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func (m *SyncMenu) MoveUp() {
|
||||
// JumpToShortcut selects the option whose shortcut matches r.
|
||||
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||
r = unicode.ToLower(r)
|
||||
for i, opt := range AllSyncOptions {
|
||||
for i, opt := range m.Options {
|
||||
if opt.Shortcut == r {
|
||||
m.Cursor = i
|
||||
return true
|
||||
@@ -67,17 +70,17 @@ func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||
|
||||
// Selected returns the highlighted action.
|
||||
func (m SyncMenu) Selected() SyncAction {
|
||||
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
|
||||
if m.Cursor < 0 || m.Cursor >= len(m.Options) {
|
||||
return SyncActionPull
|
||||
}
|
||||
return AllSyncOptions[m.Cursor].Action
|
||||
return m.Options[m.Cursor].Action
|
||||
}
|
||||
|
||||
// View renders the sync menu.
|
||||
func (m SyncMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("YouTrack"))
|
||||
for i, opt := range AllSyncOptions {
|
||||
lines = append(lines, m.Styles.Title.Render(m.Title))
|
||||
for i, opt := range m.Options {
|
||||
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
||||
if i == m.Cursor {
|
||||
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||
|
||||
Vendored
@@ -0,0 +1,18 @@
|
||||
package webembed
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// FS returns the embedded static frontend filesystem (contents of dist/).
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
return dist
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -516,6 +516,49 @@ func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (str
|
||||
return issues[0].Project.ID, nil
|
||||
}
|
||||
|
||||
// Project is a YouTrack project summary for the register/import UI.
|
||||
type Project struct {
|
||||
ID string
|
||||
ShortName string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ListProjects lists admin-accessible projects (requires sufficient token scope).
|
||||
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []Project
|
||||
for page := 0; page < maxPages; page++ {
|
||||
skip := page * defaultPageSize
|
||||
u, err := url.Parse(c.base() + "/api/admin/projects")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("fields", "id,shortName,name")
|
||||
q.Set("$top", fmt.Sprintf("%d", defaultPageSize))
|
||||
q.Set("$skip", fmt.Sprintf("%d", skip))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
ShortName string `json:"shortName"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range raw {
|
||||
all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name})
|
||||
}
|
||||
if len(raw) < defaultPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// IssueURL builds the browser URL for an issue.
|
||||
func (c *Client) IssueURL(idReadable string) string {
|
||||
return c.base() + "/issue/" + idReadable
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Svelte + Vite
|
||||
|
||||
This template should help get you started developing with Svelte in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||
|
||||
## Need an official Svelte framework?
|
||||
|
||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
**Why use this over SvelteKit?**
|
||||
|
||||
- It brings its own routing solution which might not be preferable for some users.
|
||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||
|
||||
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||
|
||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||
|
||||
**Why include `.vscode/extensions.json`?**
|
||||
|
||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||
|
||||
**Why enable `checkJs` in the JS template?**
|
||||
|
||||
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
|
||||
|
||||
**Why is HMR not preserving my local component state?**
|
||||
|
||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
|
||||
|
||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||
|
||||
```js
|
||||
// store.js
|
||||
// An extremely simple external store
|
||||
import { writable } from 'svelte/store'
|
||||
export default writable(0)
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>task-master</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
/**
|
||||
* svelte-preprocess cannot figure out whether you have
|
||||
* a value or a type, so tell TypeScript to enforce using
|
||||
* `import type` instead of `import` for Types.
|
||||
*/
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* To have warnings / errors of the Svelte compiler at the
|
||||
* correct position, enable source maps by default.
|
||||
*/
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable this if you'd like to use dynamic types.
|
||||
*/
|
||||
"checkJs": true
|
||||
},
|
||||
/**
|
||||
* Use global.d.ts instead of compilerOptions.types
|
||||
* to avoid limiting type declarations.
|
||||
*/
|
||||
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
Generated
+1107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"svelte": "^5.57.2",
|
||||
"vite": "^8.3.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,764 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from './lib/api.js'
|
||||
|
||||
let user = $state(null)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let topTab = $state('dashboard') // dashboard | projects | users | settings
|
||||
let projectView = $state('') // '' | board | ticket | report | members | settings
|
||||
let projects = $state([])
|
||||
let groups = $state([])
|
||||
let collapsedGroups = $state({})
|
||||
let projectId = $state('')
|
||||
let projectName = $state('')
|
||||
let tickets = $state([])
|
||||
let selected = $state(null)
|
||||
let revisions = $state([])
|
||||
let ticketTime = $state([])
|
||||
let users = $state([])
|
||||
let members = $state([])
|
||||
let timer = $state(null)
|
||||
let msg = $state('')
|
||||
let report = $state(null)
|
||||
let reportGroupBy = $state('ticket')
|
||||
let dash = $state({ projects: [], openTickets: [] })
|
||||
let tokens = $state([])
|
||||
let newTokenSecret = $state('')
|
||||
let renameName = $state('')
|
||||
let editGroupId = $state('')
|
||||
|
||||
let loginUser = $state('')
|
||||
let loginPass = $state('')
|
||||
let newProjectName = $state('')
|
||||
let newProjectGroupId = $state('')
|
||||
let newGroupName = $state('')
|
||||
let newTicketTitle = $state('')
|
||||
let editTitle = $state('')
|
||||
let editDesc = $state('')
|
||||
let editStatus = $state('todo')
|
||||
let newUserName = $state('')
|
||||
let newUserPass = $state('')
|
||||
let newUserAdmin = $state(false)
|
||||
let memberUserId = $state('')
|
||||
let memberRole = $state('member')
|
||||
let timeMinutes = $state(30)
|
||||
let tokenName = $state('tui')
|
||||
let tokenScope = $state('write')
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
user = await api.me()
|
||||
await openDashboard()
|
||||
await refreshTimer()
|
||||
} catch {
|
||||
user = null
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDuration(ns) {
|
||||
const totalMin = Math.max(0, Math.round(Number(ns) / 6e10))
|
||||
const h = Math.floor(totalMin / 60)
|
||||
const m = totalMin % 60
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`
|
||||
if (h > 0) return `${h}h`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
error = ''
|
||||
try {
|
||||
user = await api.login(loginUser, loginPass)
|
||||
await openDashboard()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await api.logout()
|
||||
user = null
|
||||
projects = []
|
||||
groups = []
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
;[projects, groups] = await Promise.all([api.listProjects(), api.listGroups()])
|
||||
}
|
||||
|
||||
function projectsInGroup(groupId) {
|
||||
return projects.filter((p) => (p.groupId || '') === groupId)
|
||||
}
|
||||
|
||||
function ungroupedProjects() {
|
||||
const ids = new Set(groups.map((g) => g.id))
|
||||
return projects.filter((p) => !p.groupId || !ids.has(p.groupId))
|
||||
}
|
||||
|
||||
function toggleGroup(id) {
|
||||
collapsedGroups = { ...collapsedGroups, [id]: !collapsedGroups[id] }
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
error = ''
|
||||
const name = newGroupName.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await api.createGroup(name)
|
||||
newGroupName = ''
|
||||
await refreshProjects()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup(id) {
|
||||
if (!confirm('Gruppe löschen? Projekte werden ungeordnet.')) return
|
||||
await api.deleteGroup(id)
|
||||
if (newProjectGroupId === id) newProjectGroupId = ''
|
||||
if (editGroupId === id) editGroupId = ''
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function refreshTimer() {
|
||||
const data = await api.getTimer()
|
||||
timer = data.timer
|
||||
}
|
||||
|
||||
async function openDashboard() {
|
||||
topTab = 'dashboard'
|
||||
projectId = ''
|
||||
projectView = ''
|
||||
selected = null
|
||||
dash = await api.dashboard()
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function openProjects() {
|
||||
topTab = 'projects'
|
||||
error = ''
|
||||
msg = ''
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function openProject(id, name = '') {
|
||||
topTab = 'projects'
|
||||
projectId = id
|
||||
projectName = name || projects.find((p) => p.id === id)?.name || id
|
||||
projectView = 'board'
|
||||
selected = null
|
||||
error = ''
|
||||
tickets = await api.listTickets(id)
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
error = ''
|
||||
const name = newProjectName.trim()
|
||||
if (!name) {
|
||||
error = 'Name required'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const p = await api.createProject(name, newProjectGroupId)
|
||||
newProjectName = ''
|
||||
await refreshProjects()
|
||||
await openProject(p.id, p.name)
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function createTicket() {
|
||||
if (!newTicketTitle.trim()) return
|
||||
await api.createTicket(projectId, { title: newTicketTitle, status: 'todo' })
|
||||
newTicketTitle = ''
|
||||
tickets = await api.listTickets(projectId)
|
||||
}
|
||||
|
||||
async function openTicket(t) {
|
||||
selected = t
|
||||
editTitle = t.title
|
||||
editDesc = t.description || ''
|
||||
editStatus = t.status
|
||||
revisions = await api.listRevisions(projectId, t.id)
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||
await refreshTimer()
|
||||
topTab = 'projects'
|
||||
projectView = 'ticket'
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
selected = await api.patchTicket(projectId, selected.id, {
|
||||
title: editTitle,
|
||||
description: editDesc,
|
||||
status: editStatus,
|
||||
})
|
||||
revisions = await api.listRevisions(projectId, selected.id)
|
||||
tickets = await api.listTickets(projectId)
|
||||
msg = 'Saved'
|
||||
}
|
||||
|
||||
async function removeTicket() {
|
||||
if (!confirm('Delete ticket?')) return
|
||||
await api.deleteTicket(projectId, selected.id)
|
||||
selected = null
|
||||
projectView = 'board'
|
||||
tickets = await api.listTickets(projectId)
|
||||
}
|
||||
|
||||
async function restoreRev(rid) {
|
||||
selected = await api.restoreRevision(projectId, selected.id, rid)
|
||||
editTitle = selected.title
|
||||
editDesc = selected.description || ''
|
||||
editStatus = selected.status
|
||||
revisions = await api.listRevisions(projectId, selected.id)
|
||||
tickets = await api.listTickets(projectId)
|
||||
msg = 'Restored'
|
||||
}
|
||||
|
||||
async function bookTime() {
|
||||
if (!selected) return
|
||||
await api.createTime(projectId, {
|
||||
ticketId: selected.id,
|
||||
durationNs: timeMinutes * 60 * 1e9,
|
||||
source: 'manual',
|
||||
})
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
msg = `Booked ${timeMinutes}m`
|
||||
}
|
||||
|
||||
async function toggleTimer() {
|
||||
if (!selected) return
|
||||
if (timer && timer.ticketId === selected.id) {
|
||||
await api.stopTimer()
|
||||
} else {
|
||||
await api.startTimer(selected.id, projectId)
|
||||
}
|
||||
await refreshTimer()
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
}
|
||||
|
||||
async function openReport() {
|
||||
topTab = 'projects'
|
||||
projectView = 'report'
|
||||
error = ''
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
|
||||
async function changeReportGroup(g) {
|
||||
reportGroupBy = g
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
|
||||
async function openProjectSettings() {
|
||||
topTab = 'projects'
|
||||
projectView = 'settings'
|
||||
renameName = projectName
|
||||
editGroupId = projects.find((p) => p.id === projectId)?.groupId || ''
|
||||
newTokenSecret = ''
|
||||
tokens = await api.listTokens(projectId)
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function saveProjectGroup() {
|
||||
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
||||
msg = 'Gruppe gespeichert'
|
||||
await refreshProjects()
|
||||
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
||||
}
|
||||
|
||||
function openGlobalSettings() {
|
||||
topTab = 'settings'
|
||||
error = ''
|
||||
msg = ''
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
const name = renameName.trim()
|
||||
if (!name) return
|
||||
const p = await api.patchProject(projectId, { name })
|
||||
projectName = p.name
|
||||
msg = 'Renamed'
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const t = await api.createToken(projectId, { name: tokenName || 'token', scope: tokenScope })
|
||||
newTokenSecret = t.token
|
||||
tokens = await api.listTokens(projectId)
|
||||
msg = 'Token created — copy it now'
|
||||
}
|
||||
|
||||
async function revokeToken(tid) {
|
||||
await api.revokeToken(projectId, tid)
|
||||
tokens = await api.listTokens(projectId)
|
||||
}
|
||||
|
||||
async function openAdmin() {
|
||||
topTab = 'users'
|
||||
error = ''
|
||||
msg = ''
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
||||
newUserName = ''
|
||||
newUserPass = ''
|
||||
newUserAdmin = false
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function toggleAdmin(u) {
|
||||
if (u.id === user.id) {
|
||||
error = 'Cannot change your own admin flag here'
|
||||
return
|
||||
}
|
||||
await api.patchUser(u.id, { isAdmin: !u.isAdmin })
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function deleteUser(u) {
|
||||
if (u.id === user.id) return
|
||||
if (!confirm(`Delete user ${u.username}?`)) return
|
||||
await api.deleteUser(u.id)
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function openMembers() {
|
||||
topTab = 'projects'
|
||||
projectView = 'members'
|
||||
members = await api.listMembers(projectId)
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
await api.setMember(projectId, memberUserId, memberRole)
|
||||
members = await api.listMembers(projectId)
|
||||
}
|
||||
|
||||
function byStatus(status) {
|
||||
return tickets.filter((t) => t.status === status)
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
msg = 'Copied'
|
||||
} catch {
|
||||
msg = text
|
||||
}
|
||||
}
|
||||
|
||||
function sidebarActive(v) {
|
||||
if (v === 'board') return projectView === 'board' || projectView === 'ticket'
|
||||
return projectView === v
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="login-wrap"><p class="muted">Loading…</p></div>
|
||||
{:else if !user}
|
||||
<div class="login-wrap">
|
||||
<div class="panel login-card stack">
|
||||
<h1>task-master</h1>
|
||||
<p class="muted">Sign in to manage projects and tickets.</p>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<form
|
||||
class="stack"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
doLogin()
|
||||
}}
|
||||
>
|
||||
<label>Username<input bind:value={loginUser} autocomplete="username" /></label>
|
||||
<label>Password<input type="password" bind:value={loginPass} autocomplete="current-password" /></label>
|
||||
<button class="primary" type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">task-master</div>
|
||||
<nav class="nav">
|
||||
<button class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
||||
<button class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
|
||||
{#if user.isAdmin}
|
||||
<button class:active={topTab === 'users'} onclick={openAdmin}>User</button>
|
||||
{/if}
|
||||
<button class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
|
||||
</nav>
|
||||
<div class="row">
|
||||
{#if timer}
|
||||
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
|
||||
{/if}
|
||||
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
|
||||
<button onclick={doLogout}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-body" class:with-sidebar={topTab === 'projects'}>
|
||||
{#if topTab === 'projects'}
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-section sidebar-projects">
|
||||
<p class="sidebar-label">Projekte</p>
|
||||
<div class="sidebar-project-list">
|
||||
{#each groups as g}
|
||||
<button type="button" class="sidebar-group" onclick={() => toggleGroup(g.id)}>
|
||||
<span class="sidebar-twist">{collapsedGroups[g.id] ? '▸' : '▾'}</span>
|
||||
<span class="sidebar-group-name">{g.name}</span>
|
||||
<span class="muted">{projectsInGroup(g.id).length}</span>
|
||||
</button>
|
||||
{#if !collapsedGroups[g.id]}
|
||||
{#each projectsInGroup(g.id) as p}
|
||||
<button
|
||||
class="sidebar-item sidebar-child"
|
||||
class:active={projectId === p.id}
|
||||
onclick={() => openProject(p.id, p.name)}
|
||||
>{p.name}</button>
|
||||
{:else}
|
||||
<p class="muted sidebar-child">leer</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if ungroupedProjects().length}
|
||||
{#if groups.length}
|
||||
<p class="sidebar-label sidebar-ungrouped-label">Ohne Gruppe</p>
|
||||
{/if}
|
||||
{#each ungroupedProjects() as p}
|
||||
<button
|
||||
class="sidebar-item"
|
||||
class:active={projectId === p.id}
|
||||
onclick={() => openProject(p.id, p.name)}
|
||||
>{p.name}</button>
|
||||
{/each}
|
||||
{:else if !groups.length && !projects.length}
|
||||
<p class="muted">Noch keine Projekte.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sidebar-create stack">
|
||||
<input placeholder="Neues Projekt" bind:value={newProjectName} />
|
||||
{#if groups.length}
|
||||
<select bind:value={newProjectGroupId}>
|
||||
<option value="">Ohne Gruppe</option>
|
||||
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button class="primary" onclick={createProject}>Anlegen</button>
|
||||
<div class="row" style="margin-top: 0.35rem;">
|
||||
<input placeholder="Neue Gruppe" bind:value={newGroupName} />
|
||||
<button onclick={createGroup}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if projectId}
|
||||
<div class="sidebar-section sidebar-subnav">
|
||||
<p class="sidebar-label">{projectName}</p>
|
||||
<button class="sidebar-item" class:active={sidebarActive('board')} onclick={() => openProject(projectId, projectName)}>Board</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('report')} onclick={openReport}>Report</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('members')} onclick={openMembers}>Members</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('settings')} onclick={openProjectSettings}>Settings</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="content">
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if msg}<p class="muted">{msg}</p>{/if}
|
||||
|
||||
{#if topTab === 'dashboard'}
|
||||
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
|
||||
<section class="panel stack">
|
||||
<h2>Projects</h2>
|
||||
{#each dash.projects as p}
|
||||
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
|
||||
<strong>{p.name}</strong>
|
||||
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">No projects yet.</p>
|
||||
{/each}
|
||||
<button onclick={openProjects}>Alle Projekte →</button>
|
||||
</section>
|
||||
<div class="stack">
|
||||
<section class="panel stack">
|
||||
<h2>Open tickets</h2>
|
||||
{#each dash.openTickets as t}
|
||||
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
|
||||
<span class="badge {t.status}">{t.status}</span>
|
||||
<div>{t.title}</div>
|
||||
<div class="muted">{t.projectName}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">Nothing open.</p>
|
||||
{/each}
|
||||
</section>
|
||||
<section class="panel stack">
|
||||
<h2>Time by project</h2>
|
||||
<table>
|
||||
<thead><tr><th>Project</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
{#each dash.projects as p}
|
||||
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else if topTab === 'projects' && !projectId}
|
||||
<section class="panel stack">
|
||||
<h2>Projekte</h2>
|
||||
<p class="muted">Wähle links ein Projekt oder lege ein neues an.</p>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'board'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>{projectName}</h2>
|
||||
<div class="row">
|
||||
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
|
||||
<button class="primary" onclick={createTicket}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid cols-3">
|
||||
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
|
||||
<div class="stack">
|
||||
<h3>{label}</h3>
|
||||
{#each byStatus(st) as t}
|
||||
<button class="ticket" onclick={() => openTicket(t)}>
|
||||
<span class="badge {t.status}">{label}</span>
|
||||
<div>{t.title}</div>
|
||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
||||
<section class="panel stack">
|
||||
<div class="row">
|
||||
<button onclick={() => (projectView = 'board')}>← Board</button>
|
||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||
</div>
|
||||
<label>Title<input bind:value={editTitle} /></label>
|
||||
<label>Status
|
||||
<select bind:value={editStatus}>
|
||||
<option value="todo">OPEN</option>
|
||||
<option value="in_progress">WIP</option>
|
||||
<option value="done">DONE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Description<textarea bind:value={editDesc}></textarea></label>
|
||||
<button class="primary" onclick={saveTicket}>Save</button>
|
||||
|
||||
<h3>Time</h3>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={toggleTimer}>
|
||||
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
|
||||
</button>
|
||||
<label class="row" style="width: auto;">Minutes
|
||||
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
|
||||
</label>
|
||||
<button onclick={bookTime}>Book</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
{#each ticketTime as e}
|
||||
<tr>
|
||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||
<td>{formatDuration(e.duration)}</td>
|
||||
<td>{e.source}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="3" class="muted">No time yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>History</h3>
|
||||
<table>
|
||||
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each revisions as rev}
|
||||
<tr>
|
||||
<td>{rev.rev}</td>
|
||||
<td>{rev.op}</td>
|
||||
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
|
||||
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'report'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>Report — {projectName}</h2>
|
||||
<div class="row">
|
||||
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
||||
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if report}
|
||||
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
||||
<table>
|
||||
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="muted">No time logged.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'settings'}
|
||||
<section class="panel stack">
|
||||
<h2>Settings — {projectName}</h2>
|
||||
<label>Project ID
|
||||
<div class="row">
|
||||
<code style="flex:1">{projectId}</code>
|
||||
<button onclick={() => copyText(projectId)}>Copy</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>Name
|
||||
<div class="row">
|
||||
<input bind:value={renameName} />
|
||||
<button class="primary" onclick={saveRename}>Rename</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>Gruppe
|
||||
<div class="row">
|
||||
<select bind:value={editGroupId} style="width: auto; flex: 1;">
|
||||
<option value="">Ohne Gruppe</option>
|
||||
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={saveProjectGroup}>Speichern</button>
|
||||
</div>
|
||||
</label>
|
||||
{#if groups.length}
|
||||
<div class="stack">
|
||||
<h3>Gruppen verwalten</h3>
|
||||
{#each groups as g}
|
||||
<div class="row">
|
||||
<span style="flex:1">{g.name}</span>
|
||||
<button class="danger" onclick={() => removeGroup(g.id)}>Löschen</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3>API tokens (for TUI)</h3>
|
||||
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
|
||||
{#if newTokenSecret}
|
||||
<div class="panel stack" style="border-color: var(--accent);">
|
||||
<strong>New token (shown once)</strong>
|
||||
<code>{newTokenSecret}</code>
|
||||
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
|
||||
<select bind:value={tokenScope} style="width: auto;">
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="primary" onclick={createToken}>Create token</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each tokens as t}
|
||||
<tr>
|
||||
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
|
||||
<td>{t.scope}</td>
|
||||
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{#if !t.revokedAt}
|
||||
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'members'}
|
||||
<section class="panel stack">
|
||||
<h2>Members — {projectName}</h2>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each members as m}
|
||||
<tr>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.role}</td>
|
||||
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row">
|
||||
<select bind:value={memberUserId} style="width: auto;">
|
||||
<option value="">user…</option>
|
||||
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={memberRole} style="width: auto;">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select>
|
||||
<button class="primary" onclick={addMember}>Add</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if topTab === 'users'}
|
||||
<section class="panel stack">
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each users as u}
|
||||
<tr>
|
||||
<td>{u.username}</td>
|
||||
<td>
|
||||
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
|
||||
{u.isAdmin ? 'yes' : 'no'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{#if u.id !== user.id}
|
||||
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Create user</h3>
|
||||
<input placeholder="username" bind:value={newUserName} />
|
||||
<input type="password" placeholder="password" bind:value={newUserPass} />
|
||||
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
|
||||
<button class="primary" onclick={createUser}>Create</button>
|
||||
</section>
|
||||
{:else if topTab === 'settings'}
|
||||
<section class="panel stack">
|
||||
<h2>Settings</h2>
|
||||
<p class="muted">Globale Einstellungen folgen hier später.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--bg-glow: #182028;
|
||||
--panel: #171c22;
|
||||
--panel-2: #1d242c;
|
||||
--ink: #e6edf3;
|
||||
--muted: #8b97a5;
|
||||
--line: #2a333d;
|
||||
--accent: #3d9a80;
|
||||
--accent-ink: #061510;
|
||||
--danger: #e07070;
|
||||
--todo: #9aa5b1;
|
||||
--wip: #d4a017;
|
||||
--done: #3d9a80;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(ellipse at top left, #1a2830 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, #152018 0%, transparent 45%),
|
||||
var(--bg);
|
||||
line-height: 1.45;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: transparent; }
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
button:hover { border-color: #3d4a58; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: #5a3030;
|
||||
background: #241818;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: #10151a;
|
||||
color: var(--ink);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: #5f6b78; }
|
||||
textarea { min-height: 8rem; resize: vertical; }
|
||||
a { color: var(--accent); }
|
||||
code {
|
||||
background: #10151a;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.35em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #c9d6e0;
|
||||
}
|
||||
.nav { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.nav button.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.app-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
.app-body.with-sidebar {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
.content { min-width: 0; }
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: 0.85rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-section {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
}
|
||||
.sidebar-projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.sidebar-project-list {
|
||||
--sidebar-row: 2.05rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
max-height: calc(12 * var(--sidebar-row));
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
.sidebar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.sidebar-group:hover {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sidebar-twist {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-group-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
.sidebar-child {
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
.sidebar-ungrouped-label {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.sidebar-subnav {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.sidebar-label {
|
||||
margin: 0.25rem 0 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-create {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.app-body.with-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-right: 0;
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
@media (max-width: 800px) { .cols-3 { grid-template-columns: 1fr; } }
|
||||
.muted { color: var(--muted); font-size: 0.92rem; }
|
||||
.error { color: var(--danger); margin: 0.5rem 0; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.stack { display: grid; gap: 0.6rem; }
|
||||
.ticket {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.ticket:hover {
|
||||
border-color: var(--accent);
|
||||
background: #222b34;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #10151a;
|
||||
}
|
||||
.badge.todo { color: var(--todo); }
|
||||
.badge.in_progress { color: var(--wip); }
|
||||
.badge.done { color: var(--done); }
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-card { width: min(380px, 100%); }
|
||||
h1, h2, h3 { margin: 0 0 0.75rem; font-weight: 650; color: #f0f4f8; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td, th {
|
||||
text-align: left;
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
||||
label > :is(input, select, textarea) { color: var(--ink); }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
let count = $state(0)
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
||||
@@ -0,0 +1,88 @@
|
||||
async function request(path, opts = {}) {
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
...opts,
|
||||
})
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
data = { error: text }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error) || res.statusText)
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request('/auth/me'),
|
||||
login: (username, password) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
logout: () => request('/auth/logout', { method: 'POST', body: '{}' }),
|
||||
|
||||
dashboard: () => request('/dashboard'),
|
||||
|
||||
listProjects: () => request('/projects'),
|
||||
createProject: (name, groupId = '') =>
|
||||
request('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(groupId ? { name, groupId } : { name }),
|
||||
}),
|
||||
patchProject: (id, body) =>
|
||||
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listGroups: () => request('/project-groups'),
|
||||
createGroup: (name) =>
|
||||
request('/project-groups', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
patchGroup: (id, body) =>
|
||||
request(`/project-groups/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteGroup: (id) => request(`/project-groups/${id}`, { method: 'DELETE' }),
|
||||
listMembers: (id) => request(`/projects/${id}/members`),
|
||||
setMember: (id, userId, role) =>
|
||||
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
removeMember: (id, userId) =>
|
||||
request(`/projects/${id}/members/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
listTokens: (id) => request(`/projects/${id}/tokens`),
|
||||
createToken: (id, body) =>
|
||||
request(`/projects/${id}/tokens`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
revokeToken: (id, tid) => request(`/projects/${id}/tokens/${tid}`, { method: 'DELETE' }),
|
||||
|
||||
listTickets: (projectId) => request(`/projects/${projectId}/tickets`),
|
||||
createTicket: (projectId, body) =>
|
||||
request(`/projects/${projectId}/tickets`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
getTicket: (projectId, tid) => request(`/projects/${projectId}/tickets/${tid}`),
|
||||
patchTicket: (projectId, tid, body) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteTicket: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}`, { method: 'DELETE' }),
|
||||
listRevisions: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/revisions`),
|
||||
restoreRevision: (projectId, tid, rid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/revisions/${rid}/restore`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
|
||||
report: (projectId, groupBy = 'ticket') =>
|
||||
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
|
||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||
createTime: (projectId, body) =>
|
||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
getTimer: () => request('/timer'),
|
||||
startTimer: (ticketId, projectId) =>
|
||||
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
|
||||
stopTimer: () => request('/timer/stop', { method: 'POST', body: '{}' }),
|
||||
|
||||
listUsers: () => request('/users'),
|
||||
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
mount(App, { target: document.getElementById('app') })
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: '../internal/webembed/dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user