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
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -201,3 +203,48 @@ func boolToInt(b bool) int {
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
||||
}
|
||||
|
||||
// --- calendar feed tokens -------------------------------------------------------
|
||||
|
||||
// EnsureICSToken returns the user's feed token, minting one the first time it is
|
||||
// asked for. Created lazily rather than at signup so that somebody who never
|
||||
// subscribes to a calendar never has a standing credential they did not ask for.
|
||||
func (db *DB) EnsureICSToken(ctx context.Context, userID string) (string, error) {
|
||||
var token sql.NullString
|
||||
err := db.QueryRowContext(ctx, `SELECT ics_token FROM users WHERE id = ?`, userID).Scan(&token)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token.Valid && token.String != "" {
|
||||
return token.String, nil
|
||||
}
|
||||
|
||||
fresh, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := db.SetICSToken(ctx, userID, fresh); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetICSToken(ctx context.Context, userID, token string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET ics_token = ?, updated_at = ? WHERE id = ?`,
|
||||
token, time.Now().Unix(), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserByICSToken resolves a feed URL to its owner. The token is the whole
|
||||
// credential, so it is compared as an exact match on an indexed unique column and
|
||||
// nothing else about the request is trusted.
|
||||
func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
|
||||
if token == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user