Per the owner's spec: each social connection gets publishGovernance in its fields — unset = manual publish (today), auto = dispatch on approval, admin1/admin_majority/admin_consensus = a publish-phase chatgov poll (subject draft_publish, its own open-poll slot, no collision with review gates). Dispatch holds until the publish poll passes (E3 pre-dispatch check); auto dispatches on approval. Includes migration 033, the PATCH /connections/:id/publish-governance route (chatgov.manage), and 19+31 new tests. Implementation by the branding-panel process (claude/opus), lint and integration fixes here.
54 lines
3.8 KiB
TypeScript
54 lines
3.8 KiB
TypeScript
import { ConflictException } from '@nestjs/common';
|
|
import { canonicalPostHash } from '@tactical-media-broadcast-hub/publishing-execution';
|
|
import type { PoolClient } from 'pg';
|
|
import { ApprovalGateService } from './approval-gate.service.js';
|
|
import { onDraftApproved } from './publish-governance.js';
|
|
import type { DraftPost } from './publishing.service.js';
|
|
|
|
/** E2's transactional publishing seam. A System audit actor delegates to a
|
|
* live Owner/Admin approval principal so the existing dispatch authorization
|
|
* continues to validate a real, revocable membership; no permission bypass. */
|
|
export async function transitionPollDraft(client: PoolClient, input: {
|
|
workspaceId: string; draftId: string; pollId: string; passed: boolean;
|
|
text?: string; reason: string; escalate?: boolean;
|
|
}): Promise<void> {
|
|
const current = (await client.query('SELECT * FROM drafts WHERE id = $1 FOR UPDATE', [input.draftId])).rows[0];
|
|
if (!current || !['editing', 'in_review'].includes(String(current.state))) throw new ConflictException('poll subject is no longer reviewable');
|
|
if (input.escalate) return;
|
|
if (input.passed && input.text !== undefined) {
|
|
// Separate writes are necessary: the revision trigger voids approval
|
|
// whenever content changes. Approve only the resulting revision/hash.
|
|
await client.query("UPDATE drafts SET post = jsonb_set(post, '{text}', to_jsonb($2::text)), updated_at = now() WHERE id = $1", [input.draftId, input.text]);
|
|
}
|
|
const draft = (await client.query('SELECT * FROM drafts WHERE id = $1', [input.draftId])).rows[0]!;
|
|
let reviewer: string | null = null;
|
|
if (input.passed) {
|
|
reviewer = (await client.query(`SELECT m.user_id FROM memberships m
|
|
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'approval.grant'
|
|
WHERE m.workspace_id = $1 AND m.status = 'active' AND m.role IN ('Owner', 'Admin')
|
|
ORDER BY CASE m.role WHEN 'Owner' THEN 0 ELSE 1 END, m.user_id LIMIT 1`, [input.workspaceId])).rows[0]?.user_id as string | undefined ?? null;
|
|
if (!reviewer) throw new ConflictException('poll approval needs a live Owner/Admin authorizing principal');
|
|
const gate = new ApprovalGateService();
|
|
const evaluation = await gate.evaluate(client, input.workspaceId, input.draftId, draft.post as DraftPost, reviewer);
|
|
if (evaluation.blocked.length) throw new ConflictException({ code: 'OUTSIDE_POLICY', message: 'poll passed but E1 still blocks the draft', verdicts: evaluation.blocked });
|
|
await gate.recordFiredInTxn(client, input.workspaceId, input.draftId, reviewer, evaluation.verdicts);
|
|
}
|
|
await client.query(`UPDATE drafts SET state = $2, reviewer_user_id = $3,
|
|
approved_revision = CASE WHEN $2 = 'approved' THEN revision ELSE NULL END,
|
|
approved_hash = $4, approved_at = CASE WHEN $2 = 'approved' THEN now() ELSE NULL END,
|
|
review_reason = $5, updated_at = now() WHERE id = $1`,
|
|
[input.draftId, input.passed ? 'approved' : 'rejected', reviewer,
|
|
input.passed ? canonicalPostHash(draft.post as DraftPost) : null, input.reason]);
|
|
await client.query("SELECT audit_append($1, NULL, 'System', $2, 'draft', $3, $4)", [input.workspaceId,
|
|
input.passed ? 'draft.approved' : 'draft.rejected', input.draftId,
|
|
{ actor: `system:chat-poll-gate:${input.pollId}`, pollId: input.pollId,
|
|
authorizingUserId: reviewer, from: current.state, to: input.passed ? 'approved' : 'rejected', reason: input.reason }]);
|
|
// Execute-on-vote for the target connection's publish governance (auto →
|
|
// schedule now; gate modes → open the publish poll). The scheduler sweep
|
|
// starts the workflow for the committed pending row.
|
|
if (input.passed) {
|
|
await onDraftApproved(client, { workspaceId: input.workspaceId, draftId: input.draftId,
|
|
providerMetadata: (draft.post as DraftPost).providerMetadata ?? {}, preferredUserId: reviewer,
|
|
actor: { userId: null, role: 'System' } });
|
|
}
|
|
}
|