feat(t-paliad-148) commit 4/6: reminder + deadline + derivation cleanup — pt.role → pt.responsibility

reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.

deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.

derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.

Build + vet clean. Pure-Go tests pass.
This commit is contained in:
m
2026-05-07 21:50:31 +02:00
parent e6937d232e
commit 9184e9b0ef
3 changed files with 19 additions and 11 deletions

View File

@@ -626,7 +626,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
// Reopen flips a completed Deadline back to pending and clears completed_at.
// Authorization: global admin OR a member of the Project (or any ancestor)
// with project_teams.role IN ('admin','lead'). Other authenticated viewers
// with project_teams.responsibility = 'lead'. Other authenticated viewers
// can see the Deadline but cannot reopen it.
func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, deadlineID)
@@ -667,11 +667,17 @@ func (s *DeadlineService) Reopen(ctx context.Context, userID, deadlineID uuid.UU
// assertCanAdminProject returns nil if the user may perform admin-level
// actions on the Project (reopen, future bulk ops). Pass-conditions:
// - global users.role = 'admin', or
// - direct/inherited project_teams membership with role IN ('admin','lead').
// - users.global_role = 'global_admin', or
// - direct/inherited project_teams membership with responsibility = 'lead'.
//
// Returns ErrForbidden otherwise. Visibility must be checked separately
// (callers do this via GetByID before calling here).
//
// t-paliad-148: switched from `role IN ('admin','lead')` to
// `responsibility = 'lead'`. The legacy 'admin' value was already dead
// since t-paliad-051 (project_teams.role never had an 'admin' value;
// only the legacy users.role enum did, before it was split into
// global_role).
func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, projectID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
@@ -692,13 +698,13 @@ func (s *DeadlineService) assertCanAdminProject(ctx context.Context, userID, pro
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
WHERE p.id = $1
AND pt.user_id = $2
AND pt.role IN ('admin', 'lead')
AND pt.responsibility = 'lead'
)`, projectID, userID)
if err != nil {
return fmt.Errorf("check project admin: %w", err)
}
if !ok {
return fmt.Errorf("%w: only project admins/leads can reopen Deadlines", ErrForbidden)
return fmt.Errorf("%w: only project leads can reopen Deadlines", ErrForbidden)
}
return nil
}

View File

@@ -316,7 +316,8 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
WHERE target.id = $1
),
descendant_rows AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
pt.added_by, pt.created_at,
d.title AS source_title
FROM paliad.project_teams pt
JOIN descendants d ON d.id = pt.project_id
@@ -333,18 +334,19 @@ func (s *DerivationService) ListDescendantStaffed(ctx context.Context, callerID,
) AS rn
FROM descendant_rows dr
)
SELECT d.id, d.project_id, d.user_id, d.role,
SELECT d.id, d.project_id, d.user_id, d.role, d.responsibility,
true AS inherited,
d.added_by, d.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
u.profession AS user_profession,
d.project_id AS inherited_from_id,
d.source_title AS inherited_from_title
FROM dedup d
JOIN paliad.users u ON u.id = d.user_id
WHERE d.rn = 1
ORDER BY d.role, u.display_name`,
ORDER BY d.responsibility, u.display_name`,
projectID)
if err != nil {
return nil, fmt.Errorf("list descendant-staffed: %w", err)

View File

@@ -296,7 +296,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
// Audience predicates:
// * owner of the deadline — f.created_by = U
// * project lead anywhere on the path — pt.role = 'lead'
// * project lead anywhere on the path — pt.responsibility = 'lead'
// * owner's escalation contact (override) — own.escalation_contact_id = U
// * global admin AND owner has no override — fallback channel
// Per-category recipient rules (e.g. leads don't get overdue) are applied
@@ -314,7 +314,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
) AS is_lead
FROM paliad.deadlines f
@@ -327,7 +327,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.responsibility = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)
OR own.escalation_contact_id = $2