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
@@ -67,6 +67,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
@@ -126,6 +130,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
@@ -184,6 +200,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
r.Post("/reopen", s.handleReopenTask)
|
||||
r.Post("/move", s.handleMoveTask)
|
||||
@@ -200,6 +218,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.router.ServeHTTP(w, r) }
|
||||
|
||||
// Mail and Hub are exposed so the background jobs can deliver through the same
|
||||
// sender and the same live connections the request path uses — a reminder should
|
||||
// arrive in an open tab exactly as a colleague's edit does.
|
||||
func (s *Server) Mail() *mail.Sender { return s.mail }
|
||||
func (s *Server) Hub() *realtime.Hub { return s.hub }
|
||||
|
||||
// handleHealth reports whether verdande can actually serve requests, which means
|
||||
// reaching the database — a process that is up but cannot read its own data is not
|
||||
// healthy, and answering 200 for it would keep a broken container in rotation.
|
||||
|
||||
Reference in New Issue
Block a user