Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
171 lines
6.5 KiB
Go
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
|
|
}
|