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) } }