feat(phase3): reminders, ICS feeds, templates and nightly backups

Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.

The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.

Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.

Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:05:24 +02:00
co-authored by Claude Opus 5
parent 4d142b2fa7
commit 7e4c730049
13 changed files with 2315 additions and 1 deletions
+330
View File
@@ -0,0 +1,330 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/ics"
"github.com/kristianwind/verdande/internal/store"
)
// handleICSFeed serves a user's calendar.
//
// Unauthenticated by necessity: Apple Calendar and Google fetch a URL with no
// credentials and no way to prompt for any, so the token in the path *is* the
// credential. That is why it is a token of its own — it can be rotated when a feed
// URL ends up somewhere it should not, without signing anybody out.
func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSuffix(chi.URLParam(r, "token"), ".ics")
user, err := s.db.UserByICSToken(r.Context(), token)
if err != nil {
// Not 401: a calendar client would show a password prompt nobody can
// answer. A missing feed is a missing feed.
http.NotFound(w, r)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
// Everything with a date, plus recently completed ones so a calendar does
// not silently drop what was done this week.
IncludeCompleted: true,
Limit: 2000,
})
if err != nil {
s.internal(w, "ics feed", err)
return
}
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
if err != nil {
s.internal(w, "ics feed projects", err)
return
}
names := map[string]string{}
for _, p := range projects {
names[p.ID] = p.Name
}
cutoff := time.Now().AddDate(0, 0, -30)
cal := ics.Calendar{Name: "verdande — " + user.Name, Domain: feedDomain(s.cfg.BaseURL)}
for _, t := range tasks {
// A task with no date has no place in a calendar, and a completion from
// months ago is noise in every client that shows it.
if t.DueDate == "" {
continue
}
if t.CompletedAt != nil && t.CompletedAt.Before(cutoff) {
continue
}
cal.Tasks = append(cal.Tasks, ics.Task{
ID: t.ID, Content: t.Content, Description: t.Description,
ProjectName: names[t.ProjectID], DueDate: t.DueDate, DueDatetime: t.DueDatetime,
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
Completed: t.Completed(), CompletedAt: t.CompletedAt,
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
})
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `inline; filename="`+ics.Filename("verdande")+`"`)
// A feed is per-person and changes constantly; a cached copy in a shared proxy
// would be both stale and somebody else's.
w.Header().Set("Cache-Control", "private, no-cache")
w.Write([]byte(ics.Render(cal)))
}
type feedResponse struct {
URL string `json:"url"`
}
// handleGetFeed returns the subscription URL, creating the token on first ask.
func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
if err != nil {
s.internal(w, "ics token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
}
// handleRotateFeed issues a new token, which immediately breaks every existing
// subscription. That is the point: it is what somebody does when the old URL has
// leaked.
func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
token, err := auth.NewToken()
if err != nil {
s.internal(w, "generate feed token", err)
return
}
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
s.internal(w, "set feed token", err)
return
}
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
}
func feedDomain(baseURL string) string {
host := stripScheme(baseURL)
if i := strings.IndexAny(host, "/:"); i > 0 {
host = host[:i]
}
if host == "" {
return "verdande.local"
}
return host
}
// --- reminders --------------------------------------------------------------------
type reminderJSON struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RemindAt string `json:"remind_at,omitempty"`
OffsetMin *int `json:"offset_min,omitempty"`
Sent bool `json:"sent"`
}
type reminderRequest struct {
// Exactly one of the two. An absolute moment, or minutes relative to the
// task's due time — negative for "before", which is the only kind anybody
// actually wants.
RemindAt *string `json:"remind_at"`
OffsetMin *int `json:"offset_min"`
}
func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
reminders, err := s.db.ListReminders(r.Context(), taskID)
if err != nil {
s.internal(w, "list reminders", err)
return
}
out := make([]reminderJSON, 0, len(reminders))
for _, rem := range reminders {
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin, Sent: rem.SentAt != nil}
if !rem.RemindAt.IsZero() {
j.RemindAt = rem.RemindAt.Format(time.RFC3339)
}
out = append(out, j)
}
writeJSON(w, http.StatusOK, map[string]any{"reminders": out})
}
func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
var req reminderRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
taskID := chi.URLParam(r, "taskID")
user := userFrom(r.Context())
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
if (req.RemindAt == nil) == (req.OffsetMin == nil) {
writeFieldErrors(w, map[string]string{
"remind_at": "give either a time or an offset, not both",
})
return
}
var at *time.Time
if req.RemindAt != nil {
parsed, err := time.Parse(time.RFC3339, *req.RemindAt)
if err != nil {
writeFieldErrors(w, map[string]string{"remind_at": "must be an RFC 3339 timestamp"})
return
}
at = &parsed
}
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
if err != nil {
s.internal(w, "create reminder", err)
return
}
j := reminderJSON{ID: rem.ID, TaskID: rem.TaskID, OffsetMin: rem.OffsetMin}
if at != nil {
j.RemindAt = at.Format(time.RFC3339)
}
writeJSON(w, http.StatusCreated, j)
}
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
if err != nil {
s.storeError(w, "delete reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- templates ---------------------------------------------------------------------
type templateJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color"`
TaskCount int `json:"task_count"`
CreatedAt string `json:"created_at"`
}
func toTemplateJSON(t store.Template) templateJSON {
return templateJSON{
ID: t.ID, Name: t.Name, Description: t.Description, Color: t.Color,
TaskCount: len(t.Body.Tasks), CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
}
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list templates", err)
return
}
out := make([]templateJSON, 0, len(templates))
for _, t := range templates {
out = append(out, toTemplateJSON(t))
}
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
type saveTemplateRequest struct {
ProjectID string `json:"project_id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
var req saveTemplateRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if req.ProjectID == "" {
writeFieldErrors(w, map[string]string{"project_id": "required"})
return
}
if _, err := store.RequireProjectRole(r.Context(), s.db, req.ProjectID, user.ID, store.RoleViewer); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
if err != nil {
s.storeError(w, "save template", err)
return
}
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
}
type useTemplateRequest struct {
Name string `json:"name"`
// StartDate is day zero for every relative due date in the template. Defaults
// to today, which is what "start this checklist now" means.
StartDate string `json:"start_date"`
}
func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
var req useTemplateRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "get template", err)
return
}
start := time.Now().In(userLocation(user.Timezone))
if req.StartDate != "" {
parsed, err := time.ParseInLocation("2006-01-02", req.StartDate, userLocation(user.Timezone))
if err != nil {
writeFieldErrors(w, map[string]string{"start_date": "must be a date like 2026-03-15"})
return
}
start = parsed
}
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
if err != nil {
s.internal(w, "create from template", err)
return
}
s.activity(r, project.ID, "", "project.created", map[string]any{
"name": project.Name, "from_template": tpl.Name,
})
writeJSON(w, http.StatusCreated, toProjectJSON(*project))
}
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
if err != nil {
s.storeError(w, "delete template", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+24
View File
@@ -67,6 +67,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// Docker both need this to answer before anyone has logged in.
r.Get("/healthz", s.handleHealth)
// The calendar feed is outside /api/v1 and outside the session: a calendar
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
@@ -126,6 +130,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{filterID}", s.handleDeleteFilter)
})
r.Get("/feed", s.handleGetFeed)
r.Post("/feed/rotate", s.handleRotateFeed)
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
r.Post("/{templateID}/use", s.handleUseTemplate)
r.Delete("/{templateID}", s.handleDeleteTemplate)
})
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
r.Route("/labels", func(r chi.Router) {
r.Get("/", s.handleListLabels)
r.Post("/", s.handleCreateLabel)
@@ -184,6 +200,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
@@ -200,6 +218,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
// Mail and Hub are exposed so the background jobs can deliver through the same
// sender and the same live connections the request path uses — a reminder should
// arrive in an open tab exactly as a colleague's edit does.
func (s *Server) Mail() *mail.Sender { return s.mail }
func (s *Server) Hub() *realtime.Hub { return s.hub }
// handleHealth reports whether verdande can actually serve requests, which means
// reaching the database — a process that is up but cannot read its own data is not
// healthy, and answering 200 for it would keep a broken container in rotation.
+302
View File
@@ -1,7 +1,9 @@
package httpapi
import (
"io"
"net/http"
"strings"
"testing"
"time"
@@ -1006,3 +1008,303 @@ func TestDeletingALabelKeepsItsTasks(t *testing.T) {
t.Errorf("labels = %v, want empty", got)
}
}
// --- reminders, feeds and templates -----------------------------------------------
func TestReminders(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "møde", "due_date": userDate(t, 0), "due_time": "14:00",
})
taskID := task["id"].(string)
// Relative: ten minutes before whenever the task is due.
offset := -10
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"offset_min": offset})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create relative reminder: %d %v", resp.StatusCode, body)
}
// Absolute.
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": time.Now().Add(time.Hour).Format(time.RFC3339)})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create absolute reminder: %d", resp.StatusCode)
}
// Both at once is not a reminder with two times; it is a mistake.
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders", map[string]any{
"offset_min": offset, "remind_at": time.Now().Format(time.RFC3339),
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("a reminder with both a time and an offset: status %d, want 422", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/reminders", nil)
reminders, _ := list["reminders"].([]any)
if len(reminders) != 2 {
t.Fatalf("got %d reminders, want 2", len(reminders))
}
}
// A reminder that has come due is found by the sweep; one in the future is not,
// and neither is one on a task that has already been done.
func TestDueRemindersAreFoundOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "husk"})
taskID := task["id"].(string)
past := time.Now().Add(-time.Minute)
future := time.Now().Add(time.Hour)
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": past.Format(time.RFC3339)})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": future.Format(time.RFC3339)})
due, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(due) != 1 {
t.Fatalf("got %d due reminders, want only the one in the past", len(due))
}
if due[0].TaskContent != "husk" {
t.Errorf("the reminder does not carry the task's text: %+v", due[0])
}
// Marking it sent takes it out of the sweep, so it cannot go out twice.
if err := ts.db.MarkReminderSent(t.Context(), due[0].ID); err != nil {
t.Fatal(err)
}
again, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Errorf("a sent reminder came round again: %+v", again)
}
}
// A reminder for something already done is a small betrayal of the whole feature.
func TestCompletedTasksDoNotRemind(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "gjort"})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": time.Now().Add(-time.Minute).Format(time.RFC3339)})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/complete", nil)
due, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(due) != 0 {
t.Errorf("a completed task still reminded: %+v", due)
}
}
func TestICSFeed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "møde med revisor", "due_date": userDate(t, 1), "due_time": "10:00",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "uden dato"})
resp, body := ts.do(t, "GET", "/api/v1/feed", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get feed: %d %v", resp.StatusCode, body)
}
feedURL, _ := body["url"].(string)
if feedURL == "" {
t.Fatal("no feed URL was returned")
}
// The feed itself is fetched without a session, as a calendar client would.
path := feedURL[strings.Index(feedURL, "/ics/"):]
req, err := http.NewRequest("GET", ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
raw, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer raw.Body.Close()
if raw.StatusCode != http.StatusOK {
t.Fatalf("fetching the feed without a session: %d", raw.StatusCode)
}
if ct := raw.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
t.Errorf("Content-Type = %q, want text/calendar", ct)
}
content, _ := io.ReadAll(raw.Body)
feed := string(content)
if !strings.Contains(feed, "BEGIN:VCALENDAR") {
t.Error("the feed is not an iCalendar document")
}
if !strings.Contains(feed, "SUMMARY:møde med revisor") {
t.Error("the dated task is missing from the feed")
}
if strings.Contains(feed, "uden dato") {
t.Error("a task with no date appeared in a calendar")
}
// An invented token is a missing feed, not a prompt for credentials nobody
// can answer.
bad, err := (&http.Client{}).Get(ts.URL + "/ics/opfundet.ics")
if err != nil {
t.Fatal(err)
}
defer bad.Body.Close()
if bad.StatusCode != http.StatusNotFound {
t.Errorf("an unknown feed token: status %d, want 404", bad.StatusCode)
}
}
// Rotating the token is what somebody does when a feed URL has leaked, so the old
// one has to stop working immediately.
func TestRotatingTheFeedTokenBreaksTheOldURL(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, first := ts.do(t, "GET", "/api/v1/feed", nil)
oldURL := first["url"].(string)
_, second := ts.do(t, "POST", "/api/v1/feed/rotate", nil)
newURL := second["url"].(string)
if oldURL == newURL {
t.Fatal("rotating produced the same URL")
}
oldPath := oldURL[strings.Index(oldURL, "/ics/"):]
resp, err := (&http.Client{}).Get(ts.URL + oldPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("the old feed URL still works: status %d", resp.StatusCode)
}
}
func TestProjectTemplates(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Onboarding"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Første uge"})
sectionID := section["id"].(string)
_, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opret konti", "project_id": projectID, "section_id": sectionID,
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"it"},
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "udlever laptop", "project_id": projectID, "parent_id": parent["id"].(string),
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opfølgning", "project_id": projectID, "due_date": userDate(t, 14),
})
resp, tpl := ts.do(t, "POST", "/api/v1/templates", map[string]any{
"project_id": projectID, "name": "Onboarding-skabelon",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save template: %d %v", resp.StatusCode, tpl)
}
if tpl["task_count"] != float64(3) {
t.Errorf("task_count = %v, want 3", tpl["task_count"])
}
// Used a month later, the dates land relative to the new start.
start := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
resp, created := ts.do(t, "POST", "/api/v1/templates/"+tpl["id"].(string)+"/use",
map[string]any{"name": "Onboarding: Mette", "start_date": start})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("use template: %d %v", resp.StatusCode, created)
}
if created["name"] != "Onboarding: Mette" {
t.Errorf("name = %v", created["name"])
}
newID := created["id"].(string)
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+newID, nil)
tasks, _ := list["tasks"].([]any)
if len(tasks) != 3 {
t.Fatalf("the new project has %d tasks, want 3", len(tasks))
}
// Sections came across, sub-tasks kept their parent, and the spacing between
// the dates survived even though the dates themselves did not.
_, sections := ts.do(t, "GET", "/api/v1/projects/"+newID+"/sections", nil)
if got, _ := sections["sections"].([]any); len(got) != 1 {
t.Errorf("got %d sections, want 1", len(got))
}
var withParent, dated int
var earliest, latest string
for _, raw := range tasks {
task := raw.(map[string]any)
if task["parent_id"] != nil && task["parent_id"] != "" {
withParent++
}
if due, _ := task["due_date"].(string); due != "" {
dated++
if earliest == "" || due < earliest {
earliest = due
}
if due > latest {
latest = due
}
}
}
if withParent != 1 {
t.Errorf("%d tasks have a parent, want 1", withParent)
}
if dated != 2 {
t.Errorf("%d tasks have a date, want 2", dated)
}
if earliest != start {
t.Errorf("the first task is due %q, want the stated start %q", earliest, start)
}
first, _ := time.Parse("2006-01-02", earliest)
last, _ := time.Parse("2006-01-02", latest)
if gap := int(last.Sub(first).Hours() / 24); gap != 14 {
t.Errorf("the tasks are %d days apart, want the original 14", gap)
}
}
func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
_, tpl := ts.do(t, "POST", "/api/v1/templates",
map[string]any{"project_id": project["id"].(string)})
tplID := tpl["id"].(string)
resp, _ := other.do(t, "POST", "/api/v1/templates/"+tplID+"/use", map[string]any{})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("another user used the template: status %d", resp.StatusCode)
}
_, list := other.do(t, "GET", "/api/v1/templates", nil)
if got, _ := list["templates"].([]any); len(got) != 0 {
t.Errorf("another user's template list shows %d entries", len(got))
}
}