// Wiedervorlage (reminder) store. Pattern ported from archivmail's // saved_searches.go: a small, single-file store extending the shared *Store // with its own idempotent schema init + CRUD, ownership enforced by // requiring id+tenant_id(+user_id) to match on every mutating query. package storage import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" ) // Reminder ("Wiedervorlage") is a due-date follow-up attached to a document. type Reminder struct { ID int64 `json:"id"` DocumentID int64 `json:"document_id"` TenantID int64 `json:"tenant_id"` UserID int64 `json:"user_id"` DueDate time.Time `json:"due_date"` Note string `json:"note,omitempty"` Status string `json:"status"` // open|done|dismissed NotifiedAt *time.Time `json:"notified_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } const ( ReminderStatusOpen = "open" ReminderStatusDone = "done" ReminderStatusDismissed = "dismissed" ) func (s *Store) initReminderSchema(ctx context.Context) error { _, err := s.db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS reminders ( id BIGSERIAL PRIMARY KEY, document_id BIGINT NOT NULL REFERENCES documents(id), tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, due_date TIMESTAMPTZ NOT NULL, note TEXT, status TEXT NOT NULL DEFAULT 'open', notified_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_reminders_tenant_due ON reminders(tenant_id, due_date) WHERE status = 'open'; CREATE INDEX IF NOT EXISTS idx_reminders_document ON reminders(document_id); `) if err != nil { return fmt.Errorf("storage: create reminders table: %w", err) } return nil } // CreateReminder inserts a new reminder for a document and returns it. func (s *Store) CreateReminder(ctx context.Context, documentID, tenantID, userID int64, dueDate time.Time, note string) (*Reminder, error) { var r Reminder err := s.db.QueryRow(ctx, ` INSERT INTO reminders (document_id, tenant_id, user_id, due_date, note, status) VALUES ($1, $2, $3, $4, $5, 'open') RETURNING id, document_id, tenant_id, user_id, due_date, COALESCE(note, ''), status, notified_at, created_at, updated_at `, documentID, tenantID, userID, dueDate, nullIfEmpty(note), ).Scan(&r.ID, &r.DocumentID, &r.TenantID, &r.UserID, &r.DueDate, &r.Note, &r.Status, &r.NotifiedAt, &r.CreatedAt, &r.UpdatedAt) if err != nil { return nil, fmt.Errorf("reminders: create: %w", err) } return &r, nil } // ListReminders returns reminders for a user within a tenant, optionally // filtered by status ("" = all). func (s *Store) ListReminders(ctx context.Context, tenantID, userID int64, status string) ([]Reminder, error) { var rows pgx.Rows var err error if status == "" { rows, err = s.db.Query(ctx, ` SELECT id, document_id, tenant_id, user_id, due_date, COALESCE(note, ''), status, notified_at, created_at, updated_at FROM reminders WHERE tenant_id = $1 AND user_id = $2 ORDER BY due_date ASC `, tenantID, userID) } else { rows, err = s.db.Query(ctx, ` SELECT id, document_id, tenant_id, user_id, due_date, COALESCE(note, ''), status, notified_at, created_at, updated_at FROM reminders WHERE tenant_id = $1 AND user_id = $2 AND status = $3 ORDER BY due_date ASC `, tenantID, userID, status) } if err != nil { return nil, fmt.Errorf("reminders: list: %w", err) } defer rows.Close() out := make([]Reminder, 0) for rows.Next() { var r Reminder if err := rows.Scan(&r.ID, &r.DocumentID, &r.TenantID, &r.UserID, &r.DueDate, &r.Note, &r.Status, &r.NotifiedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("reminders: scan: %w", err) } out = append(out, r) } return out, rows.Err() } // ListDueReminders returns all open reminders with due_date <= cutoff and // notified_at still NULL, across all tenants. Intended for the cron // notification job (`archivdms reminders notify`). func (s *Store) ListDueReminders(ctx context.Context, cutoff time.Time) ([]Reminder, error) { rows, err := s.db.Query(ctx, ` SELECT id, document_id, tenant_id, user_id, due_date, COALESCE(note, ''), status, notified_at, created_at, updated_at FROM reminders WHERE status = 'open' AND due_date <= $1 AND notified_at IS NULL ORDER BY due_date ASC `, cutoff) if err != nil { return nil, fmt.Errorf("reminders: list due: %w", err) } defer rows.Close() var out []Reminder for rows.Next() { var r Reminder if err := rows.Scan(&r.ID, &r.DocumentID, &r.TenantID, &r.UserID, &r.DueDate, &r.Note, &r.Status, &r.NotifiedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("reminders: scan due: %w", err) } out = append(out, r) } return out, rows.Err() } // UpdateReminderStatus updates the status of a reminder, enforcing ownership // via id+tenant_id+user_id. func (s *Store) UpdateReminderStatus(ctx context.Context, id, tenantID, userID int64, status string) (*Reminder, error) { var r Reminder err := s.db.QueryRow(ctx, ` UPDATE reminders SET status = $1, updated_at = now() WHERE id = $2 AND tenant_id = $3 AND user_id = $4 RETURNING id, document_id, tenant_id, user_id, due_date, COALESCE(note, ''), status, notified_at, created_at, updated_at `, status, id, tenantID, userID, ).Scan(&r.ID, &r.DocumentID, &r.TenantID, &r.UserID, &r.DueDate, &r.Note, &r.Status, &r.NotifiedAt, &r.CreatedAt, &r.UpdatedAt) if err != nil { return nil, fmt.Errorf("reminders: update status: %w", err) } return &r, nil } // MarkReminderNotified sets notified_at = now() for the given reminder. Used // by the cron notification job after a successful send; not ownership-scoped // because it runs as a system job, not on behalf of a specific user. func (s *Store) MarkReminderNotified(ctx context.Context, id int64) error { _, err := s.db.Exec(ctx, `UPDATE reminders SET notified_at = now(), updated_at = now() WHERE id = $1`, id) if err != nil { return fmt.Errorf("reminders: mark notified: %w", err) } return nil } // DeleteReminder deletes a reminder, enforcing ownership via // id+tenant_id+user_id. func (s *Store) DeleteReminder(ctx context.Context, id, tenantID, userID int64) error { tag, err := s.db.Exec(ctx, `DELETE FROM reminders WHERE id = $1 AND tenant_id = $2 AND user_id = $3`, id, tenantID, userID) if err != nil { return fmt.Errorf("reminders: delete: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("reminders: not found or not owned by user") } return nil }