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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4d142b2fa7
commit
7e4c730049
@@ -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"
|
||||
"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