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 <[email protected]>
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
+17 -1
View File
@@ -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 {
+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, "[email protected]", "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))
}
}
+223
View File
@@ -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)
}
+222
View File
@@ -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:[email protected]") {
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:[email protected]") {
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)
}
}
}
+264
View File
@@ -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()
}
+278
View File
@@ -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: "[email protected]", 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
}
+16
View File
@@ -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
@@ -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);
+266
View File
@@ -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()
}
+275
View File
@@ -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
}
+47
View File
@@ -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)
}