`assignee_id` has been on tasks since sharing was built and delegating has been possible from the drawer since yesterday. What was missing was anywhere to see it: work handed to somebody else left your lists and appeared in nothing. GET /delegated returns everything assigned to somebody who is not you, in the projects you can see, grouped by the person. Grouped on the server rather than flat, because the question is about people — "what is Anders sitting on" — and a client cannot group it itself: it would need the name behind an assignee id from a project it does not happen to have open, and there is no way to ask for that. The names come back with the tasks, so one request is one screen. The filter is a field of its own rather than a sentinel in AssigneeID. "What is on my plate" and "what have I handed over" are different questions, and one string that means an id most of the time and a magic word the rest is a field somebody eventually passes a real id to. An assignee whose account is gone is named "Ukendt" rather than dropped. Losing the row would make work disappear from the one view that exists to stop work disappearing. The sidebar entry only appears where delegating is possible at all. On an instance with one person it could never have anything in it, and a permanent empty entry is a question the sidebar keeps asking and answering "no". Building it turned up a bug in the split above it. "Projekter" and "Delt med mig" were divided on `shared`, which only means the project has more than one member — so inviting somebody to a project of your own moved it under "Delt med mig", where it lost its place in the order, stopped being draggable, and could not be filed under a group. It is divided on ownership now, which is what the two headings have always said. Sharing your own project is not the same event as somebody sharing theirs with you. The smoke test now follows the whole loop in one go: invite, accept in a second browser, assign a task to the person who just joined, and find it under their name — with the shared project still under "Projekter" on the way past. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
917 lines
27 KiB
Go
917 lines
27 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/recurrence"
|
|
)
|
|
|
|
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
|
|
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
|
|
// person. A second field rather than a magic value in AssigneeID, because the
|
|
// two are different questions — "what is on my plate" and "what have I handed
|
|
// over" — and one string that means an id most of the time and a sentinel the
|
|
// rest is the kind of field somebody eventually passes a real id to.
|
|
DelegatedBy 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
|
|
// FilterSQL is a compiled saved-filter expression: a WHERE fragment over `t`
|
|
// with its own bound arguments. It is produced by the filterquery package and
|
|
// never assembled from user text here.
|
|
FilterSQL string
|
|
FilterArgs []any
|
|
|
|
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.DelegatedBy != "" {
|
|
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
|
|
args = append(args, f.DelegatedBy)
|
|
}
|
|
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 != "" {
|
|
// Text or a label. A label is how you find a task whose wording you have
|
|
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
|
// and leaving them out made the box look like it had lost the task.
|
|
//
|
|
// The label side is a plain lowercase match rather than another FTS index:
|
|
// labels are short, and their names are typed by the person searching for
|
|
// them, so there is no spelling to be generous about. The diacritic
|
|
// folding that matters for task text does not earn a second index here.
|
|
where = append(where, `(
|
|
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
|
OR t.id IN (
|
|
SELECT tl.task_id FROM task_labels tl
|
|
JOIN labels l ON l.id = tl.label_id
|
|
WHERE lower(l.name) LIKE ?
|
|
)
|
|
)`)
|
|
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
|
}
|
|
if f.FilterSQL != "" {
|
|
where = append(where, "("+f.FilterSQL+")")
|
|
args = append(args, f.FilterArgs...)
|
|
}
|
|
|
|
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, §ionID, &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
|
|
}
|
|
|
|
// CompleteResult says what completing a task actually did.
|
|
//
|
|
// A recurring task is not finished by being ticked off — it moves to its next
|
|
// occurrence. The caller needs to know which happened so it can tell the person,
|
|
// and so the activity log records a completion rather than an edit.
|
|
type CompleteResult struct {
|
|
// Recurred is true when the task advanced instead of closing.
|
|
Recurred bool
|
|
// NextDue is the new due date, set only when Recurred.
|
|
NextDue string
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// A task with a recurrence rule takes a different path entirely — see recurTask.
|
|
func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) (CompleteResult, error) {
|
|
// The recurring case is decided before the transaction that closes the task,
|
|
// because the two are mutually exclusive: a repeating chore that has been done
|
|
// this week is not finished, it is due again next week.
|
|
rule, dueDate, err := db.recurrenceOf(ctx, taskID)
|
|
if err != nil {
|
|
return CompleteResult{}, err
|
|
}
|
|
if rule != "" {
|
|
return db.recurTask(ctx, taskID, userID, rule, dueDate)
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
err = 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
|
|
})
|
|
return CompleteResult{}, err
|
|
}
|
|
|
|
func (db *DB) recurrenceOf(ctx context.Context, taskID string) (rule, dueDate string, err error) {
|
|
var r, d sql.NullString
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT recurrence_rule, due_date FROM tasks
|
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`, taskID).Scan(&r, &d)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return r.String, d.String, nil
|
|
}
|
|
|
|
// recurTask advances a repeating task to its next occurrence.
|
|
//
|
|
// The row is kept and its date moved, rather than closing this one and inserting a
|
|
// successor. That keeps the task's id stable, so its sub-tasks, comments and
|
|
// attachments stay attached — which is what somebody means by "my weekly review",
|
|
// a thing that persists, rather than fifty-two separate tasks.
|
|
//
|
|
// A completion is still written to the activity log, because "what did I get done
|
|
// this week" has to include the chores that repeat.
|
|
func (db *DB) recurTask(ctx context.Context, taskID, userID, rule, dueDate string) (CompleteResult, error) {
|
|
// The anchor is the task's own due date. A repeating task with no date has
|
|
// nothing to advance from, so today stands in — which is also what somebody
|
|
// adding "hver mandag" with no start date means.
|
|
anchor := time.Now().UTC()
|
|
if dueDate != "" {
|
|
if parsed, err := time.Parse("2006-01-02", dueDate); err == nil {
|
|
anchor = parsed
|
|
}
|
|
}
|
|
|
|
next, err := recurrence.Next(rule, anchor)
|
|
if err != nil {
|
|
// A finite series that has run out really is finished. Close it, so the
|
|
// task does not sit there for ever refusing to be completed.
|
|
if errors.Is(err, recurrence.ErrSeriesEnded) {
|
|
if _, cerr := db.clearRecurrence(ctx, taskID); cerr != nil {
|
|
return CompleteResult{}, cerr
|
|
}
|
|
return db.CompleteTask(ctx, taskID, userID)
|
|
}
|
|
return CompleteResult{}, fmt.Errorf("advance recurrence %q: %w", rule, err)
|
|
}
|
|
|
|
nextDue := next.Format("2006-01-02")
|
|
now := time.Now().Unix()
|
|
|
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
|
// due_datetime is recomputed from the new day, keeping the clock time: a
|
|
// task due every Monday at 10:00 stays due at 10:00.
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE tasks
|
|
SET due_date = ?,
|
|
due_datetime = CASE
|
|
WHEN due_datetime IS NULL THEN NULL
|
|
ELSE due_datetime + (julianday(?) - julianday(due_date)) * 86400
|
|
END,
|
|
updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`,
|
|
nextDue, nextDue, now, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return CompleteResult{}, err
|
|
}
|
|
return CompleteResult{Recurred: true, NextDue: nextDue}, nil
|
|
}
|
|
|
|
func (db *DB) clearRecurrence(ctx context.Context, taskID string) (int64, error) {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE tasks SET recurrence_rule = NULL, updated_at = ? WHERE id = ?`,
|
|
time.Now().Unix(), taskID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
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), ",")
|
|
}
|