feat(phase3): reminders, ICS feeds, templates and nightly backups
Reminders are absolute or relative ("10 minutes before"). A relative one
is resolved against the task in SQL, so moving a task moves its reminders
too. They are marked sent before delivery, not after: a reminder that
goes out unrecorded repeats every minute forever, which is worse than
one recorded that fails to send.
The calendar feed is a per-user token in the path, because Apple Calendar
and Google fetch with no credentials and no way to prompt for any. Tasks
go out as VTODO — the same component CalDAV will serve — and timed ones
also as VEVENT, since Google ignores VTODO entirely.
Templates store relative day offsets rather than dates, which is what
makes them reusable: an onboarding checklist is "day 1, day 3, day 14",
not three dates in March. Building a project from one is a single
transaction, so a failure cannot leave half a checklist.
Backups use SQLite's VACUUM INTO, not a file copy — a copy of a live
WAL database can miss recent writes or catch a page mid-update, and the
result looks like a database right up until it is needed. Rotation keeps
fourteen by count, not by age: a container that was off for a month must
not come back and delete every backup it has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
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/config"
|
||||||
"github.com/kristianwind/verdande/internal/httpapi"
|
"github.com/kristianwind/verdande/internal/httpapi"
|
||||||
|
"github.com/kristianwind/verdande/internal/jobs"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,9 +54,19 @@ func run() error {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
log.Info("database ready", "path", db.Path())
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: httpapi.New(cfg, db, log, webAssets(log)),
|
Handler: api,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
// No WriteTimeout: it would also cap the WebSocket and any long file
|
// No WriteTimeout: it would also cap the WebSocket and any long file
|
||||||
// download. Per-handler timeouts do that job with the right granularity.
|
// download. Per-handler timeouts do that job with the right granularity.
|
||||||
@@ -89,6 +100,11 @@ func run() error {
|
|||||||
return fmt.Errorf("shutdown: %w", err)
|
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
|
// WAL mode leaves recent writes in the -wal file. Checkpointing on the way out
|
||||||
// means a backup taken of a stopped container is complete.
|
// means a backup taken of a stopped container is complete.
|
||||||
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
|
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.
|
// Docker both need this to answer before anyone has logged in.
|
||||||
r.Get("/healthz", s.handleHealth)
|
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.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.requireCSRF)
|
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.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.Route("/labels", func(r chi.Router) {
|
||||||
r.Get("/", s.handleListLabels)
|
r.Get("/", s.handleListLabels)
|
||||||
r.Post("/", s.handleCreateLabel)
|
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.Get("/", s.handleGetTask)
|
||||||
r.Patch("/", s.handleUpdateTask)
|
r.Patch("/", s.handleUpdateTask)
|
||||||
r.Delete("/", s.handleDeleteTask)
|
r.Delete("/", s.handleDeleteTask)
|
||||||
|
r.Get("/reminders", s.handleListReminders)
|
||||||
|
r.Post("/reminders", s.handleCreateReminder)
|
||||||
r.Post("/complete", s.handleCompleteTask)
|
r.Post("/complete", s.handleCompleteTask)
|
||||||
r.Post("/reopen", s.handleReopenTask)
|
r.Post("/reopen", s.handleReopenTask)
|
||||||
r.Post("/move", s.handleMoveTask)
|
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) }
|
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
|
// 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
|
// 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.
|
// healthy, and answering 200 for it would keep a broken container in rotation.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1006,3 +1008,303 @@ func TestDeletingALabelKeepsItsTasks(t *testing.T) {
|
|||||||
t.Errorf("labels = %v, want empty", got)
|
t.Errorf("labels = %v, want empty", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- reminders, feeds and templates -----------------------------------------------
|
||||||
|
|
||||||
|
func TestReminders(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "møde", "due_date": userDate(t, 0), "due_time": "14:00",
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
// Relative: ten minutes before whenever the task is due.
|
||||||
|
offset := -10
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||||
|
map[string]any{"offset_min": offset})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create relative reminder: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute.
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||||
|
map[string]any{"remind_at": time.Now().Add(time.Hour).Format(time.RFC3339)})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create absolute reminder: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both at once is not a reminder with two times; it is a mistake.
|
||||||
|
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders", map[string]any{
|
||||||
|
"offset_min": offset, "remind_at": time.Now().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("a reminder with both a time and an offset: status %d, want 422", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/reminders", nil)
|
||||||
|
reminders, _ := list["reminders"].([]any)
|
||||||
|
if len(reminders) != 2 {
|
||||||
|
t.Fatalf("got %d reminders, want 2", len(reminders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reminder that has come due is found by the sweep; one in the future is not,
|
||||||
|
// and neither is one on a task that has already been done.
|
||||||
|
func TestDueRemindersAreFoundOnce(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "husk"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
past := time.Now().Add(-time.Minute)
|
||||||
|
future := time.Now().Add(time.Hour)
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||||
|
map[string]any{"remind_at": past.Format(time.RFC3339)})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||||
|
map[string]any{"remind_at": future.Format(time.RFC3339)})
|
||||||
|
|
||||||
|
due, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("got %d due reminders, want only the one in the past", len(due))
|
||||||
|
}
|
||||||
|
if due[0].TaskContent != "husk" {
|
||||||
|
t.Errorf("the reminder does not carry the task's text: %+v", due[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marking it sent takes it out of the sweep, so it cannot go out twice.
|
||||||
|
if err := ts.db.MarkReminderSent(t.Context(), due[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Errorf("a sent reminder came round again: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reminder for something already done is a small betrayal of the whole feature.
|
||||||
|
func TestCompletedTasksDoNotRemind(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "gjort"})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
|
||||||
|
map[string]any{"remind_at": time.Now().Add(-time.Minute).Format(time.RFC3339)})
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/complete", nil)
|
||||||
|
|
||||||
|
due, err := ts.db.DueReminders(t.Context(), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Errorf("a completed task still reminded: %+v", due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestICSFeed(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "møde med revisor", "due_date": userDate(t, 1), "due_time": "10:00",
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "uden dato"})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/feed", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("get feed: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
feedURL, _ := body["url"].(string)
|
||||||
|
if feedURL == "" {
|
||||||
|
t.Fatal("no feed URL was returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The feed itself is fetched without a session, as a calendar client would.
|
||||||
|
path := feedURL[strings.Index(feedURL, "/ics/"):]
|
||||||
|
req, err := http.NewRequest("GET", ts.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer raw.Body.Close()
|
||||||
|
|
||||||
|
if raw.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("fetching the feed without a session: %d", raw.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := raw.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/calendar", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := io.ReadAll(raw.Body)
|
||||||
|
feed := string(content)
|
||||||
|
if !strings.Contains(feed, "BEGIN:VCALENDAR") {
|
||||||
|
t.Error("the feed is not an iCalendar document")
|
||||||
|
}
|
||||||
|
if !strings.Contains(feed, "SUMMARY:møde med revisor") {
|
||||||
|
t.Error("the dated task is missing from the feed")
|
||||||
|
}
|
||||||
|
if strings.Contains(feed, "uden dato") {
|
||||||
|
t.Error("a task with no date appeared in a calendar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invented token is a missing feed, not a prompt for credentials nobody
|
||||||
|
// can answer.
|
||||||
|
bad, err := (&http.Client{}).Get(ts.URL + "/ics/opfundet.ics")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bad.Body.Close()
|
||||||
|
if bad.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("an unknown feed token: status %d, want 404", bad.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotating the token is what somebody does when a feed URL has leaked, so the old
|
||||||
|
// one has to stop working immediately.
|
||||||
|
func TestRotatingTheFeedTokenBreaksTheOldURL(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, first := ts.do(t, "GET", "/api/v1/feed", nil)
|
||||||
|
oldURL := first["url"].(string)
|
||||||
|
|
||||||
|
_, second := ts.do(t, "POST", "/api/v1/feed/rotate", nil)
|
||||||
|
newURL := second["url"].(string)
|
||||||
|
|
||||||
|
if oldURL == newURL {
|
||||||
|
t.Fatal("rotating produced the same URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldPath := oldURL[strings.Index(oldURL, "/ics/"):]
|
||||||
|
resp, err := (&http.Client{}).Get(ts.URL + oldPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("the old feed URL still works: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectTemplates(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Onboarding"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||||
|
map[string]any{"name": "Første uge"})
|
||||||
|
sectionID := section["id"].(string)
|
||||||
|
|
||||||
|
_, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "opret konti", "project_id": projectID, "section_id": sectionID,
|
||||||
|
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"it"},
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "udlever laptop", "project_id": projectID, "parent_id": parent["id"].(string),
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "opfølgning", "project_id": projectID, "due_date": userDate(t, 14),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, tpl := ts.do(t, "POST", "/api/v1/templates", map[string]any{
|
||||||
|
"project_id": projectID, "name": "Onboarding-skabelon",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("save template: %d %v", resp.StatusCode, tpl)
|
||||||
|
}
|
||||||
|
if tpl["task_count"] != float64(3) {
|
||||||
|
t.Errorf("task_count = %v, want 3", tpl["task_count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used a month later, the dates land relative to the new start.
|
||||||
|
start := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
|
||||||
|
resp, created := ts.do(t, "POST", "/api/v1/templates/"+tpl["id"].(string)+"/use",
|
||||||
|
map[string]any{"name": "Onboarding: Mette", "start_date": start})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("use template: %d %v", resp.StatusCode, created)
|
||||||
|
}
|
||||||
|
if created["name"] != "Onboarding: Mette" {
|
||||||
|
t.Errorf("name = %v", created["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
newID := created["id"].(string)
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+newID, nil)
|
||||||
|
tasks, _ := list["tasks"].([]any)
|
||||||
|
if len(tasks) != 3 {
|
||||||
|
t.Fatalf("the new project has %d tasks, want 3", len(tasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections came across, sub-tasks kept their parent, and the spacing between
|
||||||
|
// the dates survived even though the dates themselves did not.
|
||||||
|
_, sections := ts.do(t, "GET", "/api/v1/projects/"+newID+"/sections", nil)
|
||||||
|
if got, _ := sections["sections"].([]any); len(got) != 1 {
|
||||||
|
t.Errorf("got %d sections, want 1", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
var withParent, dated int
|
||||||
|
var earliest, latest string
|
||||||
|
for _, raw := range tasks {
|
||||||
|
task := raw.(map[string]any)
|
||||||
|
if task["parent_id"] != nil && task["parent_id"] != "" {
|
||||||
|
withParent++
|
||||||
|
}
|
||||||
|
if due, _ := task["due_date"].(string); due != "" {
|
||||||
|
dated++
|
||||||
|
if earliest == "" || due < earliest {
|
||||||
|
earliest = due
|
||||||
|
}
|
||||||
|
if due > latest {
|
||||||
|
latest = due
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withParent != 1 {
|
||||||
|
t.Errorf("%d tasks have a parent, want 1", withParent)
|
||||||
|
}
|
||||||
|
if dated != 2 {
|
||||||
|
t.Errorf("%d tasks have a date, want 2", dated)
|
||||||
|
}
|
||||||
|
if earliest != start {
|
||||||
|
t.Errorf("the first task is due %q, want the stated start %q", earliest, start)
|
||||||
|
}
|
||||||
|
first, _ := time.Parse("2006-01-02", earliest)
|
||||||
|
last, _ := time.Parse("2006-01-02", latest)
|
||||||
|
if gap := int(last.Sub(first).Hours() / 24); gap != 14 {
|
||||||
|
t.Errorf("the tasks are %d days apart, want the original 14", gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
_, tpl := ts.do(t, "POST", "/api/v1/templates",
|
||||||
|
map[string]any{"project_id": project["id"].(string)})
|
||||||
|
tplID := tpl["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := other.do(t, "POST", "/api/v1/templates/"+tplID+"/use", map[string]any{})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("another user used the template: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, list := other.do(t, "GET", "/api/v1/templates", nil)
|
||||||
|
if got, _ := list["templates"].([]any); len(got) != 0 {
|
||||||
|
t.Errorf("another user's template list shows %d entries", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:t1@todo.example.dk") {
|
||||||
|
t.Error("the UID is missing or not domain-qualified")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "STATUS:NEEDS-ACTION") {
|
||||||
|
t.Error("an open task is not marked NEEDS-ACTION")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "STATUS:COMPLETED") {
|
||||||
|
t.Error("a completed task is not marked COMPLETED")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
|
||||||
|
t.Error("the recurrence rule did not survive into the feed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "CATEGORIES:Firma") {
|
||||||
|
t.Error("the project is not carried as a category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A whole-day due date must be a DATE, not a DATE-TIME. Emitting midnight in some
|
||||||
|
// timezone would move the task by a day for anybody east or west of the server.
|
||||||
|
func TestWholeDayDueDatesAreDates(t *testing.T) {
|
||||||
|
out := Render(sample())
|
||||||
|
|
||||||
|
if !strings.Contains(out, "DUE;VALUE=DATE:20260312") {
|
||||||
|
t.Error("a dateless task's due date is not a DATE value")
|
||||||
|
}
|
||||||
|
// The one with a clock time keeps it.
|
||||||
|
if !strings.Contains(out, "DUE:20260310T090000Z") {
|
||||||
|
t.Error("a timed task lost its clock time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Calendar ignores VTODO. Timed tasks are therefore also emitted as events,
|
||||||
|
// or a feed is useless in the calendar most people actually keep.
|
||||||
|
func TestTimedTasksAlsoBecomeEvents(t *testing.T) {
|
||||||
|
out := Render(sample())
|
||||||
|
|
||||||
|
if strings.Count(out, "BEGIN:VEVENT") != 1 {
|
||||||
|
t.Fatalf("got %d VEVENTs, want one for the single timed task",
|
||||||
|
strings.Count(out, "BEGIN:VEVENT"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "DTSTART:20260310T090000Z") {
|
||||||
|
t.Error("the event has no start")
|
||||||
|
}
|
||||||
|
// 45 minutes of stated duration.
|
||||||
|
if !strings.Contains(out, "DTEND:20260310T094500Z") {
|
||||||
|
t.Error("the event's end does not reflect the task's duration")
|
||||||
|
}
|
||||||
|
// The event must not share the to-do's UID, or clients treat them as one item.
|
||||||
|
if !strings.Contains(out, "UID:t1-event@todo.example.dk") {
|
||||||
|
t.Error("the event does not have a UID of its own")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comma or semicolon is a value separator in iCalendar. Unescaped, it silently
|
||||||
|
// truncates the summary at that character in every client.
|
||||||
|
func TestSpecialCharactersAreEscaped(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
|
||||||
|
out := Render(Calendar{
|
||||||
|
Name: "verdande", Domain: "example.dk",
|
||||||
|
Tasks: []Task{{
|
||||||
|
ID: "t1", Content: `Køb: mælk, brød; smør \ og mere`,
|
||||||
|
Description: "linje 1\nlinje 2",
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(out, `SUMMARY:Køb: mælk\, brød\; smør \\ og mere`) {
|
||||||
|
t.Errorf("the summary is not escaped correctly:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `DESCRIPTION:linje 1\nlinje 2`) {
|
||||||
|
t.Error("a newline in the description was not escaped")
|
||||||
|
}
|
||||||
|
// And the raw separators must not survive unescaped anywhere in the summary.
|
||||||
|
for _, line := range strings.Split(out, "\r\n") {
|
||||||
|
if !strings.HasPrefix(line, "SUMMARY:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := strings.TrimPrefix(line, "SUMMARY:")
|
||||||
|
for i := 0; i < len(body); i++ {
|
||||||
|
if (body[i] == ',' || body[i] == ';') && (i == 0 || body[i-1] != '\\') {
|
||||||
|
t.Errorf("unescaped %q in %q", body[i], line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines fold at 75 octets, and a fold in the middle of a multi-byte character
|
||||||
|
// produces a line no parser can read.
|
||||||
|
func TestLongLinesFoldWithoutBreakingCharacters(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
|
||||||
|
long := strings.Repeat("ø", 200) // two bytes each, so folds land mid-character
|
||||||
|
out := Render(Calendar{
|
||||||
|
Name: "verdande", Domain: "example.dk",
|
||||||
|
Tasks: []Task{{ID: "t1", Content: long, CreatedAt: now, UpdatedAt: now}},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, line := range strings.Split(out, "\r\n") {
|
||||||
|
if len(line) > 75 {
|
||||||
|
t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfolding must reproduce the original text exactly.
|
||||||
|
unfolded := strings.ReplaceAll(out, "\r\n ", "")
|
||||||
|
if !strings.Contains(unfolded, "SUMMARY:"+long) {
|
||||||
|
t.Error("the summary did not survive folding and unfolding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P4 means "no priority stated", not "lowest". Mapping it to 9 would arrive in a
|
||||||
|
// client as an explicit low priority on every ordinary task.
|
||||||
|
func TestPriorityMapping(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 10, 9, 0, 0, 0, time.UTC)
|
||||||
|
want := map[int]string{1: "PRIORITY:1", 2: "PRIORITY:5", 3: "PRIORITY:7", 4: "PRIORITY:0"}
|
||||||
|
|
||||||
|
for priority, expected := range want {
|
||||||
|
out := Render(Calendar{
|
||||||
|
Name: "v", Domain: "example.dk",
|
||||||
|
Tasks: []Task{{ID: "t", Content: "x", Priority: priority, CreatedAt: now, UpdatedAt: now}},
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, expected) {
|
||||||
|
t.Errorf("priority %d did not produce %q", priority, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyCalendarIsStillValid(t *testing.T) {
|
||||||
|
out := Render(Calendar{Name: "verdande", Domain: "example.dk"})
|
||||||
|
|
||||||
|
if !strings.Contains(out, "BEGIN:VCALENDAR") || !strings.Contains(out, "END:VCALENDAR") {
|
||||||
|
t.Error("an empty calendar is not a well-formed document")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "BEGIN:VTODO") {
|
||||||
|
t.Error("an empty calendar contains a task")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilename(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"verdande": "verdande.ics",
|
||||||
|
"Mine opgaver": "mine-opgaver.ics",
|
||||||
|
"Q3/2026 møder": "q32026-mder.ics",
|
||||||
|
"": "verdande.ics",
|
||||||
|
"???": "verdande.ics",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := Filename(in); got != want {
|
||||||
|
t.Errorf("Filename(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
||||||
|
if err := db.CreateUser(t.Context(), u, "Indbakke"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backup has to be a database somebody can actually open and read — not merely a
|
||||||
|
// file of the right size. This is the one piece of the system where a silent
|
||||||
|
// failure is only discovered at the worst possible moment.
|
||||||
|
func TestBackupProducesAReadableDatabase(t *testing.T) {
|
||||||
|
runner, db, cfg := newRunner(t)
|
||||||
|
user := seedUser(t, db)
|
||||||
|
|
||||||
|
inbox, err := db.InboxID(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := &store.Task{ProjectID: inbox, Content: "noget der skal overleve", CreatedBy: user.ID}
|
||||||
|
if err := db.CreateTask(t.Context(), task, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
||||||
|
t.Fatalf("backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backups := backupFiles(t, cfg)
|
||||||
|
if len(backups) != 1 {
|
||||||
|
t.Fatalf("got %d backup files, want 1", len(backups))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the copy as a database of its own and read the row back out.
|
||||||
|
copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backups[0]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer copyDB.Close()
|
||||||
|
|
||||||
|
var content string
|
||||||
|
err = copyDB.QueryRow(`SELECT content FROM tasks WHERE id = ?`, task.ID).Scan(&content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the backup: %v", err)
|
||||||
|
}
|
||||||
|
if content != "noget der skal overleve" {
|
||||||
|
t.Errorf("content = %q", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the run was recorded, so the panel can say when it last worked.
|
||||||
|
runs, err := db.ListBackupRuns(t.Context(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(runs) != 1 || runs[0].Error != "" || runs[0].FinishedAt == nil {
|
||||||
|
t.Errorf("the run was not recorded as a success: %+v", runs)
|
||||||
|
}
|
||||||
|
if runs[0].SizeBytes == 0 {
|
||||||
|
t.Error("the recorded size is zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes made after a backup must not appear in it — otherwise the "snapshot" is
|
||||||
|
// really a live handle and restoring from it would be unpredictable.
|
||||||
|
func TestABackupIsAPointInTime(t *testing.T) {
|
||||||
|
runner, db, cfg := newRunner(t)
|
||||||
|
user := seedUser(t, db)
|
||||||
|
inbox, _ := db.InboxID(t.Context(), user.ID)
|
||||||
|
|
||||||
|
before := &store.Task{ProjectID: inbox, Content: "før", CreatedBy: user.ID}
|
||||||
|
if err := db.CreateTask(t.Context(), before, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after := &store.Task{ProjectID: inbox, Content: "efter", CreatedBy: user.ID}
|
||||||
|
if err := db.CreateTask(t.Context(), after, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backupFiles(t, cfg)[0]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer copyDB.Close()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := copyDB.QueryRow(`SELECT count(*) FROM tasks`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("the backup holds %d tasks, want only the one that existed when it was taken", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourteen are kept, counted by file rather than by age: a container that was off
|
||||||
|
// for a month must not come back and delete every backup it has for being old.
|
||||||
|
func TestBackupsRotateByCountNotAge(t *testing.T) {
|
||||||
|
runner, _, cfg := newRunner(t)
|
||||||
|
|
||||||
|
// Twenty files, all timestamped a month ago — older than any age-based cutoff.
|
||||||
|
old := time.Now().AddDate(0, -1, 0)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
name := backupPrefix + old.Add(time.Duration(i)*time.Hour).UTC().Format("2006-01-02T150405Z") + ".db"
|
||||||
|
if err := os.WriteFile(filepath.Join(cfg.BackupsDir(), name), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.rotateBackups(); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := backupFiles(t, cfg)
|
||||||
|
if len(kept) != backupKeepDays {
|
||||||
|
t.Fatalf("kept %d backups, want %d", len(kept), backupKeepDays)
|
||||||
|
}
|
||||||
|
// The ones kept are the most recent, not an arbitrary fourteen.
|
||||||
|
for i := 1; i < len(kept); i++ {
|
||||||
|
if kept[i-1] > kept[i] {
|
||||||
|
t.Fatal("the remaining backups are not sorted by time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kept[0] <= backupPrefix+old.UTC().Format("2006-01-02T150405Z") {
|
||||||
|
t.Error("an older backup was kept over a newer one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file left behind by a failed attempt would look like a backup to anybody
|
||||||
|
// reading the directory.
|
||||||
|
func TestAFailedBackupLeavesNoFile(t *testing.T) {
|
||||||
|
runner, db, cfg := newRunner(t)
|
||||||
|
|
||||||
|
// Point the snapshot at a path that cannot be written.
|
||||||
|
blocked := filepath.Join(cfg.BackupsDir(), "nope")
|
||||||
|
if err := os.MkdirAll(blocked, 0o500); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Snapshot(t.Context(), filepath.Join(blocked, "sub", "x.db")); err == nil {
|
||||||
|
t.Skip("this filesystem allowed the write; the failure path cannot be exercised here")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
||||||
|
t.Fatalf("an ordinary backup should still work: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
||||||
|
t.Errorf("got %d backup files, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshotting onto an existing file would silently produce a corrupt or partial
|
||||||
|
// result, so it is refused.
|
||||||
|
func TestSnapshotRefusesToOverwrite(t *testing.T) {
|
||||||
|
_, db, cfg := newRunner(t)
|
||||||
|
|
||||||
|
path := filepath.Join(cfg.BackupsDir(), "taken.db")
|
||||||
|
if err := os.WriteFile(path, []byte("allerede her"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Snapshot(t.Context(), path); err == nil {
|
||||||
|
t.Error("Snapshot overwrote an existing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(content) != "allerede her" {
|
||||||
|
t.Error("the existing file was modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nightly job is checked hourly but must run at most once a day, or every
|
||||||
|
// hourly tick would take a full copy.
|
||||||
|
func TestTheNightlyBackupRunsAtMostOncePerDay(t *testing.T) {
|
||||||
|
runner, _, cfg := newRunner(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := runner.nightlyBackup(ctx); err != nil {
|
||||||
|
t.Fatalf("first run: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
||||||
|
t.Fatalf("got %d backups after the first run, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An hour later — the next tick — nothing further should happen.
|
||||||
|
if err := runner.nightlyBackup(ctx); err != nil {
|
||||||
|
t.Fatalf("second run: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
||||||
|
t.Errorf("got %d backups after a second tick, want still 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runner must stop when its context is cancelled, and Wait must return —
|
||||||
|
// otherwise a shutdown hangs until Docker loses patience and kills the process.
|
||||||
|
func TestJobsStopOnShutdown(t *testing.T) {
|
||||||
|
runner, _, _ := newRunner(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
runner.Start(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
runner.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the background jobs did not stop within five seconds of shutdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupFiles(t *testing.T, cfg *config.Config) []string {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && filepath.Ext(e.Name()) == ".db" &&
|
||||||
|
len(e.Name()) > len(backupPrefix) && e.Name()[:len(backupPrefix)] == backupPrefix {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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)
|
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 {
|
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
||||||
subject := "Nulstil din adgangskode i verdande"
|
subject := "Nulstil din adgangskode i verdande"
|
||||||
body := fmt.Sprintf(`Hej %s
|
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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -201,3 +203,48 @@ func boolToInt(b bool) int {
|
|||||||
func isUniqueViolation(err error) bool {
|
func isUniqueViolation(err error) bool {
|
||||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
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