Wire intent→coordinator DocResolver confirm cards→action_executor kinds for comment, reopen, pending-supplements query, and real submit-review execution.
210 lines
7.0 KiB
Go
210 lines
7.0 KiB
Go
package docgov
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
PendingSupplementPending = "pending"
|
|
PendingSupplementDigested = "digested"
|
|
PendingSupplementDiscarded = "discarded"
|
|
)
|
|
|
|
// PendingSupplement is one locked-period fact supplement deferred for later digestion.
|
|
type PendingSupplement struct {
|
|
ID int64 `json:"id"`
|
|
DocID string `json:"doc_id"`
|
|
ProjectID int64 `json:"project_id"`
|
|
SourceSummary string `json:"source_summary"`
|
|
ImpactReason string `json:"impact_reason"`
|
|
InitiatorUserID int64 `json:"initiator_user_id"`
|
|
BatchID string `json:"batch_id,omitempty"`
|
|
Status string `json:"status"`
|
|
DigestedAt *time.Time `json:"digested_at,omitempty"`
|
|
DecidedBy int64 `json:"decided_by,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// PendingSupplementNotify is invoked after pending rows are marked digested.
|
|
type PendingSupplementNotify func(ctx context.Context, doc *DocumentRecord, pending []PendingSupplement)
|
|
|
|
// RecordPendingSupplementInput captures a supplement that cannot be applied while locked.
|
|
type RecordPendingSupplementInput struct {
|
|
DocID string
|
|
ProjectID int64
|
|
InitiatorUserID int64
|
|
SourceSummary string
|
|
ImpactReason string
|
|
BatchID string
|
|
}
|
|
|
|
// RecordPendingSupplement persists a deferred supplement for a locked document.
|
|
func (e *GovernanceEngine) RecordPendingSupplement(ctx context.Context, in RecordPendingSupplementInput) (*PendingSupplement, error) {
|
|
if e == nil {
|
|
return nil, fmt.Errorf("docgov: engine required")
|
|
}
|
|
docID := strings.TrimSpace(in.DocID)
|
|
if docID == "" || in.ProjectID <= 0 {
|
|
return nil, fmt.Errorf("docgov: pending supplement requires doc_id and project_id")
|
|
}
|
|
now := time.Now().UTC()
|
|
row := &PendingSupplement{
|
|
DocID: docID, ProjectID: in.ProjectID, InitiatorUserID: in.InitiatorUserID,
|
|
SourceSummary: strings.TrimSpace(in.SourceSummary),
|
|
ImpactReason: strings.TrimSpace(in.ImpactReason),
|
|
BatchID: strings.TrimSpace(in.BatchID),
|
|
Status: PendingSupplementPending,
|
|
CreatedAt: now,
|
|
}
|
|
if e.pool == nil {
|
|
return row, nil
|
|
}
|
|
err := e.pool.QueryRow(ctx, `
|
|
INSERT INTO document_pending_supplements (
|
|
doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id, status, created_at, updated_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8)
|
|
RETURNING id, created_at
|
|
`, row.DocID, row.ProjectID, row.SourceSummary, row.ImpactReason, row.InitiatorUserID, row.BatchID, row.Status, now).
|
|
Scan(&row.ID, &row.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docgov: record pending supplement: %w", err)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// ListPendingSupplements returns pending rows for a document.
|
|
func (e *GovernanceEngine) ListPendingSupplements(ctx context.Context, docID string) ([]PendingSupplement, error) {
|
|
if e == nil || e.pool == nil {
|
|
return nil, nil
|
|
}
|
|
docID = strings.TrimSpace(docID)
|
|
if docID == "" {
|
|
return nil, nil
|
|
}
|
|
rows, err := e.pool.Query(ctx, `
|
|
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
|
|
status, digested_at, decided_by, created_at
|
|
FROM document_pending_supplements
|
|
WHERE doc_id = $1 AND status = $2
|
|
ORDER BY created_at ASC, id ASC
|
|
`, docID, PendingSupplementPending)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]PendingSupplement, 0)
|
|
for rows.Next() {
|
|
var p PendingSupplement
|
|
if err := rows.Scan(
|
|
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
|
|
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListPendingSupplementsByProject returns undigested pending rows for a project (optional doc filter).
|
|
func (e *GovernanceEngine) ListPendingSupplementsByProject(ctx context.Context, projectID int64, docID string) ([]PendingSupplement, error) {
|
|
if e == nil || e.pool == nil || projectID <= 0 {
|
|
return nil, nil
|
|
}
|
|
docID = strings.TrimSpace(docID)
|
|
var rows pgx.Rows
|
|
var err error
|
|
if docID != "" {
|
|
rows, err = e.pool.Query(ctx, `
|
|
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
|
|
status, digested_at, decided_by, created_at
|
|
FROM document_pending_supplements
|
|
WHERE project_id = $1 AND doc_id = $2 AND status = $3
|
|
ORDER BY created_at ASC, id ASC
|
|
`, projectID, docID, PendingSupplementPending)
|
|
} else {
|
|
rows, err = e.pool.Query(ctx, `
|
|
SELECT id, doc_id, project_id, source_summary, impact_reason, initiator_user_id, batch_id,
|
|
status, digested_at, decided_by, created_at
|
|
FROM document_pending_supplements
|
|
WHERE project_id = $1 AND status = $2
|
|
ORDER BY created_at ASC, id ASC
|
|
`, projectID, PendingSupplementPending)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]PendingSupplement, 0)
|
|
for rows.Next() {
|
|
var p PendingSupplement
|
|
if err := rows.Scan(
|
|
&p.ID, &p.DocID, &p.ProjectID, &p.SourceSummary, &p.ImpactReason, &p.InitiatorUserID, &p.BatchID,
|
|
&p.Status, &p.DigestedAt, &p.DecidedBy, &p.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DigestPendingSupplements marks all pending rows digested when a document returns to drafting.
|
|
// It returns the digested rows so callers can notify the author.
|
|
func (e *GovernanceEngine) DigestPendingSupplements(ctx context.Context, docID string, decidedBy int64) ([]PendingSupplement, error) {
|
|
if e == nil {
|
|
return nil, fmt.Errorf("docgov: engine required")
|
|
}
|
|
docID = strings.TrimSpace(docID)
|
|
if docID == "" {
|
|
return nil, nil
|
|
}
|
|
pending, err := e.ListPendingSupplements(ctx, docID)
|
|
if err != nil || len(pending) == 0 {
|
|
return pending, err
|
|
}
|
|
if e.pool == nil {
|
|
for i := range pending {
|
|
pending[i].Status = PendingSupplementDigested
|
|
now := time.Now().UTC()
|
|
pending[i].DigestedAt = &now
|
|
pending[i].DecidedBy = decidedBy
|
|
}
|
|
return pending, nil
|
|
}
|
|
now := time.Now().UTC()
|
|
_, err = e.pool.Exec(ctx, `
|
|
UPDATE document_pending_supplements
|
|
SET status = $2, digested_at = $3, decided_by = $4, updated_at = $3
|
|
WHERE doc_id = $1 AND status = $5
|
|
`, docID, PendingSupplementDigested, now, decidedBy, PendingSupplementPending)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docgov: digest pending supplements: %w", err)
|
|
}
|
|
for i := range pending {
|
|
pending[i].Status = PendingSupplementDigested
|
|
pending[i].DigestedAt = &now
|
|
pending[i].DecidedBy = decidedBy
|
|
}
|
|
return pending, nil
|
|
}
|
|
|
|
// DigestPendingOnReturnToDrafting digests pending supplements after a transition back to drafting.
|
|
func (e *GovernanceEngine) DigestPendingOnReturnToDrafting(ctx context.Context, doc *DocumentRecord, actorID int64, notify PendingSupplementNotify) {
|
|
if e == nil || doc == nil || doc.CurrentState != StateDrafting {
|
|
return
|
|
}
|
|
pending, err := e.DigestPendingSupplements(ctx, doc.DocID, actorID)
|
|
if err != nil || len(pending) == 0 {
|
|
return
|
|
}
|
|
if notify != nil {
|
|
notify(ctx, doc, pending)
|
|
}
|
|
}
|