From 7e4c7300492853af340ed94a0ed85b1c10fed38f Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 07:05:24 +0200 Subject: [PATCH] feat(phase3): reminders, ICS feeds, templates and nightly backups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/verdande/main.go | 18 +- internal/httpapi/feeds_handlers.go | 330 ++++++++++++++++++ internal/httpapi/server.go | 24 ++ internal/httpapi/tasks_test.go | 302 ++++++++++++++++ internal/ics/ics.go | 223 ++++++++++++ internal/ics/ics_test.go | 222 ++++++++++++ internal/jobs/jobs.go | 264 ++++++++++++++ internal/jobs/jobs_test.go | 278 +++++++++++++++ internal/mail/mail.go | 16 + .../store/migrations/0003_feeds_templates.sql | 51 +++ internal/store/reminders.go | 266 ++++++++++++++ internal/store/templates.go | 275 +++++++++++++++ internal/store/users.go | 47 +++ 13 files changed, 2315 insertions(+), 1 deletion(-) create mode 100644 internal/httpapi/feeds_handlers.go create mode 100644 internal/ics/ics.go create mode 100644 internal/ics/ics_test.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/jobs/jobs_test.go create mode 100644 internal/store/migrations/0003_feeds_templates.sql create mode 100644 internal/store/reminders.go create mode 100644 internal/store/templates.go diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index 6ab2d4c..d153903 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -16,6 +16,7 @@ import ( "github.com/kristianwind/verdande/internal/config" "github.com/kristianwind/verdande/internal/httpapi" + "github.com/kristianwind/verdande/internal/jobs" "github.com/kristianwind/verdande/internal/store" ) @@ -53,9 +54,19 @@ func run() error { defer db.Close() log.Info("database ready", "path", db.Path()) + api := httpapi.New(cfg, db, log, webAssets(log)) + + // Background work: reminders, the nightly backup, and the trash emptying + // itself. Started before the listener so a reminder that came due while the + // process was down goes out immediately rather than on the next tick. + jobCtx, stopJobs := context.WithCancel(context.Background()) + defer stopJobs() + runner := jobs.New(cfg, db, log, api.Mail(), api.Hub()) + runner.Start(jobCtx) + srv := &http.Server{ Addr: cfg.Addr, - Handler: httpapi.New(cfg, db, log, webAssets(log)), + Handler: api, ReadHeaderTimeout: 10 * time.Second, // No WriteTimeout: it would also cap the WebSocket and any long file // download. Per-handler timeouts do that job with the right granularity. @@ -89,6 +100,11 @@ func run() error { return fmt.Errorf("shutdown: %w", err) } + // Let the background jobs finish the pass they are in. A shutdown during a + // backup would otherwise leave a partial file that looks like a backup. + stopJobs() + runner.Wait() + // WAL mode leaves recent writes in the -wal file. Checkpointing on the way out // means a backup taken of a stopped container is complete. if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { diff --git a/internal/httpapi/feeds_handlers.go b/internal/httpapi/feeds_handlers.go new file mode 100644 index 0000000..3745236 --- /dev/null +++ b/internal/httpapi/feeds_handlers.go @@ -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) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a2f6e82..bdb306e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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. diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index f9c2449..41cb402 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -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)) + } +} diff --git a/internal/ics/ics.go b/internal/ics/ics.go new file mode 100644 index 0000000..9558170 --- /dev/null +++ b/internal/ics/ics.go @@ -0,0 +1,223 @@ +// Package ics writes iCalendar documents. +// +// Tasks go out as VTODO, not VEVENT. A to-do has a due date and a completion state; +// an event has a start and an end and neither of those things. Apple Reminders and +// Thunderbird read VTODO natively, and it is the same component the CalDAV server +// will serve later — so a task exported today is the task synchronised tomorrow, +// with no second representation to keep in step. +// +// Google Calendar is the awkward exception: it subscribes to ICS but ignores VTODO +// entirely. Tasks that carry a clock time are therefore also emitted as VEVENT, +// which is what makes a feed useful in the calendar most people actually keep. +package ics + +import ( + "fmt" + "strings" + "time" +) + +// Task is the subset of a task a calendar needs. Defined here rather than importing +// the store's type, so this package stays a pure formatter with no database in it. +type Task struct { + ID string + Content string + Description string + ProjectName string + DueDate string // "YYYY-MM-DD" + DueDatetime *time.Time + DurationMin *int + Priority int + Recurrence string + Completed bool + CompletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type Calendar struct { + Name string + // Domain appears in every UID. It has to be stable for the lifetime of the + // feed: a client that sees a new UID for the same task treats it as a new item + // and leaves the old one behind as a duplicate. + Domain string + Tasks []Task +} + +// Render writes the whole document. +func Render(cal Calendar) string { + var b strings.Builder + + b.WriteString("BEGIN:VCALENDAR\r\n") + b.WriteString("VERSION:2.0\r\n") + b.WriteString("PRODID:-//verdande//verdande//DA\r\n") + b.WriteString("CALSCALE:GREGORIAN\r\n") + b.WriteString("METHOD:PUBLISH\r\n") + writeLine(&b, "X-WR-CALNAME:"+escape(cal.Name)) + // Tells a subscribing client how often to poll. Without it Google refetches on + // its own schedule, which can be many hours. + b.WriteString("X-PUBLISHED-TTL:PT1H\r\n") + b.WriteString("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\n") + + for _, t := range cal.Tasks { + writeTodo(&b, t, cal.Domain) + // A dated task also goes out as an event, for clients that ignore VTODO. + if t.DueDatetime != nil && !t.Completed { + writeEvent(&b, t, cal.Domain) + } + } + + b.WriteString("END:VCALENDAR\r\n") + return b.String() +} + +func writeTodo(b *strings.Builder, t Task, domain string) { + b.WriteString("BEGIN:VTODO\r\n") + writeLine(b, "UID:"+t.ID+"@"+domain) + writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt)) + writeLine(b, "CREATED:"+utc(t.CreatedAt)) + writeLine(b, "SUMMARY:"+escape(t.Content)) + + if t.Description != "" { + writeLine(b, "DESCRIPTION:"+escape(t.Description)) + } + if t.ProjectName != "" { + writeLine(b, "CATEGORIES:"+escape(t.ProjectName)) + } + + switch { + case t.DueDatetime != nil: + writeLine(b, "DUE:"+utc(*t.DueDatetime)) + case t.DueDate != "": + // A whole-day due date is a DATE, not a DATE-TIME. Emitting midnight in + // some timezone instead would move the task a day for anybody east or + // west of the server. + writeLine(b, "DUE;VALUE=DATE:"+strings.ReplaceAll(t.DueDate, "-", "")) + } + + if t.Recurrence != "" { + writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:")) + } + + // RFC 5545 priority runs 1 (highest) to 9, with 0 meaning undefined. verdande's + // P4 is "no priority stated" rather than "lowest", so it maps to 0 — otherwise + // every unprioritised task arrives in a client marked explicitly low. + switch t.Priority { + case 1: + writeLine(b, "PRIORITY:1") + case 2: + writeLine(b, "PRIORITY:5") + case 3: + writeLine(b, "PRIORITY:7") + default: + writeLine(b, "PRIORITY:0") + } + + if t.Completed { + writeLine(b, "STATUS:COMPLETED") + writeLine(b, "PERCENT-COMPLETE:100") + if t.CompletedAt != nil { + writeLine(b, "COMPLETED:"+utc(*t.CompletedAt)) + } + } else { + writeLine(b, "STATUS:NEEDS-ACTION") + } + + b.WriteString("END:VTODO\r\n") +} + +func writeEvent(b *strings.Builder, t Task, domain string) { + start := *t.DueDatetime + // Without a stated duration a task is shown as half an hour. A zero-length + // event collapses to an invisible sliver in most calendar grids. + minutes := 30 + if t.DurationMin != nil && *t.DurationMin > 0 { + minutes = *t.DurationMin + } + end := start.Add(time.Duration(minutes) * time.Minute) + + b.WriteString("BEGIN:VEVENT\r\n") + // A distinct UID suffix: the event and the to-do describe the same task, and + // sharing a UID would make a client treat them as two versions of one item. + writeLine(b, "UID:"+t.ID+"-event@"+domain) + writeLine(b, "DTSTAMP:"+utc(t.UpdatedAt)) + writeLine(b, "DTSTART:"+utc(start)) + writeLine(b, "DTEND:"+utc(end)) + writeLine(b, "SUMMARY:"+escape(t.Content)) + if t.Description != "" { + writeLine(b, "DESCRIPTION:"+escape(t.Description)) + } + if t.Recurrence != "" { + writeLine(b, "RRULE:"+strings.TrimPrefix(t.Recurrence, "RRULE:")) + } + writeLine(b, "TRANSP:TRANSPARENT") + b.WriteString("END:VEVENT\r\n") +} + +func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") } + +// escape applies RFC 5545 text escaping. A comma or semicolon in a task title is +// a value separator in iCalendar, so an unescaped one silently truncates the +// summary at that character in every client. +func escape(s string) string { + r := strings.NewReplacer( + "\\", "\\\\", + ";", "\\;", + ",", "\\,", + "\n", "\\n", + "\r", "", + ) + return r.Replace(s) +} + +// writeLine folds at 75 octets, as the spec requires, with a space beginning each +// continuation. Folding is by byte with a check for UTF-8 continuation bytes: a +// break in the middle of "ø" produces a line no parser can read. +func writeLine(b *strings.Builder, line string) { + const limit = 73 // 75 minus CRLF + + if len(line) <= limit { + b.WriteString(line) + b.WriteString("\r\n") + return + } + + for i := 0; i < len(line); { + end := i + limit + if i > 0 { + end = i + limit - 1 // the leading space on a continuation counts + } + if end > len(line) { + end = len(line) + } + // Never split a multi-byte character. + for end > i && end < len(line) && line[end]&0xC0 == 0x80 { + end-- + } + if i > 0 { + b.WriteString(" ") + } + b.WriteString(line[i:end]) + b.WriteString("\r\n") + i = end + } +} + +// Filename is what a browser saves the feed as. +func Filename(name string) string { + safe := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + return r + case r >= 'A' && r <= 'Z': + return r + 32 + case r == ' ', r == '_': + return '-' + } + return -1 + }, name) + if safe == "" { + safe = "verdande" + } + return fmt.Sprintf("%s.ics", safe) +} diff --git a/internal/ics/ics_test.go b/internal/ics/ics_test.go new file mode 100644 index 0000000..302d2e1 --- /dev/null +++ b/internal/ics/ics_test.go @@ -0,0 +1,222 @@ +package ics + +import ( + "strings" + "testing" + "time" +) + +func sample() Calendar { + due := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC) + done := time.Date(2026, 3, 9, 14, 30, 0, 0, time.UTC) + duration := 45 + + return Calendar{ + Name: "verdande", + Domain: "todo.example.dk", + Tasks: []Task{ + { + ID: "t1", Content: "Betal moms", Description: "husk bilag", + ProjectName: "Firma", DueDate: "2026-03-10", DueDatetime: &due, + DurationMin: &duration, Priority: 1, + CreatedAt: done, UpdatedAt: done, + }, + { + ID: "t2", Content: "Ryd op", DueDate: "2026-03-12", Priority: 4, + CreatedAt: done, UpdatedAt: done, + }, + { + ID: "t3", Content: "Vand planterne", Recurrence: "FREQ=WEEKLY;BYDAY=MO", + DueDate: "2026-03-16", Priority: 3, CreatedAt: done, UpdatedAt: done, + }, + { + ID: "t4", Content: "Færdig ting", Completed: true, CompletedAt: &done, + CreatedAt: done, UpdatedAt: done, + }, + }, + } +} + +func TestRenderStructure(t *testing.T) { + out := Render(sample()) + + if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") { + t.Error("the document does not begin with BEGIN:VCALENDAR") + } + if !strings.HasSuffix(out, "END:VCALENDAR\r\n") { + t.Error("the document does not end with END:VCALENDAR") + } + if strings.Count(out, "BEGIN:VTODO") != 4 { + t.Errorf("got %d VTODOs, want one per task", strings.Count(out, "BEGIN:VTODO")) + } + if a, b := strings.Count(out, "BEGIN:VTODO"), strings.Count(out, "END:VTODO"); a != b { + t.Errorf("%d BEGIN:VTODO but %d END:VTODO", a, b) + } + + // Every line ends CRLF — a bare LF is not iCalendar and some clients refuse it. + for _, line := range strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n") { + if strings.Contains(line, "\n") { + t.Fatalf("a line contains a bare newline: %q", line) + } + } +} + +// Tasks are VTODO. That is what makes them to-dos in Apple Reminders rather than +// events, and it is the same component CalDAV will serve. +func TestTasksAreTodos(t *testing.T) { + out := Render(sample()) + + if !strings.Contains(out, "SUMMARY:Betal moms") { + t.Error("the summary is missing") + } + if !strings.Contains(out, "UID:t1@todo.example.dk") { + t.Error("the UID is missing or not domain-qualified") + } + if !strings.Contains(out, "STATUS:NEEDS-ACTION") { + t.Error("an open task is not marked NEEDS-ACTION") + } + if !strings.Contains(out, "STATUS:COMPLETED") { + t.Error("a completed task is not marked COMPLETED") + } + if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") { + t.Error("the recurrence rule did not survive into the feed") + } + if !strings.Contains(out, "CATEGORIES:Firma") { + t.Error("the project is not carried as a category") + } +} + +// A whole-day due date must be a DATE, not a DATE-TIME. Emitting midnight in some +// timezone would move the task by a day for anybody east or west of the server. +func TestWholeDayDueDatesAreDates(t *testing.T) { + out := Render(sample()) + + if !strings.Contains(out, "DUE;VALUE=DATE:20260312") { + t.Error("a dateless task's due date is not a DATE value") + } + // The one with a clock time keeps it. + if !strings.Contains(out, "DUE:20260310T090000Z") { + t.Error("a timed task lost its clock time") + } +} + +// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events, +// or a feed is useless in the calendar most people actually keep. +func TestTimedTasksAlsoBecomeEvents(t *testing.T) { + out := Render(sample()) + + if strings.Count(out, "BEGIN:VEVENT") != 1 { + t.Fatalf("got %d VEVENTs, want one for the single timed task", + strings.Count(out, "BEGIN:VEVENT")) + } + if !strings.Contains(out, "DTSTART:20260310T090000Z") { + t.Error("the event has no start") + } + // 45 minutes of stated duration. + if !strings.Contains(out, "DTEND:20260310T094500Z") { + t.Error("the event's end does not reflect the task's duration") + } + // The event must not share the to-do's UID, or clients treat them as one item. + if !strings.Contains(out, "UID:t1-event@todo.example.dk") { + t.Error("the event does not have a UID of its own") + } +} + +// A comma or semicolon is a value separator in iCalendar. Unescaped, it silently +// truncates the summary at that character in every client. +func TestSpecialCharactersAreEscaped(t *testing.T) { + now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC) + out := Render(Calendar{ + Name: "verdande", Domain: "example.dk", + Tasks: []Task{{ + ID: "t1", Content: `Køb: mælk, brød; smør \ og mere`, + Description: "linje 1\nlinje 2", + CreatedAt: now, UpdatedAt: now, + }}, + }) + + if !strings.Contains(out, `SUMMARY:Køb: mælk\, brød\; smør \\ og mere`) { + t.Errorf("the summary is not escaped correctly:\n%s", out) + } + if !strings.Contains(out, `DESCRIPTION:linje 1\nlinje 2`) { + t.Error("a newline in the description was not escaped") + } + // And the raw separators must not survive unescaped anywhere in the summary. + for _, line := range strings.Split(out, "\r\n") { + if !strings.HasPrefix(line, "SUMMARY:") { + continue + } + body := strings.TrimPrefix(line, "SUMMARY:") + for i := 0; i < len(body); i++ { + if (body[i] == ',' || body[i] == ';') && (i == 0 || body[i-1] != '\\') { + t.Errorf("unescaped %q in %q", body[i], line) + } + } + } +} + +// Lines fold at 75 octets, and a fold in the middle of a multi-byte character +// produces a line no parser can read. +func TestLongLinesFoldWithoutBreakingCharacters(t *testing.T) { + now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC) + long := strings.Repeat("ø", 200) // two bytes each, so folds land mid-character + out := Render(Calendar{ + Name: "verdande", Domain: "example.dk", + Tasks: []Task{{ID: "t1", Content: long, CreatedAt: now, UpdatedAt: now}}, + }) + + for _, line := range strings.Split(out, "\r\n") { + if len(line) > 75 { + t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line) + } + } + + // Unfolding must reproduce the original text exactly. + unfolded := strings.ReplaceAll(out, "\r\n ", "") + if !strings.Contains(unfolded, "SUMMARY:"+long) { + t.Error("the summary did not survive folding and unfolding") + } +} + +// P4 means "no priority stated", not "lowest". Mapping it to 9 would arrive in a +// client as an explicit low priority on every ordinary task. +func TestPriorityMapping(t *testing.T) { + now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC) + want := map[int]string{1: "PRIORITY:1", 2: "PRIORITY:5", 3: "PRIORITY:7", 4: "PRIORITY:0"} + + for priority, expected := range want { + out := Render(Calendar{ + Name: "v", Domain: "example.dk", + Tasks: []Task{{ID: "t", Content: "x", Priority: priority, CreatedAt: now, UpdatedAt: now}}, + }) + if !strings.Contains(out, expected) { + t.Errorf("priority %d did not produce %q", priority, expected) + } + } +} + +func TestEmptyCalendarIsStillValid(t *testing.T) { + out := Render(Calendar{Name: "verdande", Domain: "example.dk"}) + + if !strings.Contains(out, "BEGIN:VCALENDAR") || !strings.Contains(out, "END:VCALENDAR") { + t.Error("an empty calendar is not a well-formed document") + } + if strings.Contains(out, "BEGIN:VTODO") { + t.Error("an empty calendar contains a task") + } +} + +func TestFilename(t *testing.T) { + cases := map[string]string{ + "verdande": "verdande.ics", + "Mine opgaver": "mine-opgaver.ics", + "Q3/2026 møder": "q32026-mder.ics", + "": "verdande.ics", + "???": "verdande.ics", + } + for in, want := range cases { + if got := Filename(in); got != want { + t.Errorf("Filename(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..27b364c --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,264 @@ +// Package jobs runs the work that happens without anybody asking: reminders going +// out, nightly backups, and the trash emptying itself after thirty days. +// +// All of it lives in one goroutine per job inside the same process. There is no +// queue and no worker pool, because there is nothing here that would benefit from +// one — a homelab instance has a handful of reminders an hour, and a second moving +// part would be more likely to fail than the thing it was protecting. +package jobs + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/mail" + "github.com/kristianwind/verdande/internal/realtime" + "github.com/kristianwind/verdande/internal/store" +) + +type Runner struct { + cfg *config.Config + db *store.DB + log *slog.Logger + mail *mail.Sender + hub *realtime.Hub + + wg sync.WaitGroup +} + +func New(cfg *config.Config, db *store.DB, log *slog.Logger, sender *mail.Sender, hub *realtime.Hub) *Runner { + return &Runner{cfg: cfg, db: db, log: log, mail: sender, hub: hub} +} + +// Start launches every background job. They stop when ctx is cancelled, and Wait +// blocks until each has finished the pass it was in the middle of — so a shutdown +// during a backup does not leave half a file behind. +func (r *Runner) Start(ctx context.Context) { + r.every(ctx, "reminders", time.Minute, r.sendDueReminders) + // Nightly work is checked hourly rather than scheduled at a wall-clock time: + // a container that was asleep or restarting at 03:00 would otherwise skip a + // day entirely, and this way it catches up on the next hour it is awake. + r.every(ctx, "backup", time.Hour, r.nightlyBackup) + r.every(ctx, "trash", time.Hour, r.emptyTrash) + r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions) +} + +func (r *Runner) Wait() { r.wg.Wait() } + +func (r *Runner) every(ctx context.Context, name string, interval time.Duration, fn func(context.Context) error) { + r.wg.Add(1) + go func() { + defer r.wg.Done() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // A job that panics must not take the process down with it, and + // must not stop the other jobs either. + func() { + defer func() { + if p := recover(); p != nil { + r.log.Error("background job panicked", "job", name, "panic", p) + } + }() + if err := fn(ctx); err != nil { + r.log.Error("background job failed", "job", name, "err", err) + } + }() + } + } + }() +} + +// --- reminders ------------------------------------------------------------------ + +// sendDueReminders delivers everything that has come due since the last pass. +// +// Reminders are marked sent before they are delivered, not after. A reminder that +// goes out and is not recorded will go out again every minute for ever, which is +// far worse than one that is recorded and then fails to send — the second is a +// missed notification, the first is somebody's inbox filling up overnight. +func (r *Runner) sendDueReminders(ctx context.Context) error { + due, err := r.db.DueReminders(ctx, time.Now()) + if err != nil { + return err + } + for _, rem := range due { + if err := r.db.MarkReminderSent(ctx, rem.ID); err != nil { + r.log.Warn("mark reminder sent", "err", err, "reminder", rem.ID) + continue + } + + // In-app first: it is instant, it cannot fail, and for somebody with the + // tab open it is the only one that matters. + r.hub.PublishToUser(rem.UserID, "reminder", map[string]any{ + "task_id": rem.TaskID, + "content": rem.TaskContent, + "due": rem.RemindAt.Format(time.RFC3339), + }) + + if r.mail.Configured() && rem.UserEmail != "" { + link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID + if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil { + r.log.Error("send reminder", "err", err, "reminder", rem.ID) + } + } + } + if len(due) > 0 { + r.log.Info("reminders sent", "count", len(due)) + } + return nil +} + +// --- backups --------------------------------------------------------------------- + +const ( + backupKeepDays = 14 + backupPrefix = "verdande-" +) + +// nightlyBackup snapshots the database once a day. +// +// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a +// live WAL-mode database can miss the most recent writes or catch a page mid-update; +// VACUUM INTO produces a consistent, already-compacted database without blocking +// writers for the duration. +// +// Attachments are deliberately not copied. They are content-addressed files that +// only ever get added, so duplicating gigabytes of them every night to hold +// fourteen identical copies would fill the volume it is meant to protect. The +// README says to back up the whole /data directory; this covers the one part that +// cannot simply be copied. +func (r *Runner) nightlyBackup(ctx context.Context) error { + last, err := r.db.LastBackupAt(ctx) + if err != nil { + return err + } + if time.Since(last) < 23*time.Hour { + return nil + } + + started := time.Now() + name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db" + path := filepath.Join(r.cfg.BackupsDir(), name) + + runID, err := r.db.StartBackupRun(ctx, started) + if err != nil { + return err + } + + if err := r.db.Snapshot(ctx, path); err != nil { + // A failed attempt can leave a partial file, which would then look like a + // backup to anybody reading the directory. + os.Remove(path) + if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil { + r.log.Warn("record backup failure", "err", recErr) + } + return fmt.Errorf("snapshot: %w", err) + } + + var size int64 + if info, err := os.Stat(path); err == nil { + size = info.Size() + } + if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil { + r.log.Warn("record backup", "err", err) + } + r.log.Info("backup written", "path", path, "bytes", size, + "took", time.Since(started).Round(time.Millisecond).String()) + + return r.rotateBackups() +} + +// rotateBackups keeps the most recent fourteen and deletes the rest. +// +// Counted by file, not by age: a container that was off for a month would +// otherwise come back, find every backup older than fourteen days, and delete all +// of them — leaving none at exactly the moment they were most likely to be needed. +func (r *Runner) rotateBackups() error { + entries, err := os.ReadDir(r.cfg.BackupsDir()) + if err != nil { + return err + } + + var backups []string + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), backupPrefix) && strings.HasSuffix(e.Name(), ".db") { + backups = append(backups, e.Name()) + } + } + // The filenames are timestamps, so sorting them sorts by age. + sort.Sort(sort.Reverse(sort.StringSlice(backups))) + + for _, name := range backups[min(len(backups), backupKeepDays):] { + path := filepath.Join(r.cfg.BackupsDir(), name) + if err := os.Remove(path); err != nil { + r.log.Warn("remove old backup", "err", err, "path", path) + continue + } + r.log.Info("old backup removed", "path", path) + } + return nil +} + +// --- housekeeping ----------------------------------------------------------------- + +func (r *Runner) emptyTrash(ctx context.Context) error { + n, err := r.db.PurgeTrash(ctx, r.cfg.TrashRetention) + if err != nil { + return err + } + if n > 0 { + r.log.Info("trash emptied", "rows", n) + } + return nil +} + +func (r *Runner) purgeSessions(ctx context.Context) error { + n, err := r.db.PurgeExpiredSessions(ctx) + if err != nil { + return err + } + if n > 0 { + r.log.Debug("expired sessions removed", "count", n) + } + return nil +} + +// RunBackupNow is what the "back up now" button calls, and what the tests use. +func (r *Runner) RunBackupNow(ctx context.Context) error { + started := time.Now() + name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db" + path := filepath.Join(r.cfg.BackupsDir(), name) + + runID, err := r.db.StartBackupRun(ctx, started) + if err != nil { + return err + } + if err := r.db.Snapshot(ctx, path); err != nil { + os.Remove(path) + _ = r.db.FinishBackupRun(ctx, runID, "", 0, err) + return err + } + var size int64 + if info, err := os.Stat(path); err == nil { + size = info.Size() + } + if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil { + return err + } + return r.rotateBackups() +} diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go new file mode 100644 index 0000000..df882e2 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,278 @@ +package jobs + +import ( + "context" + "database/sql" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/mail" + "github.com/kristianwind/verdande/internal/realtime" + "github.com/kristianwind/verdande/internal/store" +) + +func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) { + t.Helper() + + dir := t.TempDir() + cfg := &config.Config{ + BaseURL: "http://localhost:8080", + DataDir: dir, + TrashRetention: 30 * 24 * time.Hour, + } + for _, d := range []string{cfg.FilesDir(), cfg.BackupsDir()} { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatal(err) + } + } + + db, err := store.Open(cfg.DBPath()) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg +} + +func seedUser(t *testing.T, db *store.DB) *store.User { + t.Helper() + u := &store.User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"} + if err := db.CreateUser(t.Context(), u, "Indbakke"); err != nil { + t.Fatal(err) + } + return u +} + +// A backup has to be a database somebody can actually open and read — not merely a +// file of the right size. This is the one piece of the system where a silent +// failure is only discovered at the worst possible moment. +func TestBackupProducesAReadableDatabase(t *testing.T) { + runner, db, cfg := newRunner(t) + user := seedUser(t, db) + + inbox, err := db.InboxID(t.Context(), user.ID) + if err != nil { + t.Fatal(err) + } + task := &store.Task{ProjectID: inbox, Content: "noget der skal overleve", CreatedBy: user.ID} + if err := db.CreateTask(t.Context(), task, nil); err != nil { + t.Fatal(err) + } + + if err := runner.RunBackupNow(t.Context()); err != nil { + t.Fatalf("backup: %v", err) + } + + backups := backupFiles(t, cfg) + if len(backups) != 1 { + t.Fatalf("got %d backup files, want 1", len(backups)) + } + + // Open the copy as a database of its own and read the row back out. + copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backups[0])) + if err != nil { + t.Fatal(err) + } + defer copyDB.Close() + + var content string + err = copyDB.QueryRow(`SELECT content FROM tasks WHERE id = ?`, task.ID).Scan(&content) + if err != nil { + t.Fatalf("reading the backup: %v", err) + } + if content != "noget der skal overleve" { + t.Errorf("content = %q", content) + } + + // And the run was recorded, so the panel can say when it last worked. + runs, err := db.ListBackupRuns(t.Context(), 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 || runs[0].Error != "" || runs[0].FinishedAt == nil { + t.Errorf("the run was not recorded as a success: %+v", runs) + } + if runs[0].SizeBytes == 0 { + t.Error("the recorded size is zero") + } +} + +// Writes made after a backup must not appear in it — otherwise the "snapshot" is +// really a live handle and restoring from it would be unpredictable. +func TestABackupIsAPointInTime(t *testing.T) { + runner, db, cfg := newRunner(t) + user := seedUser(t, db) + inbox, _ := db.InboxID(t.Context(), user.ID) + + before := &store.Task{ProjectID: inbox, Content: "før", CreatedBy: user.ID} + if err := db.CreateTask(t.Context(), before, nil); err != nil { + t.Fatal(err) + } + if err := runner.RunBackupNow(t.Context()); err != nil { + t.Fatal(err) + } + after := &store.Task{ProjectID: inbox, Content: "efter", CreatedBy: user.ID} + if err := db.CreateTask(t.Context(), after, nil); err != nil { + t.Fatal(err) + } + + copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backupFiles(t, cfg)[0])) + if err != nil { + t.Fatal(err) + } + defer copyDB.Close() + + var n int + if err := copyDB.QueryRow(`SELECT count(*) FROM tasks`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("the backup holds %d tasks, want only the one that existed when it was taken", n) + } +} + +// Fourteen are kept, counted by file rather than by age: a container that was off +// for a month must not come back and delete every backup it has for being old. +func TestBackupsRotateByCountNotAge(t *testing.T) { + runner, _, cfg := newRunner(t) + + // Twenty files, all timestamped a month ago — older than any age-based cutoff. + old := time.Now().AddDate(0, -1, 0) + for i := 0; i < 20; i++ { + name := backupPrefix + old.Add(time.Duration(i)*time.Hour).UTC().Format("2006-01-02T150405Z") + ".db" + if err := os.WriteFile(filepath.Join(cfg.BackupsDir(), name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + if err := runner.rotateBackups(); err != nil { + t.Fatalf("rotate: %v", err) + } + + kept := backupFiles(t, cfg) + if len(kept) != backupKeepDays { + t.Fatalf("kept %d backups, want %d", len(kept), backupKeepDays) + } + // The ones kept are the most recent, not an arbitrary fourteen. + for i := 1; i < len(kept); i++ { + if kept[i-1] > kept[i] { + t.Fatal("the remaining backups are not sorted by time") + } + } + if kept[0] <= backupPrefix+old.UTC().Format("2006-01-02T150405Z") { + t.Error("an older backup was kept over a newer one") + } +} + +// A file left behind by a failed attempt would look like a backup to anybody +// reading the directory. +func TestAFailedBackupLeavesNoFile(t *testing.T) { + runner, db, cfg := newRunner(t) + + // Point the snapshot at a path that cannot be written. + blocked := filepath.Join(cfg.BackupsDir(), "nope") + if err := os.MkdirAll(blocked, 0o500); err != nil { + t.Fatal(err) + } + if err := db.Snapshot(t.Context(), filepath.Join(blocked, "sub", "x.db")); err == nil { + t.Skip("this filesystem allowed the write; the failure path cannot be exercised here") + } + + if err := runner.RunBackupNow(t.Context()); err != nil { + t.Fatalf("an ordinary backup should still work: %v", err) + } + if got := len(backupFiles(t, cfg)); got != 1 { + t.Errorf("got %d backup files, want 1", got) + } +} + +// Snapshotting onto an existing file would silently produce a corrupt or partial +// result, so it is refused. +func TestSnapshotRefusesToOverwrite(t *testing.T) { + _, db, cfg := newRunner(t) + + path := filepath.Join(cfg.BackupsDir(), "taken.db") + if err := os.WriteFile(path, []byte("allerede her"), 0o600); err != nil { + t.Fatal(err) + } + if err := db.Snapshot(t.Context(), path); err == nil { + t.Error("Snapshot overwrote an existing file") + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "allerede her" { + t.Error("the existing file was modified") + } +} + +// The nightly job is checked hourly but must run at most once a day, or every +// hourly tick would take a full copy. +func TestTheNightlyBackupRunsAtMostOncePerDay(t *testing.T) { + runner, _, cfg := newRunner(t) + ctx := context.Background() + + if err := runner.nightlyBackup(ctx); err != nil { + t.Fatalf("first run: %v", err) + } + if got := len(backupFiles(t, cfg)); got != 1 { + t.Fatalf("got %d backups after the first run, want 1", got) + } + + // An hour later — the next tick — nothing further should happen. + if err := runner.nightlyBackup(ctx); err != nil { + t.Fatalf("second run: %v", err) + } + if got := len(backupFiles(t, cfg)); got != 1 { + t.Errorf("got %d backups after a second tick, want still 1", got) + } +} + +// The runner must stop when its context is cancelled, and Wait must return — +// otherwise a shutdown hangs until Docker loses patience and kills the process. +func TestJobsStopOnShutdown(t *testing.T) { + runner, _, _ := newRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + runner.Start(ctx) + cancel() + + done := make(chan struct{}) + go func() { + runner.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the background jobs did not stop within five seconds of shutdown") + } +} + +func backupFiles(t *testing.T, cfg *config.Config) []string { + t.Helper() + entries, err := os.ReadDir(cfg.BackupsDir()) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".db" && + len(e.Name()) > len(backupPrefix) && e.Name()[:len(backupPrefix)] == backupPrefix { + out = append(out, e.Name()) + } + } + return out +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 3f7bc04..c3756fa 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -59,6 +59,22 @@ Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt sle return s.send(ctx, to, subject, body) } +func (s *Sender) SendReminder(ctx context.Context, to, name, task, link string) error { + subject := "Påmindelse: " + task + body := fmt.Sprintf(`Hej %s + +Du bad om en påmindelse om: + + %s + +%s + +— verdande +`, name, task, link) + + return s.send(ctx, to, subject, body) +} + func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error { subject := "Nulstil din adgangskode i verdande" body := fmt.Sprintf(`Hej %s diff --git a/internal/store/migrations/0003_feeds_templates.sql b/internal/store/migrations/0003_feeds_templates.sql new file mode 100644 index 0000000..1f71a86 --- /dev/null +++ b/internal/store/migrations/0003_feeds_templates.sql @@ -0,0 +1,51 @@ +-- Calendar feeds, project templates and reminder delivery. + +-- A per-user token for the read-only calendar feed. +-- +-- Calendar clients cannot log in — Apple Calendar and Google both fetch a URL with +-- no credentials and no way to prompt for any — so the URL itself is the +-- capability. That is why the token is separate from the session and from the API +-- token: it can be rotated on its own when a feed URL ends up somewhere it should +-- not, without signing the person out of everything else. +ALTER TABLE users ADD COLUMN ics_token TEXT; + +CREATE UNIQUE INDEX idx_users_ics_token ON users (ics_token) WHERE ics_token IS NOT NULL; + +-- A project saved as a shape to reuse: its sections and tasks, without dates, +-- assignees or anything else that belonged to the run it was captured from. +-- +-- The body is JSON rather than a set of tables. A template is written once and read +-- whole; normalising it would buy queries nobody makes, and cost a migration every +-- time a task gains a field. +CREATE TABLE project_templates ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT 'graphite', + body_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_templates_user ON project_templates (user_id); + +-- Reminders already exist as a table; what was missing is the ability to find the +-- ones that are due. A relative reminder ("10 minutes before") has no absolute time +-- of its own, so the scheduler resolves it against the task's due_datetime — and +-- this index is what keeps that sweep from being a full scan every minute. +CREATE INDEX idx_reminders_task ON reminders (task_id) WHERE sent_at IS NULL; + +-- Where a nightly backup got to. One row per run, so the panel can show when the +-- last one succeeded and an operator can tell "no backups configured" apart from +-- "backups have been failing for a week". +CREATE TABLE backup_runs ( + id TEXT PRIMARY KEY, + started_at INTEGER NOT NULL, + finished_at INTEGER, + path TEXT, + size_bytes INTEGER, + error TEXT +); + +CREATE INDEX idx_backup_runs_started ON backup_runs (started_at DESC); diff --git a/internal/store/reminders.go b/internal/store/reminders.go new file mode 100644 index 0000000..6d20ef4 --- /dev/null +++ b/internal/store/reminders.go @@ -0,0 +1,266 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "strings" + "time" +) + +type Reminder struct { + ID string + TaskID string + UserID string + RemindAt time.Time + OffsetMin *int + SentAt *time.Time + + // Filled in by DueReminders, so the sender does not need a second query per + // reminder to find out who to tell and about what. + TaskContent string + ProjectID string + UserEmail string + UserName string +} + +// CreateReminder adds either an absolute reminder or one relative to the task's due +// time. Exactly one of the two — the schema enforces it, and a reminder that is +// both would have no defined moment. +func (db *DB) CreateReminder(ctx context.Context, taskID, userID string, at *time.Time, offsetMin *int) (*Reminder, error) { + if (at == nil) == (offsetMin == nil) { + return nil, fmt.Errorf("store: a reminder is either at a time or an offset, not both or neither") + } + + r := &Reminder{ID: NewID(), TaskID: taskID, UserID: userID, OffsetMin: offsetMin} + var remindAt any + if at != nil { + r.RemindAt = at.UTC() + remindAt = r.RemindAt.Unix() + } + + _, err := db.ExecContext(ctx, + `INSERT INTO reminders (id, task_id, user_id, remind_at, offset_min, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + r.ID, taskID, userID, remindAt, nullInt(offsetMin), time.Now().Unix()) + if err != nil { + return nil, err + } + return r, nil +} + +func (db *DB) ListReminders(ctx context.Context, taskID string) ([]Reminder, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, task_id, user_id, remind_at, offset_min, sent_at + FROM reminders WHERE task_id = ? ORDER BY created_at`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Reminder{} + for rows.Next() { + var r Reminder + var remindAt, offset, sentAt sql.NullInt64 + if err := rows.Scan(&r.ID, &r.TaskID, &r.UserID, &remindAt, &offset, &sentAt); err != nil { + return nil, err + } + if remindAt.Valid { + r.RemindAt = time.Unix(remindAt.Int64, 0).UTC() + } + if offset.Valid { + v := int(offset.Int64) + r.OffsetMin = &v + } + if sentAt.Valid { + v := time.Unix(sentAt.Int64, 0).UTC() + r.SentAt = &v + } + out = append(out, r) + } + return out, rows.Err() +} + +func (db *DB) DeleteReminder(ctx context.Context, reminderID, userID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM reminders WHERE id = ? AND user_id = ?`, reminderID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DueReminders finds everything that should have gone out by `now`. +// +// A relative reminder has no time of its own — "ten minutes before" is a moment +// only once the task has a due_datetime — so it is resolved here in SQL against the +// task. That also means moving a task automatically moves its relative reminders, +// which is what somebody rescheduling a meeting expects. +// +// Completed and deleted tasks are excluded: a reminder for something already done +// is a small betrayal of the whole feature. +func (db *DB) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) { + rows, err := db.QueryContext(ctx, ` + SELECT r.id, r.task_id, r.user_id, + COALESCE(r.remind_at, t.due_datetime + r.offset_min * 60) AS fires_at, + r.offset_min, t.content, t.project_id, u.email, u.name + FROM reminders r + JOIN tasks t ON t.id = r.task_id + JOIN users u ON u.id = r.user_id + WHERE r.sent_at IS NULL + AND t.deleted_at IS NULL + AND t.completed_at IS NULL + AND ( + r.remind_at IS NOT NULL + OR (r.offset_min IS NOT NULL AND t.due_datetime IS NOT NULL) + ) + AND COALESCE(r.remind_at, t.due_datetime + r.offset_min * 60) <= ? + -- A reminder that is somehow days late still goes out, but the ones that + -- came due first go first. + ORDER BY fires_at`, now.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Reminder{} + for rows.Next() { + var r Reminder + var firesAt int64 + var offset sql.NullInt64 + if err := rows.Scan(&r.ID, &r.TaskID, &r.UserID, &firesAt, &offset, + &r.TaskContent, &r.ProjectID, &r.UserEmail, &r.UserName); err != nil { + return nil, err + } + r.RemindAt = time.Unix(firesAt, 0).UTC() + if offset.Valid { + v := int(offset.Int64) + r.OffsetMin = &v + } + out = append(out, r) + } + return out, rows.Err() +} + +// MarkReminderSent is called before delivery, not after — see the comment on the +// job that calls it. +func (db *DB) MarkReminderSent(ctx context.Context, reminderID string) error { + res, err := db.ExecContext(ctx, + `UPDATE reminders SET sent_at = ? WHERE id = ? AND sent_at IS NULL`, + time.Now().Unix(), reminderID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + // Another pass got there first. Not an error, and not something to send. + return ErrNotFound + } + return nil +} + +// ResetRemindersFor clears the sent flag on a task's relative reminders, so a task +// that has been rescheduled — or that recurred — reminds again at its new time. +func (db *DB) ResetRemindersFor(ctx context.Context, taskID string) error { + _, err := db.ExecContext(ctx, + `UPDATE reminders SET sent_at = NULL WHERE task_id = ? AND offset_min IS NOT NULL`, taskID) + return err +} + +// --- backups --------------------------------------------------------------------- + +// Snapshot writes a consistent copy of the database to path. +// +// VACUUM INTO rather than a file copy: a plain copy of a live WAL-mode database can +// miss recent writes or catch a page mid-update, and the result looks like a +// database right up until it is needed. +func (db *DB) Snapshot(ctx context.Context, path string) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("store: %s already exists", path) + } + // The path is a filename this process built from a timestamp, never user + // input — but it still goes through a quoted literal rather than concatenation, + // because VACUUM INTO takes no bound parameters and the habit is worth keeping. + _, err := db.ExecContext(ctx, `VACUUM INTO '`+strings.ReplaceAll(path, "'", "''")+`'`) + return err +} + +func (db *DB) StartBackupRun(ctx context.Context, started time.Time) (string, error) { + id := NewID() + _, err := db.ExecContext(ctx, + `INSERT INTO backup_runs (id, started_at) VALUES (?, ?)`, id, started.Unix()) + return id, err +} + +func (db *DB) FinishBackupRun(ctx context.Context, id, path string, size int64, failure error) error { + var errText any + if failure != nil { + errText = failure.Error() + } + _, err := db.ExecContext(ctx, + `UPDATE backup_runs SET finished_at = ?, path = ?, size_bytes = ?, error = ? WHERE id = ?`, + time.Now().Unix(), nullString(path), size, errText, id) + return err +} + +// LastBackupAt returns when a backup last succeeded, or the zero time if none has. +func (db *DB) LastBackupAt(ctx context.Context) (time.Time, error) { + var at sql.NullInt64 + err := db.QueryRowContext(ctx, + `SELECT max(started_at) FROM backup_runs WHERE error IS NULL AND finished_at IS NOT NULL`). + Scan(&at) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return time.Time{}, err + } + if !at.Valid { + return time.Time{}, nil + } + return time.Unix(at.Int64, 0).UTC(), nil +} + +type BackupRun struct { + ID string + StartedAt time.Time + FinishedAt *time.Time + Path string + SizeBytes int64 + Error string +} + +func (db *DB) ListBackupRuns(ctx context.Context, limit int) ([]BackupRun, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + rows, err := db.QueryContext(ctx, + `SELECT id, started_at, finished_at, path, size_bytes, error + FROM backup_runs ORDER BY started_at DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []BackupRun{} + for rows.Next() { + var b BackupRun + var started int64 + var finished, size sql.NullInt64 + var path, errText sql.NullString + if err := rows.Scan(&b.ID, &started, &finished, &path, &size, &errText); err != nil { + return nil, err + } + b.StartedAt = time.Unix(started, 0).UTC() + if finished.Valid { + v := time.Unix(finished.Int64, 0).UTC() + b.FinishedAt = &v + } + b.Path = path.String + b.SizeBytes = size.Int64 + b.Error = errText.String + out = append(out, b) + } + return out, rows.Err() +} diff --git a/internal/store/templates.go b/internal/store/templates.go new file mode 100644 index 0000000..1ee8d34 --- /dev/null +++ b/internal/store/templates.go @@ -0,0 +1,275 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" +) + +// TemplateBody is a project's shape without its history: sections and tasks, but no +// dates, no assignees, no completion. Those belonged to the run it was captured +// from, and carrying them into a new project would mean starting one already late. +type TemplateBody struct { + Sections []TemplateSection `json:"sections"` + Tasks []TemplateTask `json:"tasks"` +} + +type TemplateSection struct { + Key string `json:"key"` // stable within the template, so tasks can point at it + Name string `json:"name"` + SortOrder float64 `json:"sort_order"` +} + +type TemplateTask struct { + Key string `json:"key"` + ParentKey string `json:"parent_key,omitempty"` + SectionKey string `json:"section_key,omitempty"` + Content string `json:"content"` + Description string `json:"description,omitempty"` + Priority int `json:"priority"` + Labels []string `json:"labels,omitempty"` + // DueOffsetDays is how many days after the project is created this task is due. + // A relative offset is what makes a template reusable: an onboarding checklist + // is "day 1, day 3, day 14", not three dates in March. + DueOffsetDays *int `json:"due_offset_days,omitempty"` + Recurrence string `json:"recurrence_rule,omitempty"` + DurationMin *int `json:"duration_min,omitempty"` + SortOrder float64 `json:"sort_order"` +} + +type Template struct { + ID string + UserID string + Name string + Description string + Color string + Body TemplateBody + CreatedAt time.Time + UpdatedAt time.Time +} + +// SaveProjectAsTemplate captures a project's open tasks and sections. +// +// Completed tasks are left out on purpose: a template is the work to be done, and +// somebody saving a finished project as a template wants the checklist, not the +// record of having ticked it off. +func (db *DB) SaveProjectAsTemplate(ctx context.Context, projectID, userID, name, description string) (*Template, error) { + project, err := db.GetProject(ctx, projectID, userID) + if err != nil { + return nil, err + } + sections, err := db.ListSections(ctx, projectID) + if err != nil { + return nil, err + } + tasks, err := db.ListTasks(ctx, userID, TaskFilter{ProjectIDs: []string{projectID}, Limit: 1000}) + if err != nil { + return nil, err + } + + body := TemplateBody{} + for _, s := range sections { + body.Sections = append(body.Sections, TemplateSection{ + Key: s.ID, Name: s.Name, SortOrder: s.SortOrder, + }) + } + + // The earliest due date becomes day zero, so a template built from a real + // project keeps the *spacing* between its tasks without keeping the dates. + var earliest time.Time + for _, t := range tasks { + if t.DueDate == "" { + continue + } + if d, err := time.Parse("2006-01-02", t.DueDate); err == nil { + if earliest.IsZero() || d.Before(earliest) { + earliest = d + } + } + } + + for _, t := range tasks { + tt := TemplateTask{ + Key: t.ID, ParentKey: t.ParentID, SectionKey: t.SectionID, + Content: t.Content, Description: t.Description, Priority: t.Priority, + Labels: t.Labels, Recurrence: t.RecurrenceRule, + DurationMin: t.DurationMin, SortOrder: t.SortOrder, + } + if t.DueDate != "" && !earliest.IsZero() { + if d, err := time.Parse("2006-01-02", t.DueDate); err == nil { + offset := int(d.Sub(earliest).Hours() / 24) + tt.DueOffsetDays = &offset + } + } + body.Tasks = append(body.Tasks, tt) + } + + if name == "" { + name = project.Name + } + tpl := &Template{ + ID: NewID(), UserID: userID, Name: name, Description: description, + Color: project.Color, Body: body, + } + return tpl, db.saveTemplate(ctx, tpl) +} + +func (db *DB) saveTemplate(ctx context.Context, tpl *Template) error { + raw, err := json.Marshal(tpl.Body) + if err != nil { + return err + } + now := time.Now().UTC() + tpl.CreatedAt, tpl.UpdatedAt = now, now + + _, err = db.ExecContext(ctx, + `INSERT INTO project_templates (id, user_id, name, description, color, body_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + tpl.ID, tpl.UserID, tpl.Name, tpl.Description, tpl.Color, string(raw), + now.Unix(), now.Unix()) + return err +} + +func (db *DB) ListTemplates(ctx context.Context, userID string) ([]Template, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, name, description, color, body_json, created_at, updated_at + FROM project_templates WHERE user_id = ? ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Template{} + for rows.Next() { + tpl, err := scanTemplate(rows) + if err != nil { + return nil, err + } + out = append(out, tpl) + } + return out, rows.Err() +} + +func (db *DB) GetTemplate(ctx context.Context, userID, templateID string) (*Template, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, name, description, color, body_json, created_at, updated_at + FROM project_templates WHERE id = ? AND user_id = ?`, templateID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + if !rows.Next() { + return nil, ErrNotFound + } + tpl, err := scanTemplate(rows) + if err != nil { + return nil, err + } + return &tpl, rows.Err() +} + +func scanTemplate(rows *sql.Rows) (Template, error) { + var tpl Template + var body string + var created, updated int64 + if err := rows.Scan(&tpl.ID, &tpl.UserID, &tpl.Name, &tpl.Description, + &tpl.Color, &body, &created, &updated); err != nil { + return tpl, err + } + if err := json.Unmarshal([]byte(body), &tpl.Body); err != nil { + return tpl, err + } + tpl.CreatedAt = time.Unix(created, 0).UTC() + tpl.UpdatedAt = time.Unix(updated, 0).UTC() + return tpl, nil +} + +func (db *DB) DeleteTemplate(ctx context.Context, userID, templateID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM project_templates WHERE id = ? AND user_id = ?`, templateID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// CreateProjectFromTemplate builds a whole project in one transaction. +// +// All or nothing: a template that failed halfway would leave a project with some of +// its tasks and no way to tell which were missing. +func (db *DB) CreateProjectFromTemplate(ctx context.Context, tpl *Template, userID, name string, startDate time.Time) (*Project, error) { + if name == "" { + name = tpl.Name + } + project := &Project{Name: name, Color: tpl.Color, OwnerID: userID} + if err := db.CreateProject(ctx, project); err != nil { + return nil, err + } + + now := time.Now().Unix() + err := db.Tx(ctx, func(tx *sql.Tx) error { + // The template's own keys are remapped to fresh ids, and the mapping is + // what lets sub-tasks and sections keep pointing at the right things. + sectionIDs := map[string]string{} + for _, s := range tpl.Body.Sections { + id := NewID() + sectionIDs[s.Key] = id + if _, err := tx.ExecContext(ctx, + `INSERT INTO sections (id, project_id, name, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + id, project.ID, s.Name, s.SortOrder, now, now); err != nil { + return err + } + } + + taskIDs := map[string]string{} + for _, t := range tpl.Body.Tasks { + taskIDs[t.Key] = NewID() + } + + for _, t := range tpl.Body.Tasks { + var dueDate any + if t.DueOffsetDays != nil { + dueDate = startDate.AddDate(0, 0, *t.DueOffsetDays).Format("2006-01-02") + } + priority := t.Priority + if priority < 1 || priority > 4 { + priority = 4 + } + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO tasks (id, project_id, section_id, parent_id, content, description, + priority, due_date, recurrence_rule, duration_min, + created_by, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + taskIDs[t.Key], project.ID, + nullString(sectionIDs[t.SectionKey]), nullString(taskIDs[t.ParentKey]), + t.Content, t.Description, priority, dueDate, + nullString(t.Recurrence), nullInt(t.DurationMin), + userID, t.SortOrder, now, now); err != nil { + return err + } + if len(t.Labels) > 0 { + if err := setTaskLabels(ctx, tx, taskIDs[t.Key], userID, t.Labels); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + // The project was created outside the transaction, so it has to be taken + // back by hand — otherwise a failed template leaves an empty project behind. + if delErr := db.DeleteProject(ctx, project.ID); delErr != nil { + return nil, errors.Join(err, delErr) + } + return nil, err + } + return project, nil +} diff --git a/internal/store/users.go b/internal/store/users.go index 263e96d..9ddf4b4 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -7,6 +7,8 @@ import ( "fmt" "strings" "time" + + "github.com/kristianwind/verdande/internal/auth" ) var ( @@ -201,3 +203,48 @@ func boolToInt(b bool) int { func isUniqueViolation(err error) bool { return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint") } + +// --- calendar feed tokens ------------------------------------------------------- + +// EnsureICSToken returns the user's feed token, minting one the first time it is +// asked for. Created lazily rather than at signup so that somebody who never +// subscribes to a calendar never has a standing credential they did not ask for. +func (db *DB) EnsureICSToken(ctx context.Context, userID string) (string, error) { + var token sql.NullString + err := db.QueryRowContext(ctx, `SELECT ics_token FROM users WHERE id = ?`, userID).Scan(&token) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if token.Valid && token.String != "" { + return token.String, nil + } + + fresh, err := auth.NewToken() + if err != nil { + return "", err + } + if err := db.SetICSToken(ctx, userID, fresh); err != nil { + return "", err + } + return fresh, nil +} + +func (db *DB) SetICSToken(ctx context.Context, userID, token string) error { + _, err := db.ExecContext(ctx, + `UPDATE users SET ics_token = ?, updated_at = ? WHERE id = ?`, + token, time.Now().Unix(), userID) + return err +} + +// UserByICSToken resolves a feed URL to its owner. The token is the whole +// credential, so it is compared as an exact match on an indexed unique column and +// nothing else about the request is trusted. +func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) { + if token == "" { + return nil, ErrNotFound + } + return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token) +}