Files
paliad/internal/services/deadline_service.go
m 3aa8bae8e9 feat(deadlines): add reversible deadline status — admin/lead reopen (t-paliad-045)
Completed deadlines were irreversible — accidental completions could not be
undone. Adds a symmetric reopen path for global admins and project leads.

Server:
- PATCH /api/deadlines/{id}/reopen flips status back to pending and clears
  completed_at, audit-logged as project_event kind 'deadline_reopened'.
- DeadlineService.Reopen mirrors Complete shape; new
  assertCanAdminProject helper gates on global users.role='admin' OR
  paliad.project_teams.role IN ('admin','lead') walking the project path.
- Service test (skipped without TEST_DATABASE_URL) covers admin + non-admin
  paths and idempotent no-op.

UI:
- /deadlines/{id} detail: Wieder öffnen / Reopen button replaces the
  disabled completed-state Erledigt button (admin/partner only).
- /deadlines list: per-row ↻ icon for completed rows (admin/partner only;
  project-lead-only users use the detail page).
- i18n: fristen.detail.reopen, fristen.action.reopen,
  dashboard.action.deadline_reopened (DE + EN).
2026-04-26 14:52:00 +02:00

618 lines
20 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
)
// DeadlineService reads and writes paliad.deadlines. Visibility inherits from the
// parent Project via ProjectService.GetByID — every read or write goes through
// that gate first.
//
// Audit: every mutation appends a paliad.project_events row via
// insertProjectEvent so the Project verlauf shows what changed.
type DeadlineService struct {
db *sqlx.DB
projects *ProjectService
}
// NewDeadlineService wires the service.
func NewDeadlineService(db *sqlx.DB, projects *ProjectService) *DeadlineService {
return &DeadlineService{db: db, projects: projects}
}
const fristColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at`
// CreateFristInput is the payload for Create / bulk create entries.
type CreateFristInput struct {
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"` // YYYY-MM-DD
OriginalDueDate *string `json:"original_due_date,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
}
// UpdateFristInput is the partial-update payload for PATCH.
type UpdateFristInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
type DeadlineStatusFilter string
const (
DeadlineFilterAll DeadlineStatusFilter = "all"
DeadlineFilterOverdue DeadlineStatusFilter = "overdue"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
)
// ListFilter narrows ListVisibleForUser results.
type ListFilter struct {
Status DeadlineStatusFilter
ProjectID *uuid.UUID
}
// ListVisibleForUser returns Deadlines on every Project the user can see,
// joined with parent-Project display fields. Sorted by due_date ascending.
func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.DeadlineWithProject, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.DeadlineWithProject{}, nil
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
}
if filter.ProjectID != nil {
conds = append(conds, `f.project_id = :project_id`)
args["project_id"] = *filter.ProjectID
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
endOfWeek := today.AddDate(0, 0, 7)
switch filter.Status {
case DeadlineFilterOverdue:
conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
args["today"] = today
case DeadlineFilterThisWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek`)
args["today"] = today
args["endweek"] = endOfWeek
case DeadlineFilterUpcoming:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :endweek`)
args["endweek"] = endOfWeek
case DeadlineFilterCompleted:
conds = append(conds, `f.status = 'completed'`)
case DeadlineFilterPending:
conds = append(conds, `f.status = 'pending'`)
case DeadlineFilterAll, "":
// no-op
default:
return nil, fmt.Errorf("%w: unknown status filter %q", ErrInvalidInput, filter.Status)
}
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type,
r.code AS rule_code
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list deadlines: %w", err)
}
defer stmt.Close()
rows := []models.DeadlineWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list deadlines: %w", err)
}
return rows, nil
}
// ListForProjekt returns Deadlines for a specific Project (visibility-checked).
func (s *DeadlineService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
rows := []models.Deadline{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+fristColumns+`
FROM paliad.deadlines
WHERE project_id = $1
ORDER BY due_date ASC, created_at DESC`, projektID); err != nil {
return nil, fmt.Errorf("list deadlines for project: %w", err)
}
return rows, nil
}
// GetByID returns a single Deadline, with parent Project visibility checked.
func (s *DeadlineService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) {
projektID, err := s.parentProjectID(ctx, fristID)
if err != nil {
return nil, err
}
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var f models.Deadline
if err := s.db.GetContext(ctx, &f,
`SELECT `+fristColumns+` FROM paliad.deadlines WHERE id = $1`, fristID); err != nil {
return nil, fmt.Errorf("fetch deadline: %w", err)
}
return &f, nil
}
// Create inserts a single Deadline under a Project.
func (s *DeadlineService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (*models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
id, err := s.insert(ctx, userID, projektID, input)
if err != nil {
return nil, err
}
return s.GetByID(ctx, userID, id)
}
// CreateBulk inserts multiple Deadlines under one Project in a single
// transaction (Fristenrechner "Als Deadline(en) speichern" flow).
func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projektID uuid.UUID, inputs []CreateFristInput) ([]models.Deadline, error) {
if len(inputs) == 0 {
return nil, fmt.Errorf("%w: at least one Deadline is required", ErrInvalidInput)
}
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
ids := make([]uuid.UUID, 0, len(inputs))
for _, in := range inputs {
id, err := s.insertTx(ctx, tx, userID, projektID, in)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
desc := fmt.Sprintf("%d Deadlines aus Fristenrechner übernommen", len(inputs))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadlines_imported", "Deadlines imported", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit bulk create: %w", err)
}
out := make([]models.Deadline, 0, len(ids))
for _, id := range ids {
f, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, nil
}
// Update applies a partial update to a Deadline.
func (s *DeadlineService) Update(ctx context.Context, userID, fristID uuid.UUID, input UpdateFristInput) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, fristID)
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++
}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", title)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.DueDate != nil {
due, err := time.Parse("2006-01-02", *input.DueDate)
if err != nil {
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
appendSet("due_date", due)
}
if input.Notes != nil {
appendSet("notes", *input.Notes)
}
if input.Status != nil {
if !isValidFristStatus(*input.Status) {
return nil, fmt.Errorf("%w: invalid status %q", ErrInvalidInput, *input.Status)
}
appendSet("status", *input.Status)
if *input.Status == "completed" && current.CompletedAt == nil {
appendSet("completed_at", time.Now().UTC())
} else if *input.Status != "completed" {
appendSet("completed_at", nil)
}
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, fristID)
query := fmt.Sprintf("UPDATE paliad.deadlines 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 deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C geändert", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update deadline: %w", err)
}
return s.GetByID(ctx, userID, fristID)
}
// Complete marks a Deadline as completed.
func (s *DeadlineService) Complete(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return nil, err
}
if current.Status == "completed" {
return current, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'completed', completed_at = $1, updated_at = $1
WHERE id = $2`, now, fristID); err != nil {
return nil, fmt.Errorf("complete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C als erledigt markiert", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit complete: %w", err)
}
return s.GetByID(ctx, userID, fristID)
}
// Reopen flips a completed Deadline back to pending and clears completed_at.
// Authorization: global admin OR a member of the Project (or any ancestor)
// with project_teams.role IN ('admin','lead'). Other authenticated viewers
// can see the Deadline but cannot reopen it.
func (s *DeadlineService) Reopen(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return nil, err
}
if err := s.assertCanAdminProject(ctx, userID, current.ProjectID); err != nil {
return nil, err
}
if current.Status != "completed" {
return current, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'pending', completed_at = NULL, updated_at = $1
WHERE id = $2`, now, fristID); err != nil {
return nil, fmt.Errorf("reopen deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C wieder ge\u00f6ffnet", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reopen: %w", err)
}
return s.GetByID(ctx, userID, fristID)
}
// assertCanAdminProject returns nil if the user may perform admin-level
// actions on the Project (reopen, future bulk ops). Pass-conditions:
// - global users.role = 'admin', or
// - direct/inherited project_teams membership with role IN ('admin','lead').
//
// Returns ErrForbidden otherwise. Visibility must be checked separately
// (callers do this via GetByID before calling here).
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projektID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role == "admin" {
return nil
}
var ok bool
err = s.db.GetContext(ctx, &ok,
`SELECT EXISTS (
SELECT 1
FROM paliad.projects p
JOIN paliad.project_teams pt
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE p.id = $1
AND pt.user_id = $2
AND pt.role IN ('admin', 'lead')
)`, projektID, userID)
if err != nil {
return fmt.Errorf("check project admin: %w", err)
}
if !ok {
return fmt.Errorf("%w: only project admins/leads can reopen Deadlines", ErrForbidden)
}
return nil
}
// Delete hard-deletes a Deadline. Partner/admin only.
func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden)
}
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return err
}
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.deadlines WHERE id = $1`, fristID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C gelöscht", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
}
return tx.Commit()
}
// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
type SummaryCounts struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Upcoming int `json:"upcoming" db:"upcoming"`
Completed int `json:"completed" db:"completed"`
Total int `json:"total" db:"total"`
}
// SummaryCounts aggregates Deadlines by due-date bucket for the user's visible
// projects, optionally scoped to a single Project.
func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, projektID *uuid.UUID) (*SummaryCounts, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &SummaryCounts{}, nil
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
tomorrow := today.AddDate(0, 0, 1)
endWeek := today.AddDate(0, 0, 7)
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"today": today,
"tomorrow": tomorrow,
"endweek": endWeek,
}
if projektID != nil {
conds = append(conds, `f.project_id = :project_id`)
args["project_id"] = *projektID
}
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :tomorrow) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare summary: %w", err)
}
defer stmt.Close()
var c SummaryCounts
if err := stmt.GetContext(ctx, &c, args); err != nil {
return nil, fmt.Errorf("deadline summary: %w", err)
}
return &c, nil
}
// insert performs one INSERT in its own transaction.
func (s *DeadlineService) insert(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id, err := s.insertTx(ctx, tx, userID, projektID, input)
if err != nil {
return uuid.Nil, err
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C angelegt", strings.TrimSpace(input.Title))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadline_created", "Deadline created", descPtr); err != nil {
return uuid.Nil, err
}
if err := tx.Commit(); err != nil {
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
}
return id, nil
}
// insertTx writes one deadlines row in an existing transaction.
func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.DueDate == "" {
return uuid.Nil, fmt.Errorf("%w: due_date is required", ErrInvalidInput)
}
due, err := time.Parse("2006-01-02", input.DueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
var orig *time.Time
if input.OriginalDueDate != nil && *input.OriginalDueDate != "" {
o, err := time.Parse("2006-01-02", *input.OriginalDueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: original_due_date must be YYYY-MM-DD", ErrInvalidInput)
}
orig = &o
}
source := input.Source
if source == "" {
source = "manual"
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $11)`,
id, projektID, title, input.Description, due, orig,
source, input.RuleID, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}
return id, nil
}
// parentProjectID resolves a Deadline's parent Project ID without a visibility
// check. Internal only — callers must then gate via ProjectService.GetByID.
func (s *DeadlineService) parentProjectID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var projektID uuid.UUID
err := s.db.GetContext(ctx, &projektID,
`SELECT project_id FROM paliad.deadlines WHERE id = $1`, fristID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
}
return projektID, nil
}
// users returns the shared user service via the ProjectService handle.
func (s *DeadlineService) users() *UserService {
return s.projects.Users()
}
func isValidFristStatus(st string) bool {
switch st {
case "pending", "completed", "cancelled", "waived":
return true
}
return false
}