Empty assigned heal now clears only exited or non-member reviewer/approver holders and never blanks author. Explicit reconcile uses the pre-update duty plus a project advisory lock so viewer demotion and transfers are not left to generic heal. Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
3.3 KiB
Go
114 lines
3.3 KiB
Go
package docduty
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// MemberDutyChange 成员职责/在任状态变更,供显式撤销文档槽位。
|
|
type MemberDutyChange struct {
|
|
UserID int64
|
|
PreviousDuty string
|
|
NextDuty string
|
|
Exited bool
|
|
PriorHolderIDs map[string]int64
|
|
}
|
|
|
|
// SlotRevocation 从受控文档上撤销某用户的审核/批准槽位;永不针对 author。
|
|
type SlotRevocation struct {
|
|
UserID int64
|
|
Duty string
|
|
}
|
|
|
|
// PriorDutyHolders 更新前在任成员的职责持有者。
|
|
func PriorDutyHolders(members []Member) map[string]int64 {
|
|
out := make(map[string]int64, 3)
|
|
for _, m := range members {
|
|
if !isActiveMember(m) {
|
|
continue
|
|
}
|
|
switch m.DocumentDuty {
|
|
case DutyAuthor, DutyReviewer, DutyApprover:
|
|
out[m.DocumentDuty] = m.UserID
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PlanSlotRevocations 按更新前 duty 显式计算要撤销的审核/批准槽位。
|
|
// reviewer/approver → viewer/其他职责、职责转移、退出成员都会产生撤销;author 永不进入结果。
|
|
func PlanSlotRevocations(change MemberDutyChange) []SlotRevocation {
|
|
seen := make(map[string]struct{}, 4)
|
|
out := make([]SlotRevocation, 0, 2)
|
|
add := func(userID int64, duty string) {
|
|
if userID <= 0 || (duty != DutyReviewer && duty != DutyApprover) {
|
|
return
|
|
}
|
|
key := fmt.Sprintf("%d:%s", userID, duty)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, SlotRevocation{UserID: userID, Duty: duty})
|
|
}
|
|
if change.Exited || change.PreviousDuty != change.NextDuty {
|
|
add(change.UserID, change.PreviousDuty)
|
|
}
|
|
if change.NextDuty == DutyReviewer || change.NextDuty == DutyApprover {
|
|
if prior := change.PriorHolderIDs[change.NextDuty]; prior > 0 && prior != change.UserID {
|
|
add(prior, change.NextDuty)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ReconcileMemberDuty 在项目级 advisory lock 下按更新前 duty 清空对应槽位,再 heal 新职责。
|
|
func ReconcileMemberDuty(ctx context.Context, pool *pgxpool.Pool, projectID int64, change MemberDutyChange) error {
|
|
if pool == nil || projectID <= 0 {
|
|
return nil
|
|
}
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("docduty: begin reconcile: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, projectID); err != nil {
|
|
return fmt.Errorf("docduty: lock project %d: %w", projectID, err)
|
|
}
|
|
for _, rev := range PlanSlotRevocations(change) {
|
|
q, ok := clearDutySlotSQL(rev.Duty)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
|
|
return fmt.Errorf("docduty: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("docduty: commit reconcile: %w", err)
|
|
}
|
|
_, _, err = HealProjectDocuments(ctx, pool, projectID)
|
|
return err
|
|
}
|
|
|
|
func clearDutySlotSQL(duty string) (string, bool) {
|
|
switch duty {
|
|
case DutyReviewer:
|
|
return `
|
|
UPDATE document_records
|
|
SET reviewer_id = NULL, reviewer_name = '', updated_at = now()
|
|
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND reviewer_id = $2
|
|
`, true
|
|
case DutyApprover:
|
|
return `
|
|
UPDATE document_records
|
|
SET approver_id = NULL, approver_name = '', updated_at = now()
|
|
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND approver_id = $2
|
|
`, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|