Files
tracker/internal/store/store.go
T
kyle c9e74b236d tracker: single-binary project tracker with auth, api keys, and json api
Go + SQLite web app, server-rendered, hackerman theme. Projects with
descriptions, searchable notes, todo/doing/done task tracking. Bcrypt
login sessions, hashed api keys, full /api/v1 crud. Deploys on straylight
via configs/tracker.nix (prebuilt binary).
2026-09-18 21:28:40 -07:00

445 lines
11 KiB
Go

package store
import (
"database/sql"
"errors"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not found")
)
type Project struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
type Note struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
type Task struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
type Hit struct {
Kind string `json:"kind"`
ProjectID int64 `json:"project_id"`
RefID int64 `json:"ref_id"`
Title string `json:"title"`
Snippet string `json:"snippet"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
last_used TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
expires_at TEXT NOT NULL
);
`)
return err
}
// --- projects ---
func (s *Store) Projects() ([]Project, error) {
rows, err := s.db.Query(`SELECT id, name, description, created_at FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjects(rows)
}
func (s *Store) Project(id int64) (Project, error) {
var p Project
err := s.db.QueryRow(`SELECT id, name, description, created_at FROM projects WHERE id = ?`, id).
Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
return p, err
}
func (s *Store) CreateProject(name, description string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO projects (name, description) VALUES (?, ?)`, name, description)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateProject(id int64, name, description string) error {
res, err := s.db.Exec(`UPDATE projects SET name = ?, description = ? WHERE id = ?`, name, description, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) DeleteProject(id int64) error {
res, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- notes ---
func (s *Store) Notes(projectID int64) ([]Note, error) {
rows, err := s.db.Query(`SELECT id, project_id, body, created_at FROM notes WHERE project_id = ? ORDER BY id DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []Note
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt); err != nil {
return nil, err
}
notes = append(notes, n)
}
return notes, rows.Err()
}
func (s *Store) Note(id int64) (Note, error) {
var n Note
err := s.db.QueryRow(`SELECT id, project_id, body, created_at FROM notes WHERE id = ?`, id).
Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return n, ErrNotFound
}
return n, err
}
func (s *Store) CreateNote(projectID int64, body string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO notes (project_id, body) VALUES (?, ?)`, projectID, body)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) DeleteNote(id int64) error {
res, err := s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) UpdateNote(id int64, body string) error {
res, err := s.db.Exec(`UPDATE notes SET body = ? WHERE id = ?`, body, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- tasks ---
func (s *Store) Tasks(projectID int64) ([]Task, error) {
rows, err := s.db.Query(`SELECT id, project_id, title, status, created_at FROM tasks WHERE project_id = ? ORDER BY id`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
func (s *Store) Task(id int64) (Task, error) {
var t Task
err := s.db.QueryRow(`SELECT id, project_id, title, status, created_at FROM tasks WHERE id = ?`, id).
Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CreateTask(projectID int64, title string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO tasks (project_id, title) VALUES (?, ?)`, projectID, title)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CycleTask advances status todo -> doing -> done -> todo.
func (s *Store) CycleTask(id int64) error {
_, err := s.db.Exec(`UPDATE tasks SET status = CASE status WHEN 'todo' THEN 'doing' WHEN 'doing' THEN 'done' ELSE 'todo' END WHERE id = ?`, id)
return err
}
func (s *Store) SetTaskStatus(id int64, status string) error {
res, err := s.db.Exec(`UPDATE tasks SET status = ? WHERE id = ?`, status, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) UpdateTaskTitle(id int64, title string) error {
res, err := s.db.Exec(`UPDATE tasks SET title = ? WHERE id = ?`, title, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) DeleteTask(id int64) error {
res, err := s.db.Exec(`DELETE FROM tasks WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- search ---
func (s *Store) Search(q string) ([]Hit, error) {
like := "%" + q + "%"
var hits []Hit
rows, err := s.db.Query(`SELECT id, name, description FROM projects WHERE name LIKE ?1 OR description LIKE ?1 ORDER BY name COLLATE NOCASE`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id int64
var name, desc string
if err := rows.Scan(&id, &name, &desc); err != nil {
rows.Close()
return nil, err
}
title, snip := name, desc
if snip == "" {
snip = name
}
hits = append(hits, Hit{Kind: "project", ProjectID: id, RefID: id, Title: title, Snippet: snip})
}
rows.Close()
rows, err = s.db.Query(`SELECT n.id, n.project_id, n.body, p.name FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.body LIKE ? ORDER BY n.id DESC`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id, pid int64
var body, pname string
if err := rows.Scan(&id, &pid, &body, &pname); err != nil {
rows.Close()
return nil, err
}
hits = append(hits, Hit{Kind: "note", ProjectID: pid, RefID: id, Title: pname, Snippet: snippet(body, q)})
}
rows.Close()
rows, err = s.db.Query(`SELECT t.id, t.project_id, t.title, p.name FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.title LIKE ? ORDER BY t.id DESC`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id, pid int64
var title, pname string
if err := rows.Scan(&id, &pid, &title, &pname); err != nil {
rows.Close()
return nil, err
}
hits = append(hits, Hit{Kind: "task", ProjectID: pid, RefID: id, Title: title, Snippet: pname})
}
rows.Close()
return hits, nil
}
// --- stats ---
type Stats struct {
Projects int
Tasks struct {
Todo, Doing, Done int
}
Notes int
}
func (s *Store) Stats() (Stats, error) {
var st Stats
if err := s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&st.Projects); err != nil {
return st, err
}
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes`).Scan(&st.Notes); err != nil {
return st, err
}
rows, err := s.db.Query(`SELECT status, COUNT(*) FROM tasks GROUP BY status`)
if err != nil {
return st, err
}
defer rows.Close()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return st, err
}
switch status {
case "todo":
st.Tasks.Todo = n
case "doing":
st.Tasks.Doing = n
case "done":
st.Tasks.Done = n
}
}
return st, rows.Err()
}
// --- helpers ---
func scanProjects(rows *sql.Rows) ([]Project, error) {
var projects []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt); err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func checkAffected(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// snippet returns body trimmed to a window around the first match of q.
func snippet(body, q string) string {
lb, lq := lower(body), lower(q)
i := index(lb, lq)
if i < 0 {
if len(body) > 160 {
return body[:160] + "…"
}
return body
}
start := i - 40
if start < 0 {
start = 0
}
end := i + len(q) + 80
if end > len(body) {
end = len(body)
}
out := body[start:end]
if start > 0 {
out = "…" + out
}
if end < len(body) {
out += "…"
}
return out
}
// ASCII-only helpers to avoid pulling unicode/strings machinery into hot path readability.
func lower(s string) string {
b := []byte(s)
for i := range b {
if b[i] >= 'A' && b[i] <= 'Z' {
b[i] += 'a' - 'A'
}
}
return string(b)
}
func index(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}