Move document_records duty-slot writes into docgov, project ledger heads through docgov/freedoc ports, and workspace deliverable I/O through DeliverableStore so arch-check baseline drops to sql_write:1 path:13.
195 lines
6.0 KiB
Go
195 lines
6.0 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"chat-agent-system/internal/docduty"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ClearDutySlots clears reviewer/approver columns on document_records for the given revocations.
|
|
// Author columns are never written. Skips duty_locked rows.
|
|
func ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
|
|
if tx == nil || projectID <= 0 {
|
|
return nil
|
|
}
|
|
for _, rev := range revs {
|
|
q, ok := clearDutySlotSQL(rev.Duty)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, q, projectID, rev.UserID); err != nil {
|
|
return fmt.Errorf("docgov: clear %s for user %d: %w", rev.Duty, rev.UserID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clearDutySlotSQL(duty string) (string, bool) {
|
|
switch duty {
|
|
case docduty.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 AND NOT duty_locked
|
|
`, true
|
|
case docduty.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 AND NOT duty_locked
|
|
`, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// DutySlotWriter adapts ClearDutySlots for authz injection (authz must not import docgov).
|
|
type DutySlotWriter struct{}
|
|
|
|
func (DutySlotWriter) ClearSlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
|
|
return ClearDutySlots(ctx, tx, projectID, revs)
|
|
}
|
|
|
|
type storedDocDuties struct {
|
|
DocID string
|
|
Author docduty.Holder
|
|
Reviewer docduty.Holder
|
|
Approver docduty.Holder
|
|
}
|
|
|
|
// BackfillDocumentDuties aligns controlled document duty columns to assigned project duties.
|
|
// Empty assigned slots are not invented or cleared.
|
|
func BackfillDocumentDuties(ctx context.Context, db docduty.DB, projectID int64, assigned docduty.Binding) (int, error) {
|
|
if db == nil || projectID <= 0 {
|
|
return 0, nil
|
|
}
|
|
members, creator, err := docduty.LoadProjectMembersDB(ctx, db, projectID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
memberIDs := docduty.ActiveMemberIDs(members, creator)
|
|
|
|
rows, err := db.Query(ctx, `
|
|
SELECT doc_id,
|
|
COALESCE(author_id, 0), COALESCE(author_name, ''),
|
|
COALESCE(reviewer_id, 0), COALESCE(reviewer_name, ''),
|
|
COALESCE(approver_id, 0), COALESCE(approver_name, '')
|
|
FROM document_records
|
|
WHERE project_id = $1 AND COALESCE(task_key, '') <> '' AND NOT duty_locked
|
|
`, projectID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("docgov: list documents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
pending := make([]storedDocDuties, 0)
|
|
for rows.Next() {
|
|
var rec storedDocDuties
|
|
if err := rows.Scan(
|
|
&rec.DocID,
|
|
&rec.Author.UserID, &rec.Author.Name,
|
|
&rec.Reviewer.UserID, &rec.Reviewer.Name,
|
|
&rec.Approver.UserID, &rec.Approver.Name,
|
|
); err != nil {
|
|
return 0, err
|
|
}
|
|
current := docduty.Binding{Author: rec.Author, Reviewer: rec.Reviewer, Approver: rec.Approver}
|
|
next, changed := docduty.HealBinding(current, assigned, memberIDs)
|
|
if !changed {
|
|
continue
|
|
}
|
|
rec.Author, rec.Reviewer, rec.Approver = next.Author, next.Reviewer, next.Approver
|
|
pending = append(pending, rec)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
updated := 0
|
|
for _, rec := range pending {
|
|
tag, err := db.Exec(ctx, `
|
|
UPDATE document_records
|
|
SET author_id = $2, author_name = $3,
|
|
reviewer_id = $4, reviewer_name = $5,
|
|
approver_id = $6, approver_name = $7,
|
|
updated_at = now()
|
|
WHERE doc_id = $1 AND project_id = $8
|
|
`, rec.DocID, nullIfZeroDuty(rec.Author.UserID), rec.Author.Name,
|
|
nullIfZeroDuty(rec.Reviewer.UserID), rec.Reviewer.Name,
|
|
nullIfZeroDuty(rec.Approver.UserID), rec.Approver.Name,
|
|
projectID)
|
|
if err != nil {
|
|
return updated, fmt.Errorf("docgov: backfill %s: %w", rec.DocID, err)
|
|
}
|
|
if tag.RowsAffected() > 0 {
|
|
updated++
|
|
}
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func nullIfZeroDuty(id int64) any {
|
|
if id <= 0 {
|
|
return nil
|
|
}
|
|
return id
|
|
}
|
|
|
|
// HealProjectDocuments runs project advisory lock, duty resolve, and document_records backfill in one txn.
|
|
func HealProjectDocuments(ctx context.Context, pool *pgxpool.Pool, projectID int64) (docduty.Binding, int, error) {
|
|
if pool == nil || projectID <= 0 {
|
|
return docduty.Binding{}, 0, nil
|
|
}
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return docduty.Binding{}, 0, fmt.Errorf("docgov: begin heal: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
if err := docduty.AcquireProjectDutyLock(ctx, tx, projectID); err != nil {
|
|
return docduty.Binding{}, 0, err
|
|
}
|
|
assigned, n, err := healProjectDocumentsTx(ctx, tx, projectID)
|
|
if err != nil {
|
|
return docduty.Binding{}, 0, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return docduty.Binding{}, 0, fmt.Errorf("docgov: commit heal: %w", err)
|
|
}
|
|
return assigned, n, nil
|
|
}
|
|
|
|
func healProjectDocumentsTx(ctx context.Context, tx pgx.Tx, projectID int64) (docduty.Binding, int, error) {
|
|
assigned, err := docduty.ResolveProjectTx(ctx, tx, projectID, docduty.RequestDuties{})
|
|
if err != nil {
|
|
return docduty.Binding{}, 0, err
|
|
}
|
|
n, err := (Repository{}).BackfillDuties(ctx, tx, projectID, assigned)
|
|
if err != nil {
|
|
return docduty.Binding{}, 0, err
|
|
}
|
|
return assigned, n, nil
|
|
}
|
|
|
|
// HealProjectDocuments aligns controlled document duties for a project under the governance engine.
|
|
func (e *GovernanceEngine) HealProjectDocuments(ctx context.Context, projectID int64) (docduty.Binding, int, error) {
|
|
if e == nil {
|
|
return docduty.Binding{}, 0, nil
|
|
}
|
|
return HealProjectDocuments(ctx, e.pool, projectID)
|
|
}
|
|
|
|
// ClearDutySlots is the repository write port for reviewer/approver slot revocation.
|
|
func (Repository) ClearDutySlots(ctx context.Context, tx pgx.Tx, projectID int64, revs []docduty.SlotRevocation) error {
|
|
return ClearDutySlots(ctx, tx, projectID, revs)
|
|
}
|
|
|
|
// BackfillDuties is the repository write port for duty column healing.
|
|
func (Repository) BackfillDuties(ctx context.Context, tx pgx.Tx, projectID int64, assigned docduty.Binding) (int, error) {
|
|
return BackfillDocumentDuties(ctx, tx, projectID, assigned)
|
|
}
|