Checklisten move from one-per-slug localStorage state to a template/instance
model. A user creates multiple named instances of each template (UPC SoC,
EPA Einspruch, …), each with its own checkbox state in paliad.checklist_instances
and an optional akte_id for office-wide visibility.
- Migration 014: paliad.checklist_instances + RLS mirroring the Termine
pattern (akte_id nullable → creator-only; akte_id set → can_see_akte gate).
- Static template data moves out of internal/handlers into internal/checklisten
so both handlers and the new ChecklistInstanceService can reference it
without an import cycle.
- ChecklistInstanceService: CRUD + state merge via `state || $n::jsonb`
so concurrent checkbox toggles don't clobber each other. Reset clears
state to {}. Akte-linked mutations append akten_events audit rows.
- Handlers: GET/POST /api/checklisten/{slug}/instances, GET/PATCH/DELETE
/api/checklisten/instances/{id}, POST .../reset, GET /api/akten/{id}/checklisten.
- /checklisten/{slug} redesigned to show template metadata + instance
table + "Neue Instanz" modal (with optional Akte dropdown). The
interactive checkboxes move to /checklisten/instances/{id} where the
state is DB-backed and Reset posts to the server. Fixes the original
Reset button regression — it now operates on real server state rather
than silently failing client-side.
- Akten detail grows a Checklisten tab listing linked instances with
progress bars; only loads on tab activation.
- localStorage-based progress removed from the overview grid (state no
longer lives there).
- DE + EN i18n keys added.
Verified: bun run build clean; go build ./...; go vet ./...; go test ./...
all green.
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/checklisten"
|
|
"mgit.msbls.de/m/patholo/internal/models"
|
|
)
|
|
|
|
// ChecklistInstanceService reads and writes paliad.checklist_instances.
|
|
//
|
|
// An "instance" is a user's working copy of a static checklist template
|
|
// (UPC Klageschrift, EPA Einspruch, …). Template data lives in
|
|
// internal/checklisten; per-instance checkbox state lives here in the
|
|
// `state` jsonb column.
|
|
//
|
|
// Visibility mirrors paliad.termine (akte_id nullable):
|
|
//
|
|
// - akte_id NULL → creator-only (personal instance)
|
|
// - akte_id NOT NULL → parent Akte's office-scoped gate
|
|
//
|
|
// Akte-linked mutations append an akten_events audit row so the Verlauf
|
|
// tab shows "Checkliste angelegt/umbenannt/abgehakt". Personal instances
|
|
// never touch akten_events.
|
|
type ChecklistInstanceService struct {
|
|
db *sqlx.DB
|
|
akten *AkteService
|
|
}
|
|
|
|
func NewChecklistInstanceService(db *sqlx.DB, akten *AkteService) *ChecklistInstanceService {
|
|
return &ChecklistInstanceService{db: db, akten: akten}
|
|
}
|
|
|
|
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.akte_id, ci.state,
|
|
ci.created_by, ci.created_at, ci.updated_at`
|
|
|
|
const checklistInstanceWithAkteSelect = `SELECT ` + checklistInstanceColumns + `,
|
|
a.aktenzeichen AS akte_aktenzeichen,
|
|
a.title AS akte_title
|
|
FROM paliad.checklist_instances ci
|
|
LEFT JOIN paliad.akten a ON a.id = ci.akte_id`
|
|
|
|
// CreateInstanceInput is the POST body for creating a new instance.
|
|
type CreateInstanceInput struct {
|
|
Name string `json:"name"`
|
|
AkteID *uuid.UUID `json:"akte_id,omitempty"`
|
|
}
|
|
|
|
// UpdateInstanceInput is the PATCH body. Any subset of fields may be
|
|
// set. `State` merges into the existing state (per-key upsert) rather
|
|
// than replacing it — this keeps concurrent checkbox toggles from
|
|
// clobbering each other.
|
|
type UpdateInstanceInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
AkteID *uuid.UUID `json:"akte_id,omitempty"`
|
|
State map[string]bool `json:"state,omitempty"`
|
|
// ClearAkte explicitly unlinks from the current Akte (since sending
|
|
// AkteID=nil is indistinguishable from omitting the field).
|
|
ClearAkte bool `json:"clear_akte,omitempty"`
|
|
}
|
|
|
|
// ListForTemplate returns every visible instance of a given template.
|
|
func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithAkte, error) {
|
|
if _, ok := checklisten.Find(slug); !ok {
|
|
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
|
}
|
|
user, err := s.akten.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return []models.ChecklistInstanceWithAkte{}, nil
|
|
}
|
|
query := checklistInstanceWithAkteSelect + `
|
|
WHERE ci.template_slug = :slug
|
|
AND (
|
|
(ci.akte_id IS NULL AND ci.created_by = :user_id)
|
|
OR (ci.akte_id IS NOT NULL AND (
|
|
a.firm_wide_visible = true
|
|
OR a.owning_office = :office
|
|
OR :user_id = ANY (a.collaborators)
|
|
OR :role = 'admin'
|
|
))
|
|
)
|
|
ORDER BY ci.updated_at DESC`
|
|
args := map[string]any{
|
|
"slug": slug,
|
|
"user_id": userID,
|
|
"office": user.Office,
|
|
"role": user.Role,
|
|
}
|
|
return s.listWithAkte(ctx, query, args)
|
|
}
|
|
|
|
// ListForAkte returns every visible instance attached to a given Akte.
|
|
// Used by the Akte detail Checklisten tab.
|
|
func (s *ChecklistInstanceService) ListForAkte(ctx context.Context, userID, akteID uuid.UUID) ([]models.ChecklistInstanceWithAkte, error) {
|
|
if _, err := s.akten.GetByID(ctx, userID, akteID); err != nil {
|
|
return nil, err
|
|
}
|
|
query := checklistInstanceWithAkteSelect + `
|
|
WHERE ci.akte_id = :akte_id
|
|
ORDER BY ci.updated_at DESC`
|
|
return s.listWithAkte(ctx, query, map[string]any{"akte_id": akteID})
|
|
}
|
|
|
|
// GetByID returns a single instance with visibility check applied.
|
|
func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.ChecklistInstance, error) {
|
|
inst, err := s.getByIDUnchecked(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.requireVisible(ctx, userID, inst); err != nil {
|
|
return nil, err
|
|
}
|
|
return inst, nil
|
|
}
|
|
|
|
// Create inserts a new instance. Validates slug against static templates
|
|
// and gates by Akte visibility when akte_id is set.
|
|
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
|
|
if _, ok := checklisten.Find(slug); !ok {
|
|
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
|
}
|
|
name := strings.TrimSpace(input.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if len(name) > 200 {
|
|
return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
|
|
}
|
|
if input.AkteID != nil {
|
|
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.checklist_instances
|
|
(id, template_slug, name, akte_id, state, created_by, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, $6, $6)`,
|
|
id, slug, name, input.AkteID, userID, now,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("insert checklist_instance: %w", err)
|
|
}
|
|
|
|
if input.AkteID != nil {
|
|
desc := fmt.Sprintf("Checkliste \u201E%s\u201C angelegt", name)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *input.AkteID, userID,
|
|
"checkliste_created", "Checkliste angelegt", descPtr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit create instance: %w", err)
|
|
}
|
|
return s.GetByID(ctx, userID, id)
|
|
}
|
|
|
|
// Update applies a partial update (rename, re-link, state merge).
|
|
// State is merged per-key into the existing jsonb — toggling one
|
|
// checkbox never overwrites the rest.
|
|
func (s *ChecklistInstanceService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateInstanceInput) (*models.ChecklistInstance, error) {
|
|
current, err := s.GetByID(ctx, userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []any{}
|
|
next := 1
|
|
appendSet := func(col string, val any) {
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
|
args = append(args, val)
|
|
next++
|
|
}
|
|
|
|
var renamedTo *string
|
|
if input.Name != nil {
|
|
n := strings.TrimSpace(*input.Name)
|
|
if n == "" {
|
|
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
|
}
|
|
if len(n) > 200 {
|
|
return nil, fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
|
|
}
|
|
appendSet("name", n)
|
|
renamedTo = &n
|
|
}
|
|
|
|
// akte_id changes are handled via ClearAkte (unlink) or explicit AkteID (relink).
|
|
var relinkTo *uuid.UUID
|
|
var unlinking bool
|
|
if input.ClearAkte {
|
|
appendSet("akte_id", nil)
|
|
unlinking = true
|
|
} else if input.AkteID != nil {
|
|
if _, err := s.akten.GetByID(ctx, userID, *input.AkteID); err != nil {
|
|
return nil, err
|
|
}
|
|
appendSet("akte_id", *input.AkteID)
|
|
relinkTo = input.AkteID
|
|
}
|
|
|
|
if len(input.State) > 0 {
|
|
patch, err := json.Marshal(input.State)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal state patch: %w", err)
|
|
}
|
|
sets = append(sets, fmt.Sprintf("state = state || $%d::jsonb", next))
|
|
args = append(args, string(patch))
|
|
next++
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
return current, nil
|
|
}
|
|
appendSet("updated_at", time.Now().UTC())
|
|
|
|
args = append(args, id)
|
|
query := fmt.Sprintf("UPDATE paliad.checklist_instances SET %s WHERE id = $%d",
|
|
strings.Join(sets, ", "), next)
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
|
return nil, fmt.Errorf("update checklist_instance: %w", err)
|
|
}
|
|
|
|
// Audit: rename / (un)link events on the owning Akte only.
|
|
switch {
|
|
case renamedTo != nil && current.AkteID != nil:
|
|
desc := fmt.Sprintf("Checkliste umbenannt: \u201E%s\u201C", *renamedTo)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
|
|
"checkliste_renamed", "Checkliste umbenannt", descPtr); err != nil {
|
|
return nil, err
|
|
}
|
|
case unlinking && current.AkteID != nil:
|
|
desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Akte getrennt", current.Name)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
|
|
"checkliste_unlinked", "Checkliste von Akte getrennt", descPtr); err != nil {
|
|
return nil, err
|
|
}
|
|
case relinkTo != nil:
|
|
desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Akte verknüpft", current.Name)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *relinkTo, userID,
|
|
"checkliste_linked", "Checkliste verknüpft", descPtr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit update instance: %w", err)
|
|
}
|
|
return s.GetByID(ctx, userID, id)
|
|
}
|
|
|
|
// Reset clears all checkbox state on an instance (state = {}).
|
|
func (s *ChecklistInstanceService) Reset(ctx context.Context, userID, id uuid.UUID) (*models.ChecklistInstance, error) {
|
|
current, err := s.GetByID(ctx, userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now().UTC()
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.checklist_instances
|
|
SET state = '{}'::jsonb, updated_at = $1
|
|
WHERE id = $2`, now, id); err != nil {
|
|
return nil, fmt.Errorf("reset instance: %w", err)
|
|
}
|
|
if current.AkteID != nil {
|
|
desc := fmt.Sprintf("Checkliste \u201E%s\u201C zurückgesetzt", current.Name)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
|
|
"checkliste_reset", "Checkliste zurückgesetzt", descPtr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit reset instance: %w", err)
|
|
}
|
|
return s.GetByID(ctx, userID, id)
|
|
}
|
|
|
|
// Delete removes an instance. Creator or partner/admin.
|
|
func (s *ChecklistInstanceService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
|
current, err := s.GetByID(ctx, userID, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.CreatedBy != userID {
|
|
user, err := s.akten.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil || (user.Role != "partner" && user.Role != "admin") {
|
|
return fmt.Errorf("%w: only the creator or a partner/admin can delete a checklist instance", ErrForbidden)
|
|
}
|
|
}
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.checklist_instances WHERE id = $1`, id); err != nil {
|
|
return fmt.Errorf("delete instance: %w", err)
|
|
}
|
|
if current.AkteID != nil {
|
|
desc := fmt.Sprintf("Checkliste \u201E%s\u201C gelöscht", current.Name)
|
|
descPtr := &desc
|
|
if err := insertAkteEvent(ctx, tx, *current.AkteID, userID,
|
|
"checkliste_deleted", "Checkliste gelöscht", descPtr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// --- internals ------------------------------------------------------------
|
|
|
|
func (s *ChecklistInstanceService) listWithAkte(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithAkte, error) {
|
|
stmt, err := s.db.PrepareNamedContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepare list instances: %w", err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
var rows []models.ChecklistInstanceWithAkte
|
|
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
|
|
return nil, fmt.Errorf("list checklist_instances: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.ChecklistInstance, error) {
|
|
var inst models.ChecklistInstance
|
|
err := s.db.GetContext(ctx, &inst,
|
|
`SELECT id, template_slug, name, akte_id, state, created_by, created_at, updated_at
|
|
FROM paliad.checklist_instances WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch checklist_instance: %w", err)
|
|
}
|
|
return &inst, nil
|
|
}
|
|
|
|
func (s *ChecklistInstanceService) requireVisible(ctx context.Context, userID uuid.UUID, inst *models.ChecklistInstance) error {
|
|
if inst.AkteID == nil {
|
|
if inst.CreatedBy != userID {
|
|
return ErrNotVisible
|
|
}
|
|
return nil
|
|
}
|
|
_, err := s.akten.GetByID(ctx, userID, *inst.AkteID)
|
|
return err
|
|
}
|