feat(api): projects, sections, tasks, quick add, views and live sync

Completes the MVP surface: project and section CRUD, task CRUD with
sub-trees, drag-and-drop ordering, the quick-add endpoint over the
natural-language parser, Today and Upcoming, search, sharing with roles,
an activity log and a WebSocket for live updates.

Permission checks are route middleware rather than a first line in each
handler, so an endpoint is guarded by where it sits instead of by the
author remembering. Anything the caller may not see answers 404, never
403 — a 403 confirms the thing exists. Visibility is enforced inside the
list query, so another user's project cannot be reached by filtering or
searching at it.

Ordering is a float, so a drop writes one row instead of renumbering the
list. When repeated drops into one gap exhaust the precision, the
section is spread out again and the move retried — in a separate
transaction, since the attempt that discovered the problem rolls back.

The connection pool is not 1: a single connection makes the pool a lock,
and a query run while an earlier result set is still open then waits for
a connection it is itself holding. The store now closes its rows before
querying again, and the pool leaves that mistake slow rather than fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-16 23:44:55 +02:00
co-authored by Claude Opus 5
parent 1bb644f4d1
commit dff86dd23d
15 changed files with 3667 additions and 5 deletions
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
)
type Activity struct {
ID string
ProjectID string
TaskID string
UserID string
UserName string
Event string
Payload map[string]any
CreatedAt time.Time
}
// RecordActivity appends to a project's history.
//
// Every mutation writes one, which is what makes the activity log complete rather
// than a selection of whatever somebody remembered to instrument. It deliberately
// returns an error the caller is free to ignore: failing to record that a task was
// renamed must not fail the rename.
func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, event string, payload map[string]any) error {
encoded := "{}"
if len(payload) > 0 {
if raw, err := json.Marshal(payload); err == nil {
encoded = string(raw)
}
}
_, err := db.ExecContext(ctx,
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
return err
}
func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]Activity, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := db.QueryContext(ctx, `
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
FROM activity a JOIN users u ON u.id = a.user_id
WHERE a.project_id = ?
ORDER BY a.created_at DESC, a.id DESC
LIMIT ?`, projectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Activity{}
for rows.Next() {
var a Activity
var taskID sql.NullString
var payload string
var created int64
if err := rows.Scan(&a.ID, &a.ProjectID, &taskID, &a.UserID, &a.UserName,
&a.Event, &payload, &created); err != nil {
return nil, err
}
a.TaskID = taskID.String
a.CreatedAt = time.Unix(created, 0).UTC()
_ = json.Unmarshal([]byte(payload), &a.Payload)
out = append(out, a)
}
return out, rows.Err()
}
// PurgeActivity drops history older than the retention window. An activity log that
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
// of ordinary use, on hardware that is somebody's spare machine.
func (db *DB) PurgeActivity(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := db.ExecContext(ctx,
`DELETE FROM activity WHERE created_at < ?`, time.Now().Add(-olderThan).Unix())
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// --- small lookups used by handlers ---------------------------------------------
// ProjectOwner works on trashed projects too, which is what restoring needs: a
// project in the trash is invisible to ProjectRole by design.
func (db *DB) ProjectOwner(ctx context.Context, projectID string) (string, error) {
var owner string
err := db.QueryRowContext(ctx, `SELECT owner_id FROM projects WHERE id = ?`, projectID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return owner, err
}
// ProjectByName resolves a "#name" from quick add. Case-insensitive, and it only
// searches projects the user can actually write to — typing "#Arbejde" must not
// find somebody else's project of that name.
func (db *DB) ProjectByName(ctx context.Context, userID, name string) (string, error) {
var id string
err := db.QueryRowContext(ctx, `
SELECT p.id FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
WHERE lower(p.name) = lower(?)
AND p.deleted_at IS NULL AND p.archived = 0
AND (p.owner_id = ? OR m.role IN ('owner', 'editor'))
ORDER BY p.owner_id = ? DESC
LIMIT 1`, userID, name, userID, userID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
+509
View File
@@ -0,0 +1,509 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
type Project struct {
ID string
Name string
Color string
Icon string
ViewMode string
OwnerID string
IsInbox bool
Archived bool
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
// Role is the asking user's standing, filled in by the list and get calls so
// the frontend can grey out what they cannot do without a second request.
Role Role
// MemberCount is how many people can see it; 1 means it is not shared.
MemberCount int
}
type Section struct {
ID string
ProjectID string
Name string
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
}
// ListProjects returns everything the user can see: what they own, plus what has
// been shared with them. The Inbox sorts first, then by sort_order — the Inbox is
// not a project anyone chose to place, so it does not compete for a position.
func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived bool) ([]Project, error) {
query := `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
WHERE p.deleted_at IS NULL
AND (p.owner_id = ? OR m.user_id IS NOT NULL)`
if !includeArchived {
query += ` AND p.archived = 0`
}
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Project, error) {
role, err := ProjectRole(ctx, db, projectID, userID)
if err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
? AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, ErrNotFound
}
p, err := scanProject(rows)
if err != nil {
return nil, err
}
return &p, rows.Err()
}
func scanProject(rows *sql.Rows) (Project, error) {
var p Project
var icon sql.NullString
var isInbox, archived int
var created, updated int64
var role string
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
if err != nil {
return p, err
}
p.Icon = icon.String
p.IsInbox = isInbox == 1
p.Archived = archived == 1
p.CreatedAt = time.Unix(created, 0).UTC()
p.UpdatedAt = time.Unix(updated, 0).UTC()
p.Role = Role(role)
return p, nil
}
func (db *DB) CreateProject(ctx context.Context, p *Project) error {
if p.ID == "" {
p.ID = NewID()
}
now := time.Now().UTC()
p.CreatedAt, p.UpdatedAt = now, now
if p.ViewMode == "" {
p.ViewMode = "list"
}
if p.Color == "" {
p.Color = "graphite"
}
if p.SortOrder == 0 {
order, err := db.nextProjectOrder(ctx, p.OwnerID)
if err != nil {
return err
}
p.SortOrder = order
}
var icon any
if p.Icon != "" {
icon = p.Icon
}
_, err := db.ExecContext(ctx,
`INSERT INTO projects (id, name, color, icon, view_mode, owner_id, is_inbox,
archived, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`,
p.ID, p.Name, p.Color, icon, p.ViewMode, p.OwnerID, p.SortOrder,
now.Unix(), now.Unix())
p.Role = RoleOwner
p.MemberCount = 1
return err
}
func (db *DB) nextProjectOrder(ctx context.Context, ownerID string) (float64, error) {
var max sql.NullFloat64
err := db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM projects WHERE owner_id = ? AND deleted_at IS NULL`,
ownerID).Scan(&max)
if err != nil {
return 0, err
}
return max.Float64 + 1024, nil
}
// ProjectUpdate carries only the fields that were actually sent. A nil pointer
// means "not mentioned", which is what makes PATCH different from PUT — without
// it, a client updating a name would blank every field it did not send.
type ProjectUpdate struct {
Name *string
Color *string
Icon *string
ViewMode *string
Archived *bool
SortOrder *float64
}
func (db *DB) UpdateProject(ctx context.Context, projectID string, u ProjectUpdate) error {
set, args := buildUpdate(map[string]any{
"name": u.Name,
"color": u.Color,
"icon": u.Icon,
"view_mode": u.ViewMode,
"archived": u.Archived,
"sort_order": u.SortOrder,
})
if len(set) == 0 {
return nil
}
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), projectID)
res, err := db.ExecContext(ctx,
`UPDATE projects SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteProject is a soft delete: the project goes to the trash and everything in
// it goes with it, so restoring brings back a project that still has its tasks.
func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
now := time.Now().Unix()
return db.Tx(ctx, func(tx *sql.Tx) error {
var isInbox int
err := tx.QueryRowContext(ctx,
`SELECT is_inbox FROM projects WHERE id = ? AND deleted_at IS NULL`, projectID).Scan(&isInbox)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
// The Inbox is where tasks with no project go. Deleting it would leave
// quick add with nowhere to write.
if isInbox == 1 {
return errors.New("store: the Inbox cannot be deleted")
}
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET deleted_at = ? WHERE project_id = ? AND deleted_at IS NULL`,
now, projectID); err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`UPDATE projects SET deleted_at = ?, updated_at = ? WHERE id = ?`, now, now, projectID)
return err
})
}
// RestoreProject brings a project back, along with the tasks that were deleted with
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
// and separately, and restoring a project should not undo that too.
func (db *DB) RestoreProject(ctx context.Context, projectID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
var deletedAt sql.NullInt64
err := tx.QueryRowContext(ctx,
`SELECT deleted_at FROM projects WHERE id = ?`, projectID).Scan(&deletedAt)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if !deletedAt.Valid {
return nil // already live
}
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET deleted_at = NULL WHERE project_id = ? AND deleted_at = ?`,
projectID, deletedAt.Int64); err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`UPDATE projects SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), projectID)
return err
})
}
// --- members ------------------------------------------------------------------
type Member struct {
UserID string
Email string
Name string
AvatarColor string
Role Role
AddedAt time.Time
}
// ListMembers returns everyone with access, owner included. The owner has no
// project_members row — ownership is the projects table — so they are unioned in
// rather than left out of the list of people who can see it.
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
FROM projects p JOIN users u ON u.id = p.owner_id
WHERE p.id = ?
UNION ALL
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
FROM project_members m JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
members := []Member{}
for rows.Next() {
var m Member
var added int64
if err := rows.Scan(&m.UserID, &m.Email, &m.Name, &m.AvatarColor, &m.Role, &added); err != nil {
return nil, err
}
m.AddedAt = time.Unix(added, 0).UTC()
members = append(members, m)
}
return members, rows.Err()
}
func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role) error {
if !role.Valid() || role == RoleOwner {
// Ownership is transferred, not granted: two owners is a state with no
// way to resolve a disagreement about who may remove whom.
return errors.New("store: members may be editor or viewer")
}
_, err := db.ExecContext(ctx,
`INSERT INTO project_members (project_id, user_id, role, added_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`,
projectID, userID, string(role), time.Now().Unix())
return err
}
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at
// somebody who can no longer see the project.
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET assignee_id = NULL WHERE project_id = ? AND assignee_id = ?`,
projectID, userID); err != nil {
return err
}
res, err := tx.ExecContext(ctx,
`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
}
// --- sections -----------------------------------------------------------------
func (db *DB) ListSections(ctx context.Context, projectID string) ([]Section, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, project_id, name, sort_order, created_at, updated_at
FROM sections WHERE project_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
sections := []Section{}
for rows.Next() {
var s Section
var created, updated int64
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.SortOrder, &created, &updated); err != nil {
return nil, err
}
s.CreatedAt = time.Unix(created, 0).UTC()
s.UpdatedAt = time.Unix(updated, 0).UTC()
sections = append(sections, s)
}
return sections, rows.Err()
}
func (db *DB) CreateSection(ctx context.Context, s *Section) error {
if s.ID == "" {
s.ID = NewID()
}
now := time.Now().UTC()
s.CreatedAt, s.UpdatedAt = now, now
if s.SortOrder == 0 {
var max sql.NullFloat64
if err := db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM sections WHERE project_id = ? AND deleted_at IS NULL`,
s.ProjectID).Scan(&max); err != nil {
return err
}
s.SortOrder = max.Float64 + 1024
}
_, err := db.ExecContext(ctx,
`INSERT INTO sections (id, project_id, name, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
s.ID, s.ProjectID, s.Name, s.SortOrder, now.Unix(), now.Unix())
return err
}
type SectionUpdate struct {
Name *string
SortOrder *float64
}
func (db *DB) UpdateSection(ctx context.Context, sectionID string, u SectionUpdate) error {
set, args := buildUpdate(map[string]any{"name": u.Name, "sort_order": u.SortOrder})
if len(set) == 0 {
return nil
}
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), sectionID)
res, err := db.ExecContext(ctx,
`UPDATE sections SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteSection keeps the tasks and moves them to the project's unsectioned area.
// Deleting a heading is not a request to delete the work filed under it.
func (db *DB) DeleteSection(ctx context.Context, sectionID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET section_id = NULL WHERE section_id = ?`, sectionID); err != nil {
return err
}
res, err := tx.ExecContext(ctx,
`UPDATE sections SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().Unix(), sectionID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
}
// SectionProject reports which project a section belongs to, so a handler can check
// permission against the project before touching the section.
func (db *DB) SectionProject(ctx context.Context, sectionID string) (string, error) {
var projectID string
err := db.QueryRowContext(ctx,
`SELECT project_id FROM sections WHERE id = ? AND deleted_at IS NULL`, sectionID).Scan(&projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return projectID, err
}
// --- helpers ------------------------------------------------------------------
// buildUpdate turns a map of optional pointers into SET clauses, skipping the ones
// that were not supplied. Keys are literals from the call site, never user input.
func buildUpdate(fields map[string]any) ([]string, []any) {
// Sorted so the generated SQL is stable, which keeps SQLite's statement cache
// working and makes the queries readable in a log.
keys := sortedKeys(fields)
var set []string
var args []any
for _, k := range keys {
switch v := fields[k].(type) {
case *string:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *float64:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *int:
if v != nil {
set = append(set, k+" = ?")
args = append(args, *v)
}
case *bool:
if v != nil {
set = append(set, k+" = ?")
args = append(args, boolToInt(*v))
}
}
}
return set, args
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
keys[j], keys[j-1] = keys[j-1], keys[j]
}
}
return keys
}
func joinComma(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += ", "
}
out += p
}
return out
}
+15 -5
View File
@@ -9,8 +9,10 @@ import (
"fmt"
"io/fs"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // pure-Go driver: no cgo, so the binary stays static
)
@@ -46,11 +48,19 @@ func Open(path string) (*DB, error) {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// One writer. SQLite serialises writes anyway, and letting the pool open several
// write connections only converts that into SQLITE_BUSY churn. Reads still run
// concurrently against the WAL through this same pool.
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
// SQLite serialises writes whatever the pool does, so this is sized for readers.
// In WAL mode they run concurrently with a write in flight, and busy_timeout
// absorbs the contention between writers.
//
// Not 1. A single connection makes the pool a lock, and any code that runs a
// query while an earlier *sql.Rows is still open then waits forever for a
// connection it is itself holding — a deadlock rather than a slow query, and
// one that only appears once a list has rows in it. The store closes its result
// sets before querying again, and this leaves room for the mistake to be slow
// instead of fatal if it is ever made again.
sqlDB.SetMaxOpenConns(max(4, runtime.NumCPU()))
sqlDB.SetMaxIdleConns(4)
sqlDB.SetConnMaxLifetime(time.Hour)
if err := sqlDB.PingContext(context.Background()); err != nil {
sqlDB.Close()
+765
View File
@@ -0,0 +1,765 @@
package store
import (
"context"
"database/sql"
"errors"
"strings"
"time"
)
type Task struct {
ID string
ProjectID string
SectionID string
ParentID string
Content string
Description string
Priority int
// DueDate is a calendar day, "YYYY-MM-DD". DueDatetime is set as well when the
// task has a clock time, so day-based queries never do timezone arithmetic.
DueDate string
DueDatetime *time.Time
DueTimezone string
DurationMin *int
RecurrenceRule string
AssigneeID string
CompletedAt *time.Time
CompletedBy string
CreatedBy string
SortOrder float64
Labels []string
CreatedAt time.Time
UpdatedAt time.Time
}
func (t *Task) Completed() bool { return t.CompletedAt != nil }
// TaskFilter is the one query shape behind every list of tasks: a project view,
// Today, Upcoming, a label, an assignee. Zero values mean "do not filter on this".
type TaskFilter struct {
ProjectIDs []string
SectionID string
ParentID string
// TopLevelOnly excludes sub-tasks, which a project view wants because it
// renders them nested under their parents rather than as separate rows.
TopLevelOnly bool
AssigneeID string
LabelID string
Priority int
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
DueBefore string
DueFrom string
// NoDate selects tasks with no due date at all.
NoDate bool
IncludeCompleted bool
// CompletedOnly powers the "completed" view and the activity log.
CompletedOnly bool
Search string
Limit int
Offset int
}
// ListTasks returns tasks the user can see, matching the filter.
//
// Visibility is enforced in the query rather than by filtering afterwards: the
// WHERE clause restricts to projects the user owns or is a member of, so a task in
// somebody else's project cannot be reached by guessing a filter, and a paginated
// response cannot come back short because rows were removed after LIMIT.
func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Task, error) {
var where []string
var args []any
where = append(where, `t.deleted_at IS NULL`)
where = append(where, `p.deleted_at IS NULL`)
where = append(where, `(p.owner_id = ? OR EXISTS (
SELECT 1 FROM project_members m WHERE m.project_id = t.project_id AND m.user_id = ?))`)
args = append(args, userID, userID)
switch {
case f.CompletedOnly:
where = append(where, `t.completed_at IS NOT NULL`)
case !f.IncludeCompleted:
where = append(where, `t.completed_at IS NULL`)
}
if len(f.ProjectIDs) > 0 {
where = append(where, `t.project_id IN (`+placeholders(len(f.ProjectIDs))+`)`)
for _, id := range f.ProjectIDs {
args = append(args, id)
}
}
if f.SectionID != "" {
where = append(where, `t.section_id = ?`)
args = append(args, f.SectionID)
}
if f.ParentID != "" {
where = append(where, `t.parent_id = ?`)
args = append(args, f.ParentID)
}
if f.TopLevelOnly {
where = append(where, `t.parent_id IS NULL`)
}
if f.AssigneeID != "" {
where = append(where, `t.assignee_id = ?`)
args = append(args, f.AssigneeID)
}
if f.Priority > 0 {
where = append(where, `t.priority = ?`)
args = append(args, f.Priority)
}
if f.LabelID != "" {
where = append(where, `EXISTS (SELECT 1 FROM task_labels tl WHERE tl.task_id = t.id AND tl.label_id = ?)`)
args = append(args, f.LabelID)
}
if f.NoDate {
where = append(where, `t.due_date IS NULL`)
}
if f.DueBefore != "" {
where = append(where, `t.due_date IS NOT NULL AND t.due_date <= ?`)
args = append(args, f.DueBefore)
}
if f.DueFrom != "" {
where = append(where, `t.due_date IS NOT NULL AND t.due_date >= ?`)
args = append(args, f.DueFrom)
}
if expr := MatchExpr(f.Search); expr != "" {
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
args = append(args, expr)
}
query := `
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
if f.Limit > 0 {
query += ` LIMIT ?`
args = append(args, f.Limit)
if f.Offset > 0 {
query += ` OFFSET ?`
args = append(args, f.Offset)
}
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
tasks := []Task{}
ids := []string{}
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
rows.Close()
return nil, err
}
tasks = append(tasks, t)
ids = append(ids, t.ID)
}
err = rows.Err()
// Closed explicitly, before the next query rather than by a deferred call at
// the end of the function. An open *sql.Rows holds its connection, and this
// pool is small on purpose — running the label lookup below while these rows
// were still open would wait for a connection that this very function is
// holding, which is a deadlock rather than a slow query.
rows.Close()
if err != nil {
return nil, err
}
// Labels in one further query rather than a join: joining would multiply every
// task row by its label count and require de-duplication in Go, which is more
// work and more code than a second round trip against a local file.
if err := db.attachLabels(ctx, tasks, ids); err != nil {
return nil, err
}
return tasks, nil
}
func (db *DB) attachLabels(ctx context.Context, tasks []Task, ids []string) error {
if len(ids) == 0 {
return nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT tl.task_id, l.name FROM task_labels tl
JOIN labels l ON l.id = tl.label_id
WHERE tl.task_id IN (`+placeholders(len(ids))+`)
ORDER BY l.name`, args...)
if err != nil {
return err
}
defer rows.Close()
byTask := map[string][]string{}
for rows.Next() {
var taskID, name string
if err := rows.Scan(&taskID, &name); err != nil {
return err
}
byTask[taskID] = append(byTask[taskID], name)
}
if err := rows.Err(); err != nil {
return err
}
for i := range tasks {
tasks[i].Labels = byTask[tasks[i].ID]
}
return nil
}
func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error) {
if _, err := TaskRole(ctx, db, taskID, userID); err != nil {
return nil, err
}
rows, err := db.QueryContext(ctx, `
SELECT id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, completed_at, completed_by,
created_by, sort_order, created_at, updated_at
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
if err != nil {
return nil, err
}
var t Task
found := rows.Next()
if found {
t, err = scanTask(rows)
}
if err == nil {
err = rows.Err()
}
// Closed before the label lookup below, for the reason given in ListTasks:
// an open result set holds its connection.
rows.Close()
if err != nil {
return nil, err
}
if !found {
return nil, ErrNotFound
}
if t.Labels, err = db.taskLabels(ctx, t.ID); err != nil {
return nil, err
}
return &t, nil
}
func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
rows, err := db.QueryContext(ctx,
`SELECT l.name FROM task_labels tl JOIN labels l ON l.id = tl.label_id
WHERE tl.task_id = ? ORDER BY l.name`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func scanTask(rows *sql.Rows) (Task, error) {
var t Task
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
var dueDatetime, completedAt sql.NullInt64
var duration sql.NullInt64
var created, updated int64
err := rows.Scan(&t.ID, &t.ProjectID, &sectionID, &parentID, &t.Content, &t.Description,
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
&recurrence, &assignee, &completedAt, &completedBy,
&t.CreatedBy, &t.SortOrder, &created, &updated)
if err != nil {
return t, err
}
t.SectionID = sectionID.String
t.ParentID = parentID.String
t.DueDate = dueDate.String
t.DueTimezone = dueTZ.String
t.RecurrenceRule = recurrence.String
t.AssigneeID = assignee.String
t.CompletedBy = completedBy.String
if dueDatetime.Valid {
v := time.Unix(dueDatetime.Int64, 0).UTC()
t.DueDatetime = &v
}
if completedAt.Valid {
v := time.Unix(completedAt.Int64, 0).UTC()
t.CompletedAt = &v
}
if duration.Valid {
v := int(duration.Int64)
t.DurationMin = &v
}
t.CreatedAt = time.Unix(created, 0).UTC()
t.UpdatedAt = time.Unix(updated, 0).UTC()
return t, nil
}
func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) error {
if t.ID == "" {
t.ID = NewID()
}
if t.Priority < 1 || t.Priority > 4 {
t.Priority = 4
}
now := time.Now().UTC()
t.CreatedAt, t.UpdatedAt = now, now
if t.SortOrder == 0 {
order, err := db.nextTaskOrder(ctx, t.ProjectID, t.SectionID)
if err != nil {
return err
}
t.SortOrder = order
}
return db.Tx(ctx, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, created_by, sort_order,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
t.Content, t.Description, t.Priority, nullString(t.DueDate),
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
t.SortOrder, now.Unix(), now.Unix())
if err != nil {
return err
}
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
})
}
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
var max sql.NullFloat64
var err error
if sectionID == "" {
err = db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM tasks
WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL`,
projectID).Scan(&max)
} else {
err = db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM tasks
WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL`,
projectID, sectionID).Scan(&max)
}
if err != nil {
return 0, err
}
return max.Float64 + 1024, nil
}
// TaskUpdate mirrors ProjectUpdate: nil means the field was not sent.
//
// The due fields are grouped, because clearing a date must also clear the time and
// leaving them independent would allow a task due at 10:00 on no particular day.
type TaskUpdate struct {
Content *string
Description *string
Priority *int
SectionID *string
ParentID *string
AssigneeID *string
RecurrenceRule *string
DurationMin *int
SortOrder *float64
SetDue bool
DueDate string
DueDatetime *time.Time
DueTimezone string
Labels []string
SetLabels bool
}
func (db *DB) UpdateTask(ctx context.Context, taskID, userID string, u TaskUpdate) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
set, args := buildUpdate(map[string]any{
"content": u.Content,
"description": u.Description,
"priority": u.Priority,
"sort_order": u.SortOrder,
})
// These four are nullable, so an empty string means "clear it" rather than
// "set it to the empty string" — a task's section, parent and assignee are
// all things a user removes as often as they set.
for _, f := range []struct {
column string
value *string
}{
{"section_id", u.SectionID},
{"parent_id", u.ParentID},
{"assignee_id", u.AssigneeID},
{"recurrence_rule", u.RecurrenceRule},
} {
if f.value != nil {
set = append(set, f.column+" = ?")
args = append(args, nullString(*f.value))
}
}
if u.DurationMin != nil {
set = append(set, "duration_min = ?")
if *u.DurationMin <= 0 {
args = append(args, nil)
} else {
args = append(args, *u.DurationMin)
}
}
if u.SetDue {
set = append(set, "due_date = ?", "due_datetime = ?", "due_timezone = ?")
args = append(args, nullString(u.DueDate), nullTime(u.DueDatetime), nullString(u.DueTimezone))
}
if len(set) > 0 {
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), taskID)
res, err := tx.ExecContext(ctx,
`UPDATE tasks SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
}
if u.SetLabels {
return setTaskLabels(ctx, tx, taskID, userID, u.Labels)
}
return nil
})
}
// setTaskLabels replaces a task's labels, creating any that the user does not have
// yet. Labels are personal, so the same word used by two people is two labels —
// which is what stops one person's tidying from renaming another's.
func setTaskLabels(ctx context.Context, tx *sql.Tx, taskID, userID string, names []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM task_labels WHERE task_id = ?`, taskID); err != nil {
return err
}
now := time.Now().Unix()
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
var labelID string
err := tx.QueryRowContext(ctx,
`SELECT id FROM labels WHERE user_id = ? AND lower(name) = lower(?)`,
userID, name).Scan(&labelID)
if errors.Is(err, sql.ErrNoRows) {
labelID = NewID()
if _, err := tx.ExecContext(ctx,
`INSERT INTO labels (id, user_id, name, color, sort_order, created_at)
VALUES (?, ?, ?, 'graphite', 0, ?)`, labelID, userID, name, now); err != nil {
return err
}
} else if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)
ON CONFLICT DO NOTHING`, taskID, labelID); err != nil {
return err
}
}
return nil
}
// CompleteTask marks a task done, along with everything under it.
//
// Sub-tasks are completed with their parent because the parent standing complete
// over unfinished children is a state the UI would then have to explain. Reopening
// does not undo that: which children were deliberately still open is not recorded,
// and guessing would be worse than leaving them done.
func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) error {
now := time.Now().Unix()
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`,
now, userID, now, taskID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
// Recursive, so a whole sub-tree closes rather than only its first level.
_, err = tx.ExecContext(ctx, `
WITH RECURSIVE descendants(id) AS (
SELECT id FROM tasks WHERE parent_id = ?
UNION ALL
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
)
UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
WHERE id IN (SELECT id FROM descendants)
AND deleted_at IS NULL AND completed_at IS NULL`,
taskID, now, userID, now)
return err
})
}
func (db *DB) ReopenTask(ctx context.Context, taskID string) error {
res, err := db.ExecContext(ctx,
`UPDATE tasks SET completed_at = NULL, completed_by = NULL, updated_at = ?
WHERE id = ? AND deleted_at IS NULL`, time.Now().Unix(), taskID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteTask is a soft delete, and takes the sub-tree with it.
func (db *DB) DeleteTask(ctx context.Context, taskID string) error {
now := time.Now().Unix()
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
now, now, taskID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
_, err = tx.ExecContext(ctx, `
WITH RECURSIVE descendants(id) AS (
SELECT id FROM tasks WHERE parent_id = ?
UNION ALL
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
)
UPDATE tasks SET deleted_at = ?, updated_at = ?
WHERE id IN (SELECT id FROM descendants) AND deleted_at IS NULL`,
taskID, now, now)
return err
})
}
func (db *DB) RestoreTask(ctx context.Context, taskID string) error {
res, err := db.ExecContext(ctx,
`UPDATE tasks SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), taskID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// MoveTask places a task between two neighbours and returns its new position.
//
// Ordering is a float, so an insert is the midpoint of its neighbours and touches
// one row — no renumbering, which on a drag-and-drop list would mean rewriting
// every task in the project on every drop.
//
// Floats do run out of room: repeatedly dropping into the same gap halves it each
// time, and after about fifty drops the midpoint of two neighbours is equal to one
// of them. Then the section is spread back out and the move retried. The renumber
// runs in its own transaction, because the attempt that discovered the problem
// rolls back — doing it inside that transaction would undo the repair along with it.
func (db *DB) MoveTask(ctx context.Context, taskID, projectID, sectionID string, afterID, beforeID string) (float64, error) {
order, err := db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
if !errors.Is(err, errOrderExhausted) {
return order, err
}
if err := db.Tx(ctx, func(tx *sql.Tx) error {
return renumber(ctx, tx, projectID, sectionID)
}); err != nil {
return 0, err
}
// One retry: after a renumber the gaps are wide again, so a second failure
// would mean something other than exhausted precision.
order, err = db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
if errors.Is(err, errOrderExhausted) {
return 0, errors.New("store: could not find a position for the task")
}
return order, err
}
var errOrderExhausted = errors.New("store: no float left between these two positions")
func (db *DB) tryMove(ctx context.Context, taskID, projectID, sectionID, afterID, beforeID string) (float64, error) {
var order float64
err := db.Tx(ctx, func(tx *sql.Tx) error {
after, hasAfter, err := neighbourOrder(ctx, tx, afterID)
if err != nil {
return err
}
before, hasBefore, err := neighbourOrder(ctx, tx, beforeID)
if err != nil {
return err
}
switch {
case hasAfter && hasBefore:
order = after + (before-after)/2
if order <= after || order >= before {
return errOrderExhausted
}
case hasAfter:
order = after + 1024
case hasBefore:
order = before - 1024
default:
order = 1024
}
res, err := tx.ExecContext(ctx,
`UPDATE tasks SET project_id = ?, section_id = ?, sort_order = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL`,
projectID, nullString(sectionID), order, time.Now().Unix(), taskID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
return order, err
}
func neighbourOrder(ctx context.Context, tx *sql.Tx, id string) (float64, bool, error) {
if id == "" {
return 0, false, nil
}
var order float64
err := tx.QueryRowContext(ctx,
`SELECT sort_order FROM tasks WHERE id = ? AND deleted_at IS NULL`, id).Scan(&order)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return order, true, nil
}
// renumber spreads a section's tasks back out to whole multiples of 1024.
func renumber(ctx context.Context, tx *sql.Tx, projectID, sectionID string) error {
var rows *sql.Rows
var err error
if sectionID == "" {
rows, err = tx.QueryContext(ctx,
`SELECT id FROM tasks WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL
ORDER BY sort_order, created_at`, projectID)
} else {
rows, err = tx.QueryContext(ctx,
`SELECT id FROM tasks WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at`, projectID, sectionID)
}
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE tasks SET sort_order = ? WHERE id = ?`, float64((i+1)*1024), id); err != nil {
return err
}
}
return nil
}
// PurgeTrash deletes for real what has been in the trash longer than the retention
// window. This is the only place a row leaves the database.
func (db *DB) PurgeTrash(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan).Unix()
var total int64
err := db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM tasks WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
if err != nil {
return err
}
n, _ := res.RowsAffected()
total += n
res, err = tx.ExecContext(ctx,
`DELETE FROM projects WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
if err != nil {
return err
}
n, _ = res.RowsAffected()
total += n
return nil
})
return total, err
}
func nullString(s string) any {
if s == "" {
return nil
}
return s
}
func nullTime(t *time.Time) any {
if t == nil {
return nil
}
return t.Unix()
}
func nullInt(n *int) any {
if n == nil {
return nil
}
return *n
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}