package services import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" ) // ChecklistInstanceService reads and writes paliad.checklist_instances. // // Visibility mirrors paliad.appointments (project_id nullable): // - project_id NULL → creator-only (personal instance) // - project_id NOT NULL → parent Project's team-based gate // // Template resolution goes through ChecklistCatalogService so authored // templates (paliad.checklists, mig 114) and static templates work // interchangeably. Instance create captures a template_snapshot so // subsequent template edits/deletes don't disturb existing instances. type ChecklistInstanceService struct { db *sqlx.DB projects *ProjectService catalog *ChecklistCatalogService } func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog *ChecklistCatalogService) *ChecklistInstanceService { return &ChecklistInstanceService{db: db, projects: projects, catalog: catalog} } const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state, ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version` const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `, p.reference AS project_reference, p.title AS project_title FROM paliad.checklist_instances ci LEFT JOIN paliad.projects p ON p.id = ci.project_id` // CreateInstanceInput is the POST body for creating a new instance. type CreateInstanceInput struct { Name string `json:"name"` ProjectID *uuid.UUID `json:"project_id,omitempty"` } // UpdateInstanceInput is the PATCH body. Any subset of fields may be set. type UpdateInstanceInput struct { Name *string `json:"name,omitempty"` ProjectID *uuid.UUID `json:"project_id,omitempty"` State map[string]bool `json:"state,omitempty"` ClearProject bool `json:"clear_projekt,omitempty"` } // ListForTemplate returns every visible instance of a given template. func (s *ChecklistInstanceService) ListForTemplate(ctx context.Context, userID uuid.UUID, slug string) ([]models.ChecklistInstanceWithProject, error) { if _, err := s.catalog.Find(ctx, userID, slug); err != nil { if errors.Is(err, ErrNotVisible) { return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug) } return nil, err } user, err := s.projects.Users().GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { return []models.ChecklistInstanceWithProject{}, nil } query := checklistInstanceWithProjectSelect + ` WHERE ci.template_slug = :slug AND ( (ci.project_id IS NULL AND ci.created_by = :user_id) OR (ci.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) ) ORDER BY ci.updated_at DESC` args := map[string]any{ "slug": slug, "user_id": userID, } return s.listWithProject(ctx, query, args) } // ListForProject returns every visible instance attached to a Project. func (s *ChecklistInstanceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ChecklistInstanceWithProject, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { return nil, err } query := checklistInstanceWithProjectSelect + ` WHERE ci.project_id = :project_id ORDER BY ci.updated_at DESC` return s.listWithProject(ctx, query, map[string]any{"project_id": projectID}) } // ListAllVisible returns every checklist instance the user can see across // all templates and projects. Personal instances (project_id NULL) are scoped // to the creator; project-attached instances follow paliad.can_see_project. // Ordered by created_at DESC so the most recently created instances surface // first on the /checklists "Vorhandene Instanzen" tab. func (s *ChecklistInstanceService) ListAllVisible(ctx context.Context, userID uuid.UUID) ([]models.ChecklistInstanceWithProject, error) { user, err := s.projects.Users().GetByID(ctx, userID) if err != nil { return nil, err } if user == nil { return []models.ChecklistInstanceWithProject{}, nil } query := checklistInstanceWithProjectSelect + ` WHERE ( (ci.project_id IS NULL AND ci.created_by = :user_id) OR (ci.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) ) ORDER BY ci.created_at DESC` return s.listWithProject(ctx, query, map[string]any{"user_id": userID}) } // 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. Captures a template_snapshot via the // catalog so subsequent template edits/deletes don't disturb this row // (t-paliad-225 Slice A). func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) { entry, err := s.catalog.Find(ctx, userID, slug) if err != nil { if errors.Is(err, ErrNotVisible) { return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug) } return nil, err } snapshot, err := s.catalog.SnapshotBody(ctx, userID, slug) if err != nil { return nil, fmt.Errorf("snapshot template body: %w", err) } // Slice C — capture the version we snapshotted from so the instance // detail page can show "template updated since this instance was // created" when the live version pulls ahead. snapshotVersion := entry.Version 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.ProjectID != nil { if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); 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, project_id, state, template_snapshot, template_version, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $8, $8)`, id, slug, name, input.ProjectID, string(snapshot), snapshotVersion, userID, now, ); err != nil { return nil, fmt.Errorf("insert checklist_instance: %w", err) } if input.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C angelegt", name) descPtr := &desc if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID, "checklist_created", "Checklist created", descPtr, map[string]any{"checklist_instance_id": id}); 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). 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 } var relinkTo *uuid.UUID var unlinking bool if input.ClearProject { appendSet("project_id", nil) unlinking = true } else if input.ProjectID != nil { if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil { return nil, err } appendSet("project_id", *input.ProjectID) relinkTo = input.ProjectID } 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) } meta := map[string]any{"checklist_instance_id": id} switch { case renamedTo != nil && current.ProjectID != nil: desc := fmt.Sprintf("Checkliste umbenannt: \u201E%s\u201C", *renamedTo) descPtr := &desc if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "checklist_renamed", "Checklist renamed", descPtr, meta); err != nil { return nil, err } case unlinking && current.ProjectID != nil: desc := fmt.Sprintf("Checkliste \u201E%s\u201C von Project getrennt", current.Name) descPtr := &desc if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "checklist_unlinked", "Checklist unlinked", descPtr, meta); err != nil { return nil, err } case relinkTo != nil: desc := fmt.Sprintf("Checkliste \u201E%s\u201C mit Project verknüpft", current.Name) descPtr := &desc if err := insertProjectEventWithMeta(ctx, tx, *relinkTo, userID, "checklist_linked", "Checklist linked", descPtr, meta); 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. 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.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C zurückgesetzt", current.Name) descPtr := &desc if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "checklist_reset", "Checklist reset", descPtr, map[string]any{"checklist_instance_id": id}); 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.projects.Users().GetByID(ctx, userID) if err != nil { return err } if user.GlobalRole != "global_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.ProjectID != nil { desc := fmt.Sprintf("Checkliste \u201E%s\u201C gelöscht", current.Name) descPtr := &desc if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "checklist_deleted", "Checklist deleted", descPtr); err != nil { return err } } return tx.Commit() } // --- internals ------------------------------------------------------------ func (s *ChecklistInstanceService) listWithProject(ctx context.Context, query string, args map[string]any) ([]models.ChecklistInstanceWithProject, error) { stmt, err := s.db.PrepareNamedContext(ctx, query) if err != nil { return nil, fmt.Errorf("prepare list instances: %w", err) } defer stmt.Close() rows := []models.ChecklistInstanceWithProject{} 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, project_id, state, created_by, created_at, updated_at, template_snapshot, template_version 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.ProjectID == nil { if inst.CreatedBy != userID { return ErrNotVisible } return nil } _, err := s.projects.GetByID(ctx, userID, *inst.ProjectID) return err }