Files
archivdms/internal/storage/reminders.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

171 lines
6.5 KiB
Go

// 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
}