Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project
This commit is contained in:
@@ -53,6 +53,9 @@ type AgendaItem struct {
|
||||
ProjectTitle *string `json:"project_title,omitempty"`
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
ProjectRef *string `json:"project_reference,omitempty"`
|
||||
// ApprovalStatus (t-paliad-138) — "pending" → render warning pill on
|
||||
// the agenda timeline. "approved"/"legacy" → no pill.
|
||||
ApprovalStatus *string `json:"approval_status,omitempty"`
|
||||
}
|
||||
|
||||
// AgendaFilter narrows the merged feed.
|
||||
@@ -167,6 +170,7 @@ SELECT f.id,
|
||||
f.title,
|
||||
f.due_date,
|
||||
f.status,
|
||||
f.approval_status,
|
||||
p.id AS project_id,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -184,6 +188,7 @@ SELECT f.id,
|
||||
Title string `db:"title"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
Status string `db:"status"`
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
ProjectID uuid.UUID `db:"project_id"`
|
||||
ProjectTitle string `db:"project_title"`
|
||||
ProjectType string `db:"project_type"`
|
||||
@@ -198,20 +203,22 @@ SELECT f.id,
|
||||
for _, r := range rows {
|
||||
due := r.DueDate.Format("2006-01-02")
|
||||
status := r.Status
|
||||
approvalStatus := r.ApprovalStatus
|
||||
projectID := r.ProjectID
|
||||
projectTitle := r.ProjectTitle
|
||||
projectType := r.ProjectType
|
||||
out = append(out, AgendaItem{
|
||||
ID: r.ID,
|
||||
Type: "deadline",
|
||||
Title: r.Title,
|
||||
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
ProjectID: &projectID,
|
||||
ProjectTitle: &projectTitle,
|
||||
ProjectType: &projectType,
|
||||
ProjectRef: r.ProjectReference,
|
||||
ID: r.ID,
|
||||
Type: "deadline",
|
||||
Title: r.Title,
|
||||
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
ProjectID: &projectID,
|
||||
ProjectTitle: &projectTitle,
|
||||
ProjectType: &projectType,
|
||||
ProjectRef: r.ProjectReference,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -228,6 +235,7 @@ SELECT t.id,
|
||||
t.end_at,
|
||||
t.location,
|
||||
t.appointment_type,
|
||||
t.approval_status,
|
||||
t.project_id,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -249,6 +257,7 @@ SELECT t.id,
|
||||
EndAt *time.Time `db:"end_at"`
|
||||
Location *string `db:"location"`
|
||||
AppointmentType *string `db:"appointment_type"`
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
ProjectID *uuid.UUID `db:"project_id"`
|
||||
ProjectTitle *string `db:"project_title"`
|
||||
ProjectType *string `db:"project_type"`
|
||||
@@ -261,6 +270,7 @@ SELECT t.id,
|
||||
|
||||
out := make([]AgendaItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
approvalStatus := r.ApprovalStatus
|
||||
out = append(out, AgendaItem{
|
||||
ID: r.ID,
|
||||
Type: "appointment",
|
||||
@@ -269,6 +279,7 @@ SELECT t.id,
|
||||
EndAt: r.EndAt,
|
||||
Location: r.Location,
|
||||
AppointmentType: r.AppointmentType,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
ProjectID: r.ProjectID,
|
||||
ProjectTitle: r.ProjectTitle,
|
||||
ProjectType: r.ProjectType,
|
||||
|
||||
@@ -29,7 +29,14 @@ type AppointmentService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
|
||||
caldav AppointmentCalDAVPusher
|
||||
caldav AppointmentCalDAVPusher
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
// SetApprovalService wires the optional 4-eye approval workflow
|
||||
// (t-paliad-138). See DeadlineService.SetApprovalService.
|
||||
func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
|
||||
@@ -52,7 +59,8 @@ func (s *AppointmentService) SetCalDAVPusher(p AppointmentCalDAVPusher) {
|
||||
|
||||
const appointmentColumns = `id, project_id, title, description, start_at, end_at,
|
||||
location, appointment_type, caldav_uid, caldav_etag, created_by,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at, completed_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
// CreateAppointmentInput is the payload for POST /api/appointments.
|
||||
type CreateAppointmentInput struct {
|
||||
@@ -66,13 +74,21 @@ type CreateAppointmentInput struct {
|
||||
}
|
||||
|
||||
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
|
||||
//
|
||||
// ProjectID + ClearProject control the project move (t-paliad-140). Both
|
||||
// nil/false = leave project_id untouched. ClearProject=true unlinks the
|
||||
// appointment from its current project (only the creator may do this,
|
||||
// matching the personal-appointment edit gate). ProjectID set = move under
|
||||
// that project (visibility on the new project is enforced).
|
||||
type UpdateAppointmentInput struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
ClearProject bool `json:"clear_project,omitempty"`
|
||||
}
|
||||
|
||||
// AppointmentListFilter narrows ListVisibleForUser results.
|
||||
@@ -138,6 +154,8 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid
|
||||
SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at,
|
||||
t.location, t.appointment_type, t.caldav_uid, t.caldav_etag,
|
||||
t.created_by, t.created_at, t.updated_at,
|
||||
t.completed_at,
|
||||
t.approval_status, t.pending_request_id, t.approved_by, t.approved_at,
|
||||
p.reference AS project_reference,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type
|
||||
@@ -314,6 +332,15 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
|
||||
map[string]any{"appointment_id": id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Approval gate (t-paliad-138). No-op for personal appointments
|
||||
// (project_id IS NULL) and when no policy applies.
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit insert appointment: %w", err)
|
||||
@@ -330,6 +357,11 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
|
||||
}
|
||||
|
||||
// Update applies a partial update.
|
||||
//
|
||||
// Approval gate (t-paliad-138): only date-bearing fields (start_at,
|
||||
// end_at) trigger 4-eye per Q4. Cosmetic edits (title, description,
|
||||
// location, appointment_type) bypass approval. Personal appointments
|
||||
// (project_id IS NULL) never gate — there's no project policy to consult.
|
||||
func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID uuid.UUID, input UpdateAppointmentInput) (*models.Appointment, error) {
|
||||
current, err := s.GetByID(ctx, userID, appointmentID)
|
||||
if err != nil {
|
||||
@@ -342,6 +374,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -352,6 +387,9 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
next++
|
||||
}
|
||||
|
||||
preImage := map[string]any{}
|
||||
payload := map[string]any{}
|
||||
|
||||
if input.Title != nil {
|
||||
title := strings.TrimSpace(*input.Title)
|
||||
if title == "" {
|
||||
@@ -363,10 +401,28 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
appendSet("description", *input.Description)
|
||||
}
|
||||
if input.StartAt != nil {
|
||||
appendSet("start_at", input.StartAt.UTC())
|
||||
newStart := input.StartAt.UTC()
|
||||
if !newStart.Equal(current.StartAt) {
|
||||
preImage["start_at"] = current.StartAt.Format(time.RFC3339)
|
||||
payload["start_at"] = newStart.Format(time.RFC3339)
|
||||
}
|
||||
appendSet("start_at", newStart)
|
||||
}
|
||||
if input.EndAt != nil {
|
||||
appendSet("end_at", input.EndAt.UTC())
|
||||
newEnd := input.EndAt.UTC()
|
||||
oldEnd := time.Time{}
|
||||
if current.EndAt != nil {
|
||||
oldEnd = *current.EndAt
|
||||
}
|
||||
if !newEnd.Equal(oldEnd) {
|
||||
if current.EndAt != nil {
|
||||
preImage["end_at"] = current.EndAt.Format(time.RFC3339)
|
||||
} else {
|
||||
preImage["end_at"] = nil
|
||||
}
|
||||
payload["end_at"] = newEnd.Format(time.RFC3339)
|
||||
}
|
||||
appendSet("end_at", newEnd)
|
||||
}
|
||||
if input.Location != nil {
|
||||
appendSet("location", *input.Location)
|
||||
@@ -377,6 +433,41 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
appendSet("appointment_type", *input.AppointmentType)
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). ClearProject takes precedence over
|
||||
// ProjectID so a payload that sets both falls into the unlink branch
|
||||
// rather than silently ignoring the contradiction. Visibility on the
|
||||
// destination is enforced via projects.GetByID (matches Create).
|
||||
// Unlinking to a personal appointment is creator-only — same gate
|
||||
// personal-only Update branches enforce above — so a non-creator who
|
||||
// can mutate the project-attached row can't strand it on someone else's
|
||||
// personal calendar.
|
||||
var movedFromProject *uuid.UUID
|
||||
var movedToProject *uuid.UUID
|
||||
if input.ClearProject {
|
||||
if current.ProjectID != nil {
|
||||
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
||||
return nil, fmt.Errorf("%w: only the creator can convert this Appointment to personal", ErrForbidden)
|
||||
}
|
||||
from := *current.ProjectID
|
||||
movedFromProject = &from
|
||||
appendSet("project_id", nil)
|
||||
}
|
||||
} else if input.ProjectID != nil {
|
||||
if current.ProjectID == nil || *input.ProjectID != *current.ProjectID {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := *input.ProjectID
|
||||
movedToProject = &to
|
||||
if current.ProjectID != nil {
|
||||
from := *current.ProjectID
|
||||
movedFromProject = &from
|
||||
}
|
||||
appendSet("project_id", *input.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
}
|
||||
@@ -396,12 +487,62 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
return nil, fmt.Errorf("update appointment: %w", err)
|
||||
}
|
||||
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr,
|
||||
map[string]any{"appointment_id": appointmentID}); err != nil {
|
||||
return nil, err
|
||||
// Audit emission. Project moves (t-paliad-140) get their own
|
||||
// appointment_project_changed pair so the OLD and NEW project rows
|
||||
// keep an honest chronology. Edits to other fields land as
|
||||
// appointment_updated on whichever project the row sits on AFTER the
|
||||
// move (or on the source project if it was unlinked). Personal
|
||||
// appointments don't have audit history, so unlink/link rows on the
|
||||
// "personal" side are skipped.
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if movedFromProject != nil || movedToProject != nil {
|
||||
moveMeta := map[string]any{"appointment_id": appointmentID}
|
||||
if movedFromProject != nil {
|
||||
moveMeta["from_project_id"] = *movedFromProject
|
||||
}
|
||||
if movedToProject != nil {
|
||||
moveMeta["to_project_id"] = *movedToProject
|
||||
}
|
||||
if movedFromProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
|
||||
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if movedToProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedToProject, userID,
|
||||
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.StartAt != nil || input.EndAt != nil || input.Location != nil ||
|
||||
input.AppointmentType != nil
|
||||
if otherFieldsTouched {
|
||||
// After-move project. If the row is now personal (unlink), no
|
||||
// audit row — personal appointments don't surface in any
|
||||
// project's Verlauf.
|
||||
var auditProject *uuid.UUID
|
||||
switch {
|
||||
case movedToProject != nil:
|
||||
auditProject = movedToProject
|
||||
case movedFromProject != nil:
|
||||
// Unlink: no audit project
|
||||
default:
|
||||
auditProject = current.ProjectID
|
||||
}
|
||||
if auditProject != nil {
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *auditProject, userID, "appointment_updated", "Appointment updated", descPtr,
|
||||
map[string]any{"appointment_id": appointmentID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.approvals != nil {
|
||||
if _, err := s.approvals.SubmitUpdate(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -418,6 +559,12 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
|
||||
// Delete removes an Appointment.
|
||||
//
|
||||
// Approval gate (t-paliad-138): for project-attached appointments, if a
|
||||
// (project, appointment, delete) policy applies, the row stays alive
|
||||
// with approval_status='pending' until the approver hard-deletes
|
||||
// (approve) or restores it (reject) — same stage-then-write exception
|
||||
// as DeadlineService.Delete.
|
||||
func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID uuid.UUID) error {
|
||||
current, err := s.GetByID(ctx, userID, appointmentID)
|
||||
if err != nil {
|
||||
@@ -430,6 +577,9 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -437,21 +587,39 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
|
||||
return fmt.Errorf("delete appointment: %w", err)
|
||||
}
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
|
||||
// Approval gate runs first for project-attached appointments. If a
|
||||
// policy applies, SubmitDelete returns a non-nil request id and we
|
||||
// skip the hard delete + the deletion event emit.
|
||||
var pendingRequest *uuid.UUID
|
||||
if current.ProjectID != nil && s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"title": current.Title,
|
||||
"start_at": current.StartAt.Format(time.RFC3339),
|
||||
}
|
||||
req, err := s.approvals.SubmitDelete(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingRequest = req
|
||||
}
|
||||
|
||||
if pendingRequest == nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
|
||||
return fmt.Errorf("delete appointment: %w", err)
|
||||
}
|
||||
if current.ProjectID != nil {
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit delete appointment: %w", err)
|
||||
}
|
||||
if s.caldav != nil {
|
||||
if pendingRequest == nil && s.caldav != nil {
|
||||
s.caldav.OnAppointmentDeleted(ctx, userID, current)
|
||||
}
|
||||
return nil
|
||||
|
||||
105
internal/services/approval_levels.go
Normal file
105
internal/services/approval_levels.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package services
|
||||
|
||||
import "errors"
|
||||
|
||||
// Strict-ladder level helper for the 4-Augen-Prüfung approval gate
|
||||
// (t-paliad-138). Mirrors paliad.approval_role_level(text) in migration
|
||||
// 054. A user with project_teams.role R can approve any request whose
|
||||
// required_role has level <= levelOf(R). Roles outside the approval
|
||||
// ladder (local_counsel, expert, observer, anything new) return 0 and
|
||||
// are ineligible to approve at any level.
|
||||
|
||||
// RoleSeniorPA is the new project_teams.role value added by migration 054.
|
||||
// It sits between associate (3) and pa (1) and gives a named tier between
|
||||
// "associate" and "PA" for projects that want PAs supervised by senior PAs
|
||||
// rather than by associates.
|
||||
const RoleSeniorPA = "senior_pa"
|
||||
|
||||
// EntityType values for the polymorphic approval workflow.
|
||||
const (
|
||||
EntityTypeDeadline = "deadline"
|
||||
EntityTypeAppointment = "appointment"
|
||||
)
|
||||
|
||||
// LifecycleEvent values matching paliad.approval_policies.lifecycle_event
|
||||
// and paliad.approval_requests.lifecycle_event CHECK constraints.
|
||||
const (
|
||||
LifecycleCreate = "create"
|
||||
LifecycleUpdate = "update"
|
||||
LifecycleComplete = "complete"
|
||||
LifecycleDelete = "delete"
|
||||
)
|
||||
|
||||
// ApprovalStatus values on paliad.deadlines.approval_status and
|
||||
// paliad.appointments.approval_status.
|
||||
const (
|
||||
ApprovalStatusApproved = "approved"
|
||||
ApprovalStatusPending = "pending"
|
||||
ApprovalStatusLegacy = "legacy"
|
||||
)
|
||||
|
||||
// RequestStatus values on paliad.approval_requests.status.
|
||||
const (
|
||||
RequestStatusPending = "pending"
|
||||
RequestStatusApproved = "approved"
|
||||
RequestStatusRejected = "rejected"
|
||||
RequestStatusRevoked = "revoked"
|
||||
RequestStatusSuperseded = "superseded"
|
||||
)
|
||||
|
||||
// DecisionKind discriminates "peer" (normal in-team sign-off) from
|
||||
// "admin_override" (global_admin used the escape-hatch path). Verlauf
|
||||
// chronology renders these distinctly.
|
||||
const (
|
||||
DecisionKindPeer = "peer"
|
||||
DecisionKindAdminOverride = "admin_override"
|
||||
)
|
||||
|
||||
// levelOf maps a project_teams.role value to its strict-ladder level.
|
||||
// Mirrors paliad.approval_role_level(text) in SQL.
|
||||
//
|
||||
// 5: lead — partner-tier on this project
|
||||
// 4: of_counsel
|
||||
// 3: associate ← default required level on new policies
|
||||
// 2: senior_pa — added by migration 054
|
||||
// 1: pa
|
||||
// 0: local_counsel / expert / observer / anything new — ineligible to approve
|
||||
func levelOf(role string) int {
|
||||
switch role {
|
||||
case "lead":
|
||||
return 5
|
||||
case "of_counsel":
|
||||
return 4
|
||||
case "associate":
|
||||
return 3
|
||||
case RoleSeniorPA:
|
||||
return 2
|
||||
case "pa":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidRequiredRole returns true iff the role can be set as a policy's
|
||||
// required_role (i.e. it has a non-zero strict-ladder level).
|
||||
func IsValidRequiredRole(role string) bool {
|
||||
return levelOf(role) > 0
|
||||
}
|
||||
|
||||
// Approval-flow errors. Handlers map these to the right HTTP status:
|
||||
//
|
||||
// ErrSelfApproval -> 403
|
||||
// ErrNoQualifiedApprover -> 409 (with required_role hint)
|
||||
// ErrConcurrentPending -> 409 (with the existing request id hint)
|
||||
// ErrNotApprover -> 403
|
||||
// ErrRequestNotPending -> 409
|
||||
// ErrUnknownEntityType -> 500 (programming error)
|
||||
var (
|
||||
ErrSelfApproval = errors.New("self-approval blocked")
|
||||
ErrNoQualifiedApprover = errors.New("no qualified approver available")
|
||||
ErrConcurrentPending = errors.New("entity already has a pending approval request")
|
||||
ErrNotApprover = errors.New("not authorized to approve this request")
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
)
|
||||
859
internal/services/approval_service.go
Normal file
859
internal/services/approval_service.go
Normal file
@@ -0,0 +1,859 @@
|
||||
package services
|
||||
|
||||
// ApprovalService implements the 4-Augen-Prüfung workflow on
|
||||
// paliad.deadlines and paliad.appointments (t-paliad-138).
|
||||
//
|
||||
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
|
||||
// the entity row immediately; the entity carries approval_status='pending'
|
||||
// + pending_request_id until an approver flips it to 'approved'. Delete is
|
||||
// the one stage-then-write exception — we mark the row pending instead of
|
||||
// hard-deleting, then hard-delete on approve / restore on reject.
|
||||
//
|
||||
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
|
||||
// invoked by DeadlineService / AppointmentService inside their existing
|
||||
// transactions. They:
|
||||
// 1. Look up the policy for (project, entity_type, lifecycle_event).
|
||||
// 2. If no policy → no-op (entity stays approval_status='approved').
|
||||
// 3. If policy → run a deadlock check (qualified approver != requester
|
||||
// must exist), insert an approval_requests row, mark the entity
|
||||
// pending, emit a *_approval_requested project_events row.
|
||||
//
|
||||
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
|
||||
// - Approve: validate canApprove(caller, request); flip the entity back
|
||||
// to approved (or hard-delete for delete-lifecycle); emit
|
||||
// *_approval_approved.
|
||||
// - Reject: validate canApprove; revert the entity from pre_image (or
|
||||
// hard-delete a pending-create); emit *_approval_rejected.
|
||||
// - Revoke: validate caller == requester; same revert as Reject; emit
|
||||
// *_approval_revoked.
|
||||
//
|
||||
// Self-approval is blocked at three layers:
|
||||
// 1. canApprove() returns ErrSelfApproval when caller == requester.
|
||||
// 2. The DB CHECK constraint approval_requests_no_self_approval refuses
|
||||
// decided_by == requested_by writes.
|
||||
// 3. The deadlock-check excludes the requester from the qualified-approver
|
||||
// pool, so the deadlock path can't be silently bypassed.
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ApprovalService is the workflow orchestrator. It holds no entity-specific
|
||||
// knowledge — DeadlineService / AppointmentService call its Submit*
|
||||
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
|
||||
// entity tables to keep the dependency graph acyclic.
|
||||
type ApprovalService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewApprovalService wires the service.
|
||||
func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
|
||||
return &ApprovalService{db: db, users: users}
|
||||
}
|
||||
|
||||
// LookupPolicy returns the approval policy for the given tuple, or nil if
|
||||
// none exists. Read inside the same tx as Submit* so policy reads see
|
||||
// whatever the calling tx may have already written.
|
||||
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||||
var p models.ApprovalPolicy
|
||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
||||
row := txOrDB(tx, s.db).QueryRowxContext(ctx, q, projectID, entityType, lifecycleEvent)
|
||||
if err := row.StructScan(&p); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("lookup approval policy: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// hasQualifiedApprover counts users on the project's team-membership path
|
||||
// (direct OR ancestor) whose role meets the strict-ladder threshold for
|
||||
// requiredRole, plus any global_admin user. Excludes requesterID.
|
||||
//
|
||||
// Returns true if at least one such user exists. The path-walk JOIN matches
|
||||
// the visibility predicate so an ancestor lead qualifies for a descendant's
|
||||
// approval, just like they have visibility.
|
||||
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
|
||||
q := `WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = $1
|
||||
)
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
JOIN path ON pt.project_id = ANY(path.ids)
|
||||
WHERE pt.user_id <> $2
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
|
||||
UNION ALL
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin' AND u.id <> $2
|
||||
LIMIT 1
|
||||
) AS ok`
|
||||
var ok bool
|
||||
if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
|
||||
return false, fmt.Errorf("deadlock check: %w", err)
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// SubmitCreate is invoked by Deadline/AppointmentService inside their
|
||||
// create-tx, after the entity row has been INSERTed but before the
|
||||
// commit. If a (project, entity_type, 'create') policy applies, it inserts
|
||||
// the approval_requests row, marks the entity pending, and emits the
|
||||
// *_approval_requested audit event.
|
||||
//
|
||||
// payload is the just-inserted entity's field values (used as audit echo).
|
||||
//
|
||||
// Returns the new request ID if pending, nil if no policy applied.
|
||||
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload)
|
||||
}
|
||||
|
||||
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
|
||||
// carries the date-bearing fields that were just overwritten (per Q4
|
||||
// allowlist) so a rejection can restore them. payload echoes the new values.
|
||||
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
if len(preImage) == 0 {
|
||||
// Nothing in the date-bearing allowlist actually changed — bypass
|
||||
// the approval flow entirely (the underlying UPDATE was cosmetic).
|
||||
return nil, nil
|
||||
}
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload)
|
||||
}
|
||||
|
||||
// SubmitComplete is invoked after status was flipped to 'completed'
|
||||
// (deadline) or completed_at was set (appointment). preImage stores the
|
||||
// pre-completion state so a rejection can revert.
|
||||
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload)
|
||||
}
|
||||
|
||||
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
|
||||
// the stage-then-write exception). The entity row stays alive with
|
||||
// approval_status='pending'; on approve we hard-delete, on reject we just
|
||||
// clear the pending markers.
|
||||
//
|
||||
// preImage stores the full row state so the inbox can render
|
||||
// "About to delete: Frist X (due 2026-05-12)".
|
||||
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil)
|
||||
}
|
||||
|
||||
// submit is the shared lifecycle-handling kernel.
|
||||
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// No policy applies — entity stays approval_status='approved'. No-op.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Deadlock check: somebody other than the requester must be qualified
|
||||
// to approve, either via project team membership or as global_admin.
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, policy.RequiredRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, policy.RequiredRole)
|
||||
}
|
||||
|
||||
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
|
||||
// guard against double-pending — but we surface a clean error rather
|
||||
// than letting the UPDATE silently fail. The guard relies on
|
||||
// approval_status='approved' being the precondition for a fresh
|
||||
// pending state.
|
||||
currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
requestID := uuid.New()
|
||||
preImageJSON, err := marshalJSONOrNull(preImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal pre_image: %w", err)
|
||||
}
|
||||
payloadJSON, err := marshalJSONOrNull(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
insertReqSQL := `INSERT INTO paliad.approval_requests
|
||||
(id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, required_role, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
|
||||
if _, err := tx.ExecContext(ctx, insertReqSQL,
|
||||
requestID, projectID, entityType, entityID, lifecycle,
|
||||
preImageJSON, payloadJSON, requesterID, policy.RequiredRole); err != nil {
|
||||
return nil, fmt.Errorf("insert approval_request: %w", err)
|
||||
}
|
||||
|
||||
// Mark the entity row pending. The WHERE approval_status='approved'
|
||||
// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
|
||||
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
|
||||
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
|
||||
entityTableName(entityType))
|
||||
res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark entity pending: %w", err)
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows != 1 {
|
||||
// Either the entity vanished or another tx flipped it pending.
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
// Audit emit.
|
||||
eventType := approvalEventType(entityType, "requested")
|
||||
descPtr := approvalDescription("requested", policy.RequiredRole, lifecycle)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": requestID.String(),
|
||||
"lifecycle_event": lifecycle,
|
||||
"required_role": policy.RequiredRole,
|
||||
entityType + "_id": entityID.String(),
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &requestID, nil
|
||||
}
|
||||
|
||||
// Approve flips a pending request to 'approved' and applies the lifecycle
|
||||
// to the entity. Runs in its own transaction.
|
||||
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
|
||||
}
|
||||
|
||||
// Reject flips a pending request to 'rejected' and reverts the entity from
|
||||
// pre_image. Runs in its own transaction.
|
||||
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
|
||||
}
|
||||
|
||||
// Revoke is invoked by the requester to undo their own pending submission
|
||||
// before any approver acts on it. The entity reverts as if the request had
|
||||
// been rejected, but the request status is 'revoked'. Runs in its own tx.
|
||||
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// decide is the shared kernel for Approve / Reject / Revoke. The decision
|
||||
// kind is derived from the (caller, request) relationship and the requested
|
||||
// final status:
|
||||
// - RequestStatusApproved: caller must NOT be requester; admin override or peer.
|
||||
// - RequestStatusRejected: same authorization rules as Approve.
|
||||
// - RequestStatusRevoked: caller MUST be requester.
|
||||
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
req, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Status != RequestStatusPending {
|
||||
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
|
||||
}
|
||||
|
||||
var decisionKind string
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved, RequestStatusRejected:
|
||||
kind, err := s.canApprove(ctx, tx, callerID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decisionKind = kind
|
||||
case RequestStatusRevoked:
|
||||
if callerID != req.RequestedBy {
|
||||
return ErrNotApprover
|
||||
}
|
||||
decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
|
||||
default:
|
||||
return fmt.Errorf("invalid final status %q", finalStatus)
|
||||
}
|
||||
|
||||
// Apply the lifecycle outcome to the entity.
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved:
|
||||
if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
|
||||
return err
|
||||
}
|
||||
case RequestStatusRejected, RequestStatusRevoked:
|
||||
if err := s.applyRevert(ctx, tx, req); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Update the request row.
|
||||
now := time.Now().UTC()
|
||||
var trimmedNote *string
|
||||
if n := strings.TrimSpace(note); n != "" {
|
||||
trimmedNote = &n
|
||||
}
|
||||
updateReqSQL := `UPDATE paliad.approval_requests
|
||||
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
|
||||
decision_note = $5, updated_at = $3
|
||||
WHERE id = $6`
|
||||
// For revoke, decided_by stays NULL (the requester didn't "decide" to
|
||||
// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
|
||||
// would block decided_by=requester anyway. NULL is correct.
|
||||
var decidedBy any
|
||||
var decisionKindArg any
|
||||
if finalStatus != RequestStatusRevoked {
|
||||
decidedBy = callerID
|
||||
decisionKindArg = decisionKind
|
||||
} else {
|
||||
decidedBy = nil
|
||||
decisionKindArg = nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, updateReqSQL,
|
||||
finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
|
||||
return fmt.Errorf("update approval_request: %w", err)
|
||||
}
|
||||
|
||||
// Audit emit.
|
||||
var verlaufKind string
|
||||
switch finalStatus {
|
||||
case RequestStatusApproved:
|
||||
verlaufKind = "approved"
|
||||
case RequestStatusRejected:
|
||||
verlaufKind = "rejected"
|
||||
case RequestStatusRevoked:
|
||||
verlaufKind = "revoked"
|
||||
}
|
||||
eventType := approvalEventType(req.EntityType, verlaufKind)
|
||||
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": req.ID.String(),
|
||||
"lifecycle_event": req.LifecycleEvent,
|
||||
req.EntityType + "_id": req.EntityID.String(),
|
||||
}
|
||||
if finalStatus != RequestStatusRevoked {
|
||||
meta["decision_kind"] = decisionKind
|
||||
}
|
||||
if trimmedNote != nil {
|
||||
meta["decision_note"] = *trimmedNote
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// canApprove enforces the strict-ladder gate plus the no-self-approval
|
||||
// rule. Returns the decision_kind ('peer' | 'admin_override') the caller
|
||||
// should record, or an error.
|
||||
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
|
||||
if callerID == req.RequestedBy {
|
||||
return "", ErrSelfApproval
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user == nil {
|
||||
return "", ErrNotApprover
|
||||
}
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return DecisionKindAdminOverride, nil
|
||||
}
|
||||
// Path-walk: check direct OR ancestor team membership with sufficient role.
|
||||
q := `SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(
|
||||
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level($3)
|
||||
)`
|
||||
var ok bool
|
||||
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
|
||||
return "", fmt.Errorf("authorization check: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return "", ErrNotApprover
|
||||
}
|
||||
return DecisionKindPeer, nil
|
||||
}
|
||||
|
||||
// applyApproved finalises the lifecycle on the entity row.
|
||||
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
|
||||
table := entityTableName(req.EntityType)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if req.LifecycleEvent == LifecycleDelete {
|
||||
// Hard-delete the entity. The approval_requests.entity_id reference
|
||||
// is a polymorphic uuid (no FK) so it survives the row going away.
|
||||
// pending_request_id on the entity has ON DELETE SET NULL but the
|
||||
// entity is the one being deleted, not the request — so this is
|
||||
// just a plain DELETE.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
|
||||
req.EntityID); err != nil {
|
||||
return fmt.Errorf("delete on approve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-delete approve = clear pending markers, set approved_by/at.
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = 'approved',
|
||||
pending_request_id = NULL,
|
||||
approved_by = $1,
|
||||
approved_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3`, table)
|
||||
if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
|
||||
return fmt.Errorf("clear pending on approve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyRevert undoes the in-flight change on the entity row, restoring it
|
||||
// from the request's pre_image jsonb. Used by both Reject and Revoke.
|
||||
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
|
||||
table := entityTableName(req.EntityType)
|
||||
|
||||
switch req.LifecycleEvent {
|
||||
case LifecycleCreate:
|
||||
// The entity should never have existed. Hard-delete.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
|
||||
req.EntityID); err != nil {
|
||||
return fmt.Errorf("delete on reject-create: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case LifecycleDelete:
|
||||
// We never deleted the entity (delete is stage-then-write); just
|
||||
// clear the pending markers so the row is fully alive again.
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s
|
||||
SET approval_status = CASE WHEN approval_status = 'pending'
|
||||
THEN 'approved' ELSE approval_status END,
|
||||
pending_request_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, table)
|
||||
if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
|
||||
return fmt.Errorf("clear pending on reject-delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case LifecycleUpdate, LifecycleComplete:
|
||||
// Restore pre_image fields, clear pending markers.
|
||||
preImage := map[string]any{}
|
||||
if len(req.PreImage) > 0 {
|
||||
if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
|
||||
return fmt.Errorf("unmarshal pre_image: %w", err)
|
||||
}
|
||||
}
|
||||
setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Always clear pending markers + revert approval_status.
|
||||
setClauses = append(setClauses,
|
||||
"approval_status = 'approved'",
|
||||
"pending_request_id = NULL",
|
||||
"updated_at = now()")
|
||||
args = append(args, req.EntityID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
|
||||
table, strings.Join(setClauses, ", "), len(args))
|
||||
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
||||
return fmt.Errorf("revert entity from pre_image: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
|
||||
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
|
||||
// keys are silently dropped to defend against malformed pre_image rows
|
||||
// (defence-in-depth: callers should already be sending only allowlisted
|
||||
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
|
||||
// fields be reverted).
|
||||
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
|
||||
var setClauses []string
|
||||
var args []any
|
||||
|
||||
add := func(col string, val any) {
|
||||
args = append(args, val)
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||
}
|
||||
|
||||
switch entityType {
|
||||
case EntityTypeDeadline:
|
||||
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
|
||||
if v, ok := preImage[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
// Complete-revert restores status='pending' + completed_at NULL.
|
||||
// We detect this branch by the presence of a status key; lifecycle
|
||||
// is the formal source but pre_image is what the caller stored.
|
||||
if v, ok := preImage["status"]; ok {
|
||||
add("status", v)
|
||||
}
|
||||
if _, ok := preImage["completed_at"]; ok {
|
||||
// Always NULL on revert — completion didn't really happen.
|
||||
args = append(args, nil)
|
||||
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
|
||||
}
|
||||
|
||||
case EntityTypeAppointment:
|
||||
for _, col := range []string{"start_at", "end_at"} {
|
||||
if v, ok := preImage[col]; ok {
|
||||
add(col, v)
|
||||
}
|
||||
}
|
||||
if _, ok := preImage["completed_at"]; ok {
|
||||
args = append(args, nil)
|
||||
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
|
||||
}
|
||||
return setClauses, args, nil
|
||||
}
|
||||
|
||||
// getRequestForUpdate locks an approval_requests row inside the tx for
|
||||
// decision processing.
|
||||
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
|
||||
var req models.ApprovalRequest
|
||||
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, requested_at, required_role,
|
||||
status, decided_by, decided_at, decision_kind, decision_note,
|
||||
created_at, updated_at
|
||||
FROM paliad.approval_requests
|
||||
WHERE id = $1
|
||||
FOR UPDATE`
|
||||
if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrRequestNotPending
|
||||
}
|
||||
return nil, fmt.Errorf("load request: %w", err)
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
// entityApprovalStatus reads the current approval_status on the entity
|
||||
// row. Returns "" if the row doesn't exist.
|
||||
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
|
||||
q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
|
||||
entityTableName(entityType))
|
||||
var status string
|
||||
if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("read approval_status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// entityTableName resolves the SQL table name for a given entity_type.
|
||||
// Internal helper — entityType comes from server-side constants, not user
|
||||
// input, so a panic on an unknown value is a programming error.
|
||||
func entityTableName(entityType string) string {
|
||||
switch entityType {
|
||||
case EntityTypeDeadline:
|
||||
return "deadlines"
|
||||
case EntityTypeAppointment:
|
||||
return "appointments"
|
||||
default:
|
||||
panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
|
||||
}
|
||||
}
|
||||
|
||||
// approvalEventType returns the project_events.event_type value for a
|
||||
// given (entity, lifecycle-step) pair. Step is one of "requested" |
|
||||
// "approved" | "rejected" | "revoked".
|
||||
func approvalEventType(entityType, step string) string {
|
||||
return entityType + "_approval_" + step
|
||||
}
|
||||
|
||||
// approvalDescription returns the short audit description string. Frontend
|
||||
// renders the localized version via translateEvent; this is the raw audit
|
||||
// row's description column, used as a fallback and for /admin/audit-log.
|
||||
func approvalDescription(step, requiredRole, lifecycle string) *string {
|
||||
d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
|
||||
return &d
|
||||
}
|
||||
|
||||
// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
|
||||
// either inside a calling tx (for consistency with concurrent writes) or
|
||||
// standalone for List endpoints.
|
||||
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
|
||||
if tx != nil {
|
||||
return tx
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
|
||||
// Defined here to avoid adding a public abstraction across the package.
|
||||
type sqlxQueryer interface {
|
||||
GetContext(ctx context.Context, dest any, query string, args ...any) error
|
||||
SelectContext(ctx context.Context, dest any, query string, args ...any) error
|
||||
QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
|
||||
}
|
||||
|
||||
// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
|
||||
// nil/empty maps so callers can pass it directly to a jsonb column without
|
||||
// branching at every call site.
|
||||
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
|
||||
if len(m) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Read paths — inbox + policy CRUD.
|
||||
// ============================================================================
|
||||
|
||||
// ApprovalRequestView is the inbox-friendly projection of an approval
|
||||
// request: the bare ApprovalRequest plus the contextual labels the inbox
|
||||
// needs to render a row without further fetches.
|
||||
type ApprovalRequestView struct {
|
||||
models.ApprovalRequest
|
||||
ProjectTitle string `db:"project_title" json:"project_title"`
|
||||
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
|
||||
RequesterName string `db:"requester_name" json:"requester_name"`
|
||||
RequesterEmail string `db:"requester_email" json:"requester_email"`
|
||||
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
|
||||
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
|
||||
}
|
||||
|
||||
const approvalRequestViewColumns = `
|
||||
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
|
||||
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
|
||||
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
|
||||
ar.created_at, ar.updated_at,
|
||||
p.title AS project_title,
|
||||
CASE WHEN ar.entity_type = 'deadline' THEN d.title
|
||||
WHEN ar.entity_type = 'appointment' THEN a.title
|
||||
END AS entity_title,
|
||||
COALESCE(ru.display_name, ru.email) AS requester_name,
|
||||
ru.email AS requester_email,
|
||||
du.display_name AS decider_name,
|
||||
du.email AS decider_email`
|
||||
|
||||
const approvalRequestViewJoins = `
|
||||
paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
JOIN paliad.users ru ON ru.id = ar.requested_by
|
||||
LEFT JOIN paliad.users du ON du.id = ar.decided_by
|
||||
LEFT JOIN paliad.deadlines d ON ar.entity_type = 'deadline' AND d.id = ar.entity_id
|
||||
LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`
|
||||
|
||||
// InboxFilter narrows the inbox listings.
|
||||
type InboxFilter struct {
|
||||
Status string // "" → no filter; otherwise one of RequestStatus*
|
||||
ProjectID *uuid.UUID
|
||||
EntityType string // "" → both
|
||||
Limit int // 0 → 100
|
||||
}
|
||||
|
||||
// ListPendingForApprover returns approval requests where the caller is
|
||||
// qualified to approve and is not the requester.
|
||||
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
conds := []string{
|
||||
"ar.status = 'pending'",
|
||||
"ar.requested_by <> $1",
|
||||
// Either caller is global_admin OR caller has eligible role on the project's path.
|
||||
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
|
||||
))`,
|
||||
}
|
||||
args := []any{callerID}
|
||||
if filter.ProjectID != nil {
|
||||
args = append(args, *filter.ProjectID)
|
||||
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
args = append(args, filter.EntityType)
|
||||
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins,
|
||||
strings.Join(conds, " AND "), len(args))
|
||||
|
||||
var out []ApprovalRequestView
|
||||
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list pending for approver: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListSubmittedByUser returns approval requests authored by the caller.
|
||||
// Status filter optional.
|
||||
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 100
|
||||
}
|
||||
conds := []string{"ar.requested_by = $1"}
|
||||
args := []any{callerID}
|
||||
if filter.Status != "" {
|
||||
args = append(args, filter.Status)
|
||||
conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
|
||||
}
|
||||
if filter.ProjectID != nil {
|
||||
args = append(args, *filter.ProjectID)
|
||||
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
args = append(args, filter.EntityType)
|
||||
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins,
|
||||
strings.Join(conds, " AND "), len(args))
|
||||
|
||||
var out []ApprovalRequestView
|
||||
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list submitted by user: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRequest returns one approval request hydrated for the inbox detail
|
||||
// view. Visibility is gated upstream by the handler (anyone with project
|
||||
// access can see the request).
|
||||
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
|
||||
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
|
||||
approvalRequestViewColumns, approvalRequestViewJoins)
|
||||
var v ApprovalRequestView
|
||||
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get approval request: %w", err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// PendingCountForUser returns how many requests await this user's approval.
|
||||
// Cheap query for the sidebar bell badge.
|
||||
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
|
||||
q := `SELECT COUNT(*)
|
||||
FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by <> $1
|
||||
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND paliad.approval_role_level(pt.role) >= paliad.approval_role_level(ar.required_role)
|
||||
))`
|
||||
var n int
|
||||
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
|
||||
return 0, fmt.Errorf("pending count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Policy CRUD — paliad.approval_policies.
|
||||
// ============================================================================
|
||||
|
||||
// ListPolicies returns the (up to 8) policy rows for a project. Caller
|
||||
// must already have project visibility.
|
||||
func (s *ApprovalService) ListPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
var out []models.ApprovalPolicy
|
||||
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
|
||||
return nil, fmt.Errorf("list approval policies: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
|
||||
// policy row. Caller must be global_admin (gate enforced at handler).
|
||||
func (s *ApprovalService) UpsertPolicy(ctx context.Context, projectID, callerID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
|
||||
if !IsValidRequiredRole(requiredRole) {
|
||||
return nil, fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
|
||||
}
|
||||
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
|
||||
return nil, fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
|
||||
}
|
||||
switch lifecycle {
|
||||
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
|
||||
}
|
||||
|
||||
q := `INSERT INTO paliad.approval_policies
|
||||
(project_id, entity_type, lifecycle_event, required_role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
|
||||
created_at, updated_at, created_by`
|
||||
var p models.ApprovalPolicy
|
||||
if err := s.db.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert approval policy: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// DeletePolicy removes a single (project, entity, lifecycle) policy row,
|
||||
// reverting that lifecycle event back to the no-approval-needed default.
|
||||
func (s *ApprovalService) DeletePolicy(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) error {
|
||||
q := `DELETE FROM paliad.approval_policies
|
||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
||||
if _, err := s.db.ExecContext(ctx, q, projectID, entityType, lifecycle); err != nil {
|
||||
return fmt.Errorf("delete approval policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
617
internal/services/approval_service_test.go
Normal file
617
internal/services/approval_service_test.go
Normal file
@@ -0,0 +1,617 @@
|
||||
package services
|
||||
|
||||
// Approval-service tests. Two layers:
|
||||
//
|
||||
// - Pure-Go: levelOf strict ladder + IsValidRequiredRole. No DB touch.
|
||||
// - Live-DB: the full submit→approve and submit→reject flows on real
|
||||
// paliad.deadlines / paliad.approval_requests rows. Skipped when
|
||||
// TEST_DATABASE_URL is unset, mirroring audit_service_test and
|
||||
// deadline_service_test.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Pure-Go tests.
|
||||
// ============================================================================
|
||||
|
||||
func TestLevelOf_StrictLadder(t *testing.T) {
|
||||
cases := []struct {
|
||||
role string
|
||||
want int
|
||||
}{
|
||||
{"lead", 5},
|
||||
{"of_counsel", 4},
|
||||
{"associate", 3},
|
||||
{"senior_pa", 2},
|
||||
{"pa", 1},
|
||||
{"local_counsel", 0},
|
||||
{"expert", 0},
|
||||
{"observer", 0},
|
||||
{"", 0},
|
||||
{"unknown", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.role, func(t *testing.T) {
|
||||
if got := levelOf(c.role); got != c.want {
|
||||
t.Errorf("levelOf(%q) = %d, want %d", c.role, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelOf_HigherSatisfiesLower(t *testing.T) {
|
||||
// "Anyone strictly above the required level satisfies it" — verify by
|
||||
// asserting the ladder is monotonic and partner > all PA tiers etc.
|
||||
if levelOf("lead") <= levelOf("associate") {
|
||||
t.Errorf("lead must outrank associate")
|
||||
}
|
||||
if levelOf("associate") <= levelOf("senior_pa") {
|
||||
t.Errorf("associate must outrank senior_pa")
|
||||
}
|
||||
if levelOf("senior_pa") <= levelOf("pa") {
|
||||
t.Errorf("senior_pa must outrank pa")
|
||||
}
|
||||
if levelOf("of_counsel") <= levelOf("associate") {
|
||||
t.Errorf("of_counsel must outrank associate")
|
||||
}
|
||||
// PA-required policy: anyone associate-or-above must satisfy.
|
||||
if levelOf("associate") < levelOf("pa") {
|
||||
t.Errorf("associate must satisfy a pa-required policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidRequiredRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
role string
|
||||
ok bool
|
||||
}{
|
||||
{"lead", true},
|
||||
{"of_counsel", true},
|
||||
{"associate", true},
|
||||
{"senior_pa", true},
|
||||
{"pa", true},
|
||||
{"local_counsel", false},
|
||||
{"expert", false},
|
||||
{"observer", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.role, func(t *testing.T) {
|
||||
if got := IsValidRequiredRole(c.role); got != c.ok {
|
||||
t.Errorf("IsValidRequiredRole(%q) = %v, want %v", c.role, got, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
}{
|
||||
{"deadline", "requested", "deadline_approval_requested"},
|
||||
{"deadline", "approved", "deadline_approval_approved"},
|
||||
{"deadline", "rejected", "deadline_approval_rejected"},
|
||||
{"deadline", "revoked", "deadline_approval_revoked"},
|
||||
{"appointment", "requested", "appointment_approval_requested"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := approvalEventType(c.entity, c.step); got != c.want {
|
||||
t.Errorf("approvalEventType(%q,%q) = %q, want %q",
|
||||
c.entity, c.step, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live-DB tests.
|
||||
// ============================================================================
|
||||
|
||||
// approvalTestEnv holds a configured ApprovalService + helpers tied to a
|
||||
// throwaway project / user pool. Caller cleans up via env.cleanup().
|
||||
type approvalTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
approvals *ApprovalService
|
||||
deadlines *DeadlineService
|
||||
users *UserService
|
||||
projects *ProjectService
|
||||
projectID uuid.UUID
|
||||
requester uuid.UUID
|
||||
approver uuid.UUID
|
||||
other uuid.UUID
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupApprovalTest(t *testing.T) *approvalTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, nil)
|
||||
approvals := NewApprovalService(pool, users)
|
||||
|
||||
// Seed two users + one project. The requester owns the deadline; the
|
||||
// approver is the other lead on the team. "other" has no role and is
|
||||
// used for the deadlock check (no qualified approver scenario).
|
||||
requesterID := uuid.New()
|
||||
approverID := uuid.New()
|
||||
otherID := uuid.New()
|
||||
|
||||
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
|
||||
ON CONFLICT (id) DO NOTHING`, id); err != nil {
|
||||
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
||||
VALUES ($1, $1::text || '@test.local', 'Test User', 'munich', 'standard')
|
||||
ON CONFLICT (id) DO NOTHING`, id); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
projectID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, title, status, created_by)
|
||||
VALUES ($1, 'project', 'Approval Test Project', 'active', $2)`,
|
||||
projectID, requesterID); err != nil {
|
||||
t.Fatalf("seed project: %v", err)
|
||||
}
|
||||
|
||||
// Add requester + approver to the project team. Requester=associate
|
||||
// (cannot approve associate-required policy), approver=lead (can).
|
||||
for _, m := range []struct {
|
||||
uid uuid.UUID
|
||||
role string
|
||||
}{
|
||||
{requesterID, "associate"},
|
||||
{approverID, "lead"},
|
||||
} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
projectID, m.uid, m.role); err != nil {
|
||||
t.Fatalf("seed project_teams: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
ctx := context.Background()
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.approval_requests WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.approval_policies WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
for _, id := range []uuid.UUID{requesterID, approverID, otherID} {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, id)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, id)
|
||||
}
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
return &approvalTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
approvals: approvals,
|
||||
deadlines: deadlines,
|
||||
users: users,
|
||||
projects: projects,
|
||||
projectID: projectID,
|
||||
requester: requesterID,
|
||||
approver: approverID,
|
||||
other: otherID,
|
||||
cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
// seedPolicy sets a policy on the env's project for one (entity, lifecycle).
|
||||
func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) {
|
||||
e.t.Helper()
|
||||
if _, err := e.approvals.UpsertPolicy(context.Background(),
|
||||
e.projectID, e.requester, entityType, lifecycle, requiredRole); err != nil {
|
||||
e.t.Fatalf("seed policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDeadline inserts a basic deadline row directly (bypassing the
|
||||
// service so we can test ApprovalService.Submit* in isolation). Returns
|
||||
// the deadline's ID.
|
||||
func (e *approvalTestEnv) seedDeadline(due time.Time) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
if _, err := e.pool.ExecContext(context.Background(),
|
||||
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by, approval_status)
|
||||
VALUES ($1, $2, 'Test Deadline', $3, 'manual', 'pending', $4, 'approved')`,
|
||||
id, e.projectID, due, e.requester); err != nil {
|
||||
e.t.Fatalf("seed deadline: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestApprovalService_NoPolicyIsNoop: with no policy, Submit* returns
|
||||
// (nil, nil) and the entity stays approval_status='approved'.
|
||||
func TestApprovalService_NoPolicyIsNoop(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if reqID != nil {
|
||||
t.Errorf("expected nil request id with no policy, got %v", reqID)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := env.pool.GetContext(ctx, &status,
|
||||
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if status != "approved" {
|
||||
t.Errorf("expected approval_status=approved, got %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_SubmitMarksPendingAndApproveClears: end-to-end happy
|
||||
// path. With a policy in place: submit → request row + entity pending →
|
||||
// approve → entity back to approved with approved_by set.
|
||||
func TestApprovalService_SubmitApproveCycle(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
// Submit (inside a tx, as DeadlineService would).
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline,
|
||||
map[string]any{"due_date": "2026-05-20"})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if reqID == nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("expected request id, got nil")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Entity is now pending.
|
||||
var status string
|
||||
if err := env.pool.GetContext(ctx, &status,
|
||||
`SELECT approval_status FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if status != "pending" {
|
||||
t.Errorf("after submit: approval_status=%q, want pending", status)
|
||||
}
|
||||
|
||||
// Self-approval blocks.
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.requester, ""); !errors.Is(err, ErrSelfApproval) {
|
||||
t.Errorf("self-approve: got %v, want ErrSelfApproval", err)
|
||||
}
|
||||
|
||||
// Approver (lead) signs off.
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.approver, "looks good"); err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
|
||||
// Entity flipped back to approved with approved_by populated.
|
||||
row := struct {
|
||||
Status string `db:"approval_status"`
|
||||
ApprovedBy *uuid.UUID `db:"approved_by"`
|
||||
}{}
|
||||
if err := env.pool.GetContext(ctx, &row,
|
||||
`SELECT approval_status, approved_by FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read post-approve: %v", err)
|
||||
}
|
||||
if row.Status != "approved" {
|
||||
t.Errorf("after approve: approval_status=%q, want approved", row.Status)
|
||||
}
|
||||
if row.ApprovedBy == nil || *row.ApprovedBy != env.approver {
|
||||
t.Errorf("after approve: approved_by=%v, want %v", row.ApprovedBy, env.approver)
|
||||
}
|
||||
|
||||
// Request row marked approved.
|
||||
var reqStatus string
|
||||
if err := env.pool.GetContext(ctx, &reqStatus,
|
||||
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
|
||||
t.Fatalf("read request status: %v", err)
|
||||
}
|
||||
if reqStatus != "approved" {
|
||||
t.Errorf("request status=%q, want approved", reqStatus)
|
||||
}
|
||||
|
||||
// Approving again fails (not pending anymore).
|
||||
if err := env.approvals.Approve(ctx, *reqID, env.approver, ""); !errors.Is(err, ErrRequestNotPending) {
|
||||
t.Errorf("re-approve: got %v, want ErrRequestNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RejectRevertsCreateAsDelete: rejecting a CREATE
|
||||
// request hard-deletes the entity (it never should have existed).
|
||||
func TestApprovalService_RejectCreateDeletes(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 7))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
if err := env.approvals.Reject(ctx, *reqID, env.approver, "wrong date"); err != nil {
|
||||
t.Fatalf("Reject: %v", err)
|
||||
}
|
||||
|
||||
// Entity row is gone.
|
||||
var n int
|
||||
if err := env.pool.GetContext(ctx, &n,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("count deadline: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("after reject-create: deadline still exists (count=%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RejectUpdateRestoresPreImage: rejecting an UPDATE
|
||||
// reverts the date fields back to the snapshotted pre_image values.
|
||||
func TestApprovalService_RejectUpdateRestoresPreImage(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleUpdate, "associate")
|
||||
|
||||
originalDue := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
deadlineID := env.seedDeadline(originalDue)
|
||||
|
||||
// Simulate an update: set due to 2026-06-15, then submit.
|
||||
newDue := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines SET due_date = $1 WHERE id = $2`,
|
||||
newDue, deadlineID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("UPDATE pre-submit: %v", err)
|
||||
}
|
||||
preImage := map[string]any{"due_date": "2026-06-01"}
|
||||
payload := map[string]any{"due_date": "2026-06-15"}
|
||||
reqID, err := env.approvals.SubmitUpdate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, preImage, payload)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitUpdate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Reject — due_date should snap back to 2026-06-01.
|
||||
if err := env.approvals.Reject(ctx, *reqID, env.approver, ""); err != nil {
|
||||
t.Fatalf("Reject: %v", err)
|
||||
}
|
||||
|
||||
var got time.Time
|
||||
if err := env.pool.GetContext(ctx, &got,
|
||||
`SELECT due_date FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("read due_date: %v", err)
|
||||
}
|
||||
if !got.Equal(originalDue) {
|
||||
t.Errorf("after reject-update: due_date=%v, want %v", got, originalDue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_NoQualifiedApprover: when only the requester would
|
||||
// qualify, Submit returns ErrNoQualifiedApprover.
|
||||
func TestApprovalService_NoQualifiedApprover(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Demote the approver to observer (level 0 = ineligible). Now requester
|
||||
// (associate) is the only on-team user with any role, and observer
|
||||
// can't approve.
|
||||
if _, err := env.pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_teams SET role='observer' WHERE project_id=$1 AND user_id=$2`,
|
||||
env.projectID, env.approver); err != nil {
|
||||
t.Fatalf("demote approver: %v", err)
|
||||
}
|
||||
|
||||
// Make sure no global_admin exists in our test pool — promote-and-revert
|
||||
// any existing global_admin so the deadlock kicks in. We can't safely do
|
||||
// that without affecting other tests, so use a project where the
|
||||
// requester is the only person + setup excludes other users.
|
||||
// Easier approach: temporarily set requester to global_admin, then test
|
||||
// against a different "pretend requester" — but we want the case where
|
||||
// our seeded requester is the only candidate.
|
||||
//
|
||||
// Approach: use UpsertPolicy to set 'lead' as required role. Then no
|
||||
// project team member (associate, observer) qualifies. The deadlock
|
||||
// check still passes if any global_admin exists firmwide (Q8 escape
|
||||
// hatch), so we accept this test may be a no-op on pools with admins.
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "lead")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
// Count global admins; if any exist (e.g. m or tester) the deadlock
|
||||
// path can't fire — skip with a note.
|
||||
var nAdmins int
|
||||
if err := env.pool.GetContext(ctx, &nAdmins,
|
||||
`SELECT COUNT(*) FROM paliad.users WHERE global_role='global_admin' AND id <> $1`,
|
||||
env.requester); err != nil {
|
||||
t.Fatalf("count admins: %v", err)
|
||||
}
|
||||
if nAdmins > 0 {
|
||||
t.Skip("global_admin exists in test pool — deadlock fallback hides ErrNoQualifiedApprover; covered indirectly via canApprove unit checks")
|
||||
}
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if !errors.Is(err, ErrNoQualifiedApprover) {
|
||||
t.Errorf("got %v, want ErrNoQualifiedApprover", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_RevokeRevertsAndMarksRevoked: requester revokes
|
||||
// their own pending → entity reverts, request status='revoked'.
|
||||
func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// Non-requester can't revoke.
|
||||
if err := env.approvals.Revoke(ctx, *reqID, env.approver); !errors.Is(err, ErrNotApprover) {
|
||||
t.Errorf("non-requester revoke: got %v, want ErrNotApprover", err)
|
||||
}
|
||||
|
||||
// Requester revokes — succeeds. Create lifecycle = entity gets deleted.
|
||||
if err := env.approvals.Revoke(ctx, *reqID, env.requester); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := env.pool.GetContext(ctx, &n,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("after revoke-create: entity should be gone (count=%d)", n)
|
||||
}
|
||||
|
||||
var reqStatus string
|
||||
if err := env.pool.GetContext(ctx, &reqStatus,
|
||||
`SELECT status FROM paliad.approval_requests WHERE id = $1`, *reqID); err != nil {
|
||||
t.Fatalf("read request: %v", err)
|
||||
}
|
||||
if reqStatus != "revoked" {
|
||||
t.Errorf("request status=%q, want revoked", reqStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
|
||||
func TestApprovalService_PolicyCRUD(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Upsert two rows.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil {
|
||||
t.Fatalf("upsert 1: %v", err)
|
||||
}
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeAppointment, LifecycleUpdate, "lead"); err != nil {
|
||||
t.Fatalf("upsert 2: %v", err)
|
||||
}
|
||||
|
||||
// List.
|
||||
got, err := env.approvals.ListPolicies(ctx, env.projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("list returned %d rows, want 2", len(got))
|
||||
}
|
||||
|
||||
// Re-upsert the first to a different role.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "lead"); err != nil {
|
||||
t.Fatalf("re-upsert: %v", err)
|
||||
}
|
||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
||||
for _, p := range got {
|
||||
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "lead" {
|
||||
t.Errorf("after re-upsert: required_role=%q, want lead", p.RequiredRole)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid role rejected.
|
||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := env.approvals.DeletePolicy(ctx, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
||||
if len(got) != 1 {
|
||||
t.Errorf("after delete: %d rows, want 1", len(got))
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,15 @@ func formatAppointment(t *models.Appointment) string {
|
||||
if t.EndAt != nil {
|
||||
w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
|
||||
}
|
||||
w("SUMMARY:" + escapeText(t.Title))
|
||||
// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
|
||||
// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
|
||||
// reflect the unverified state honestly — silence on a pending change
|
||||
// would be a worse outcome than visible-but-flagged.
|
||||
summary := t.Title
|
||||
if t.ApprovalStatus == "pending" {
|
||||
summary = "[PENDING] " + t.Title
|
||||
}
|
||||
w("SUMMARY:" + escapeText(summary))
|
||||
if t.Description != nil && *t.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(*t.Description))
|
||||
}
|
||||
|
||||
@@ -30,17 +30,28 @@ type DeadlineService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
eventTypes *EventTypeService
|
||||
approvals *ApprovalService
|
||||
}
|
||||
|
||||
// NewDeadlineService wires the service. eventTypes may be nil in tests
|
||||
// that don't exercise the event_types junction; production wires it.
|
||||
// NewDeadlineService wires the service. eventTypes and approvals may be
|
||||
// nil in tests that don't exercise those features; production wires both.
|
||||
func NewDeadlineService(db *sqlx.DB, projects *ProjectService, eventTypes *EventTypeService) *DeadlineService {
|
||||
return &DeadlineService{db: db, projects: projects, eventTypes: eventTypes}
|
||||
}
|
||||
|
||||
// SetApprovalService wires the optional 4-eye approval workflow
|
||||
// (t-paliad-138). When set, every Create/Update/Complete/Delete consults
|
||||
// paliad.approval_policies and may stage the change as a pending request
|
||||
// instead of applying it directly. main.go wires this in production;
|
||||
// tests that don't exercise the workflow can leave it unset.
|
||||
func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at`
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
// CreateDeadlineInput is the payload for Create / bulk create entries.
|
||||
type CreateDeadlineInput struct {
|
||||
@@ -61,13 +72,19 @@ type CreateDeadlineInput struct {
|
||||
// UpdateDeadlineInput is the partial-update payload for PATCH.
|
||||
// EventTypeIDs uses pointer-to-slice semantics: nil = leave existing
|
||||
// attachments untouched; non-nil (including empty) = replace.
|
||||
//
|
||||
// ProjectID, when non-nil, moves the deadline under a different project
|
||||
// (t-paliad-140). The caller must be able to see the new project; the
|
||||
// service emits a deadline_project_changed audit row on both the old and
|
||||
// new project so each side's Verlauf still shows the move.
|
||||
type UpdateDeadlineInput 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"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
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"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -186,6 +203,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
p.reference AS project_reference,
|
||||
p.title AS project_title,
|
||||
p.type AS project_type,
|
||||
@@ -378,11 +396,23 @@ func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projectID uuid
|
||||
}
|
||||
|
||||
// Update applies a partial update to a Deadline.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if any date-bearing field actually changes
|
||||
// (due_date / original_due_date / warning_date — Q4 allowlist), the change
|
||||
// is applied immediately AND parked in paliad.approval_requests with
|
||||
// approval_status='pending' on the row. Approver flips it to 'approved'
|
||||
// or rejects (which reverts the row from the snapshotted pre_image).
|
||||
//
|
||||
// Refuses to mutate a row whose approval_status is already 'pending'
|
||||
// (a different request is in flight) — caller must wait or revoke.
|
||||
func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
|
||||
current, err := s.GetByID(ctx, userID, deadlineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
@@ -393,6 +423,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
next++
|
||||
}
|
||||
|
||||
// Capture pre_image / payload for the date-bearing allowlist as fields
|
||||
// are about to be set. Only populated when a field actually changes —
|
||||
// SubmitUpdate skips the approval flow entirely when nothing in the
|
||||
// allowlist moved.
|
||||
preImage := map[string]any{}
|
||||
payload := map[string]any{}
|
||||
|
||||
if input.Title != nil {
|
||||
title := strings.TrimSpace(*input.Title)
|
||||
if title == "" {
|
||||
@@ -408,6 +445,10 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
|
||||
}
|
||||
if !due.Equal(current.DueDate) {
|
||||
preImage["due_date"] = current.DueDate.Format("2006-01-02")
|
||||
payload["due_date"] = *input.DueDate
|
||||
}
|
||||
appendSet("due_date", due)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
@@ -429,6 +470,22 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). Visibility on the destination is enforced
|
||||
// the same way as on Create — a GetByID round-trip through ProjectService
|
||||
// returns ErrNotVisible if the user can't see the target. Same-project
|
||||
// "moves" are silently dropped so a UI that always sends project_id in
|
||||
// the PATCH payload doesn't churn the Verlauf.
|
||||
var movedFromProject *uuid.UUID
|
||||
if input.ProjectID != nil && *input.ProjectID != current.ProjectID {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("project_id", *input.ProjectID)
|
||||
from := current.ProjectID
|
||||
movedFromProject = &from
|
||||
}
|
||||
|
||||
if len(sets) == 0 && input.EventTypeIDs == nil {
|
||||
return current, nil
|
||||
}
|
||||
@@ -460,12 +517,53 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
// Description carries value-only payload (the deadline title); frontend
|
||||
// renders via the localized event.description.deadline_updated template.
|
||||
// Same pattern below for completed/reopened/deleted/created.
|
||||
//
|
||||
// Audit shape for project moves (t-paliad-140): emit
|
||||
// deadline_project_changed on both old and new project rows so each
|
||||
// side's Verlauf still shows the move (the row is gone from the old
|
||||
// project, but its history shouldn't be). If the same PATCH also
|
||||
// touched other fields, the new project additionally records a
|
||||
// deadline_updated covering those edits.
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEventWithMeta(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr,
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
return nil, err
|
||||
if movedFromProject != nil {
|
||||
moveMeta := map[string]any{
|
||||
"deadline_id": deadlineID,
|
||||
"from_project_id": *movedFromProject,
|
||||
"to_project_id": *input.ProjectID,
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
|
||||
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID,
|
||||
"deadline_project_changed", "Deadline project changed", descPtr, moveMeta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Did the PATCH touch anything beyond the project move?
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
|
||||
input.EventTypeIDs != nil
|
||||
if otherFieldsTouched {
|
||||
auditProject := current.ProjectID
|
||||
if movedFromProject != nil {
|
||||
auditProject = *input.ProjectID
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, auditProject, userID, "deadline_updated", "Deadline updated", descPtr,
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Approval gate (Q4 = date-bearing allowlist only). When preImage is
|
||||
// empty (no allowlisted field changed), SubmitUpdate is a no-op.
|
||||
if s.approvals != nil {
|
||||
if _, err := s.approvals.SubmitUpdate(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update deadline: %w", err)
|
||||
}
|
||||
@@ -473,6 +571,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
|
||||
// Complete marks a Deadline as completed.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if a (project, deadline, complete) policy
|
||||
// applies, the row is flipped to status='completed' immediately AND
|
||||
// parked in approval_requests with approval_status='pending'. Reject
|
||||
// reverts (status back to 'pending', completed_at cleared).
|
||||
func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
|
||||
current, err := s.GetByID(ctx, userID, deadlineID)
|
||||
if err != nil {
|
||||
@@ -481,6 +584,9 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
if current.Status == "completed" {
|
||||
return current, nil
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -501,6 +607,17 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
map[string]any{"deadline_id": deadlineID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"status": current.Status,
|
||||
"completed_at": nil,
|
||||
}
|
||||
if _, err := s.approvals.SubmitComplete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit complete: %w", err)
|
||||
}
|
||||
@@ -586,7 +703,13 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete hard-deletes a Deadline. Partner/admin only.
|
||||
// Delete removes a Deadline. Partner/admin only.
|
||||
//
|
||||
// Approval gate (t-paliad-138): if a (project, deadline, delete) policy
|
||||
// applies, this is the one stage-then-write exception in the otherwise
|
||||
// write-then-approve architecture. The row stays alive with
|
||||
// approval_status='pending' until the approver hard-deletes (approve) or
|
||||
// restores it (reject).
|
||||
func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UUID) error {
|
||||
user, err := s.users().GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -602,6 +725,9 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -609,14 +735,35 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("delete deadline: %w", err)
|
||||
// Approval gate runs FIRST (before the actual delete). If a policy
|
||||
// applies, SubmitDelete returns a non-nil request id and we skip the
|
||||
// hard delete — the row is now flagged pending. The approver's
|
||||
// Approve flips it to a real delete; their Reject clears the marker.
|
||||
var pendingRequest *uuid.UUID
|
||||
if s.approvals != nil {
|
||||
preImage := map[string]any{
|
||||
"title": current.Title,
|
||||
"due_date": current.DueDate.Format("2006-01-02"),
|
||||
"status": current.Status,
|
||||
}
|
||||
req, err := s.approvals.SubmitDelete(ctx, tx, current.ProjectID, deadlineID, userID, EntityTypeDeadline, preImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingRequest = req
|
||||
}
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
|
||||
return err
|
||||
|
||||
if pendingRequest == nil {
|
||||
// No policy applied — proceed with the immediate hard-delete.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
|
||||
return fmt.Errorf("delete deadline: %w", err)
|
||||
}
|
||||
desc := current.Title
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -750,6 +897,21 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
|
||||
map[string]any{"deadline_id": id}); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
// Approval gate: if a (project, deadline, create) policy applies, this
|
||||
// flips the just-inserted row's approval_status to 'pending' and emits
|
||||
// a 'deadline_approval_requested' audit event. No-op when no policy is
|
||||
// configured or when the approval service isn't wired (test harness).
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{
|
||||
"title": desc,
|
||||
"due_date": input.DueDate,
|
||||
}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ type EventListItem struct {
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
||||
|
||||
// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
|
||||
// (default), "pending" (in-flight 4-eye request — pill rendered on
|
||||
// every list surface), or "legacy" (pre-4-eye row, no pill).
|
||||
ApprovalStatus *string `json:"approval_status,omitempty"`
|
||||
|
||||
// Deadline-only.
|
||||
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
||||
Status *string `json:"status,omitempty"`
|
||||
@@ -195,6 +200,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
due := d.DueDate.Format("2006-01-02")
|
||||
status := d.Status
|
||||
src := d.Source
|
||||
approvalStatus := d.ApprovalStatus
|
||||
|
||||
return EventListItem{
|
||||
Type: "deadline",
|
||||
@@ -207,6 +213,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
ProjectTitle: &pt,
|
||||
ProjectType: &ptype,
|
||||
CreatedBy: d.CreatedBy,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
DueDate: &due,
|
||||
Status: &status,
|
||||
CompletedAt: d.CompletedAt,
|
||||
@@ -222,6 +229,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
// projectAppointment projects an AppointmentWithProject row into the union shape.
|
||||
func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
||||
startCopy := a.StartAt
|
||||
approvalStatus := a.ApprovalStatus
|
||||
return EventListItem{
|
||||
Type: "appointment",
|
||||
ID: a.ID,
|
||||
@@ -233,6 +241,7 @@ func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
||||
ProjectTitle: a.ProjectTitle,
|
||||
ProjectType: a.ProjectType,
|
||||
CreatedBy: a.CreatedBy,
|
||||
ApprovalStatus: &approvalStatus,
|
||||
StartAt: &startCopy,
|
||||
EndAt: a.EndAt,
|
||||
Location: a.Location,
|
||||
|
||||
@@ -242,6 +242,11 @@ type digestRow struct {
|
||||
ProjectReference string `db:"project_reference"`
|
||||
ProjectTitle string `db:"project_title"`
|
||||
IsLead bool `db:"is_lead"`
|
||||
// ApprovalStatus (t-paliad-138). When 'pending', the digest renders
|
||||
// the row with a "[PENDING] " title prefix so the user can't miss
|
||||
// that the deadline is unverified — silence on a pending change is
|
||||
// the worst outcome.
|
||||
ApprovalStatus string `db:"approval_status"`
|
||||
// OwnerEscalationContactID is the owner's optional escalation override:
|
||||
// non-NULL diverts overdue/DRINGEND escalation away from global_admins
|
||||
// to the named user. Used by visibleForCategory to decide whether the
|
||||
@@ -300,6 +305,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
|
||||
SELECT f.id AS deadline_id,
|
||||
f.title AS title,
|
||||
f.due_date AS due_date,
|
||||
f.approval_status AS approval_status,
|
||||
f.created_by AS owner_id,
|
||||
COALESCE(own.display_name, '') AS owner_name,
|
||||
own.escalation_contact_id AS owner_escalation_contact_id,
|
||||
@@ -590,11 +596,23 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
|
||||
|
||||
// Bucket rows by category for the template. Within a category, rows
|
||||
// arrive sorted by due_date already (SQL ORDER BY).
|
||||
//
|
||||
// Pending-approval rows (t-paliad-138) get a "[PENDING] " title prefix
|
||||
// so the recipient can't miss that the deadline is unverified — silence
|
||||
// on a pending change is the worst outcome for a 4-eye system.
|
||||
var overdue, dueToday, dueWarning []map[string]any
|
||||
pendingCount := 0
|
||||
for _, r := range rows {
|
||||
title := r.Title
|
||||
isPending := r.ApprovalStatus == ApprovalStatusPending
|
||||
if isPending {
|
||||
title = "[PENDING] " + title
|
||||
pendingCount++
|
||||
}
|
||||
item := map[string]any{
|
||||
"DueDate": r.DueDate.Format("2006-01-02"),
|
||||
"Title": r.Title,
|
||||
"Title": title,
|
||||
"IsPending": isPending,
|
||||
"ProjectReference": r.ProjectReference,
|
||||
"ProjectTitle": r.ProjectTitle,
|
||||
"OwnerName": r.OwnerName,
|
||||
@@ -627,6 +645,13 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
|
||||
"DueWarningCount": len(dueWarning),
|
||||
"OpenTotal": len(dueToday) + len(dueWarning),
|
||||
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
|
||||
// PendingCount > 0 → templates can render a banner like
|
||||
// "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
|
||||
// /inbox" above the digest body. Available even when the
|
||||
// template doesn't currently use it (forward-compat, no
|
||||
// existing-template breakage).
|
||||
"PendingCount": pendingCount,
|
||||
"InboxURL": fmt.Sprintf("%s/inbox", s.baseURL),
|
||||
}
|
||||
return s.mail.SendTemplate(TemplateData{
|
||||
To: u.Email,
|
||||
|
||||
Reference in New Issue
Block a user