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>
331 lines
10 KiB
Go
331 lines
10 KiB
Go
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)
|
|
}
|