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:
co-authored by
Claude Opus 5
parent
4d142b2fa7
commit
7e4c730049
+17
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user