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.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user