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
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user