feat: per-connection publish governance — auto, 1-admin, majority, consensus
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.
This commit is contained in:
parent
f2eb14956e
commit
b89dea5c93
26 changed files with 1637 additions and 31 deletions
40
apps/api/migrations/033_publish_governance.sql
Normal file
40
apps/api/migrations/033_publish_governance.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- Per-connection publish governance (publish-phase gate).
|
||||
--
|
||||
-- connections.fields.publishGovernance selects what happens once a draft is
|
||||
-- approved: unset = manual publish (today), 'auto' = dispatch on approval,
|
||||
-- 'admin1' | 'admin_majority' | 'admin_consensus' = a SECOND chatgov poll
|
||||
-- (subject_kind 'draft_publish', kind yes_no) must pass before the publish
|
||||
-- is scheduled. The setting itself lives in the established fields jsonb;
|
||||
-- this migration only teaches `polls` about the publish-phase subject.
|
||||
--
|
||||
-- publish_connection_id the destination the publish poll releases to
|
||||
-- publish_request {publishAt, requiredPermission} captured at hold
|
||||
-- time; the requester is polls.created_by and stays
|
||||
-- the scheduling principal (re-checked at claim).
|
||||
|
||||
ALTER TABLE polls DROP CONSTRAINT polls_subject_kind_check;
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_subject_kind_check CHECK
|
||||
(subject_kind IN ('draft', 'chat_post', 'none', 'draft_publish'));
|
||||
|
||||
ALTER TABLE polls
|
||||
ADD COLUMN publish_connection_id uuid,
|
||||
ADD COLUMN publish_request jsonb CHECK (publish_request IS NULL OR jsonb_typeof(publish_request) = 'object');
|
||||
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_publish_connection_fk
|
||||
FOREIGN KEY (publish_connection_id, workspace_id) REFERENCES connections (id, workspace_id)
|
||||
ON DELETE NO ACTION ON UPDATE RESTRICT;
|
||||
|
||||
ALTER TABLE polls ADD CONSTRAINT polls_publish_subject_check CHECK (
|
||||
(subject_kind = 'draft_publish') = (publish_connection_id IS NOT NULL)
|
||||
AND (subject_kind <> 'draft_publish' OR (draft_id IS NOT NULL AND publish_request IS NOT NULL))
|
||||
);
|
||||
|
||||
-- One open REVIEW gate per draft stays index-enforced; publish gates get
|
||||
-- their own slot per (draft, destination) so they never collide with it.
|
||||
DROP INDEX polls_open_one_per_draft;
|
||||
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
|
||||
WHERE state = 'open' AND draft_id IS NOT NULL AND subject_kind <> 'draft_publish';
|
||||
CREATE UNIQUE INDEX polls_open_one_per_publish ON polls (workspace_id, draft_id, publish_connection_id)
|
||||
WHERE state = 'open' AND subject_kind = 'draft_publish';
|
||||
CREATE INDEX polls_ws_publish ON polls (workspace_id, draft_id, publish_connection_id, created_at DESC)
|
||||
WHERE subject_kind = 'draft_publish';
|
||||
|
|
@ -6,6 +6,7 @@ import { scrub } from '../audit/audit.service.js';
|
|||
import { draftDestinationAccounts } from '../publishing/approval-gate.service.js';
|
||||
import type { DraftPost } from '../publishing/publishing.service.js';
|
||||
import { transitionPollDraft } from '../publishing/poll-approval.js';
|
||||
import { releasePublishPoll } from '../publishing/publish-governance.js';
|
||||
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
|
||||
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
|
||||
|
||||
|
|
@ -18,7 +19,10 @@ interface PollRow {
|
|||
anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
|
||||
winner_option_id: string | null; eligible_user_ids: string[] | null;
|
||||
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
|
||||
/** 'draft_publish' = publish-phase gate (publish-governance.ts); else a review/chat poll. */
|
||||
subject_kind: string; publish_connection_id: string | null; publish_request: unknown;
|
||||
}
|
||||
const isPublishPoll = (poll: PollRow): boolean => poll.subject_kind === 'draft_publish';
|
||||
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
|
||||
interface Member { user_id: string; role: string; can_vote: boolean }
|
||||
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
|
||||
|
|
@ -301,7 +305,12 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
|
|||
Object.assign(poll, claimed.rows[0]);
|
||||
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
|
||||
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
|
||||
if (poll.draft_id && !poll.rule.dryRun) {
|
||||
if (poll.draft_id && !poll.rule.dryRun && isPublishPoll(poll)) {
|
||||
// Publish phase: the draft stays approved either way; a pass schedules
|
||||
// the held publish (execute-on-vote), a fail/expiry/veto leaves it
|
||||
// re-publishable and is recorded blocked below.
|
||||
if (evaluation.state === 'passed') await releasePublishPoll(client, poll);
|
||||
} else if (poll.draft_id && !poll.rule.dryRun) {
|
||||
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
|
||||
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
||||
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
|
||||
|
|
@ -316,14 +325,15 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
|
|||
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
|
||||
if (!poll.draft_id) return;
|
||||
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
||||
for (const accountId of draftDestinationAccounts(draft.post as DraftPost)) {
|
||||
const accounts = isPublishPoll(poll) && poll.publish_connection_id ? [poll.publish_connection_id] : draftDestinationAccounts(draft.post as DraftPost);
|
||||
for (const accountId of accounts) {
|
||||
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
|
||||
await client.query(`INSERT INTO gate_decisions
|
||||
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||
VALUES ($1,$2,$3,'E2',$4,$5,$6,$7,$8,$9)`,
|
||||
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
|
||||
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
|
||||
{ ...evaluation, pollState: poll.state }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
||||
{ ...evaluation, pollState: poll.state, ...(isPublishPoll(poll) ? { phase: 'publish' } : {}) }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
||||
}
|
||||
}
|
||||
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
|
||||
|
|
@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
|
|||
Object.assign(poll, changed.rows[0]);
|
||||
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
|
||||
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
|
||||
if (poll.rule.dryRun || !poll.draft_id) return;
|
||||
// A stale publish gate never re-arms or touches the draft: the approval it
|
||||
// answered is gone, and a new approval opens a new publish gate.
|
||||
if (poll.rule.dryRun || !poll.draft_id || isPublishPoll(poll)) return;
|
||||
if (poll.rule.resetOnChange) {
|
||||
await openPollInTransaction(client, poll.workspace_id, {
|
||||
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
|
||||
|
|
@ -375,6 +387,6 @@ export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string,
|
|||
if (!draftDestinationAccounts(post).length) return;
|
||||
const rule = await draftPollRule(client, { post });
|
||||
if (!rule) return;
|
||||
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open'", [draftId])).rowCount) return;
|
||||
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open' AND subject_kind <> 'draft_publish'", [draftId])).rowCount) return;
|
||||
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { withTenant } from "../db/client.js";
|
|||
import type { SealedCredential } from "../vault/vault.js";
|
||||
import type { ConnectionSummary } from "./connections.service.js";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { readPublishGovernance, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
|
||||
export interface ConnectionStore {
|
||||
|
|
@ -15,6 +16,8 @@ export interface ConnectionStore {
|
|||
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
|
||||
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
||||
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
||||
/** Set (or clear with null) connection.fields.publishGovernance; chatgov.manage holders only. */
|
||||
setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void>;
|
||||
}
|
||||
|
||||
export class PgConnectionStore implements ConnectionStore {
|
||||
|
|
@ -65,7 +68,9 @@ export class PgConnectionStore implements ConnectionStore {
|
|||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
|
||||
if (reconnect) {
|
||||
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
|
||||
// Governance settings are not provider identity: a reconnect keeps them.
|
||||
const updated = await client.query(`UPDATE connections SET fields = $2::jsonb || jsonb_strip_nulls(jsonb_build_object('publishGovernance', fields->'publishGovernance')),
|
||||
disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id`, [connection.id, fields, connection.provider]);
|
||||
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
|
||||
} else {
|
||||
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
|
||||
|
|
@ -106,6 +111,22 @@ export class PgConnectionStore implements ConnectionStore {
|
|||
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
|
||||
}
|
||||
}
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||
await this.transaction(session.workspaceId, async (client) => {
|
||||
// Serialized with membership revocation like every governance write.
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'chatgov.manage'`, [session.userId, session.authzEpoch]);
|
||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const current = await client.query("SELECT fields FROM connections WHERE id = $1 AND disconnected_at IS NULL FOR UPDATE", [id]);
|
||||
if (!current.rowCount) throw new NotFoundException("Connection not found");
|
||||
const previous = readPublishGovernance(current.rows[0]?.fields);
|
||||
await client.query(
|
||||
"UPDATE connections SET fields = CASE WHEN $2::text IS NULL THEN fields - 'publishGovernance' ELSE fields || jsonb_build_object('publishGovernance', $2::text) END WHERE id = $1",
|
||||
[id, mode],
|
||||
);
|
||||
await client.query("SELECT audit_append($1,$2,$3,'connection.publish_governance','connection',$4,$5)", [session.workspaceId, session.userId, session.role, id, { from: previous, to: mode }]);
|
||||
});
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
await this.transaction(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
|
||||
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
|
||||
import { CHATGOV_PERMISSIONS } from "../chatgov/policy.js";
|
||||
import { PUBLISH_GOVERNANCE_MODES, type PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
@Controller("connections")
|
||||
export class ConnectionsController {
|
||||
|
|
@ -74,6 +76,19 @@ export class ConnectionsController {
|
|||
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish governance for one connection: auto | admin1 | admin_majority |
|
||||
* admin_consensus, or null for manual publishing. Deciding who must sign
|
||||
* off a publish is a governance rule, so it needs chatgov.manage.
|
||||
*/
|
||||
@Patch(":connectionId/publish-governance")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission(CHATGOV_PERMISSIONS.manage)
|
||||
async publishGovernance(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Body() body: unknown) {
|
||||
const input = requireObject(body, "publish governance");
|
||||
return this.connections.setPublishGovernance(requireSession(request), parseId(id), parsePublishGovernance(input.publishGovernance));
|
||||
}
|
||||
|
||||
@Delete(":connectionId")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
|
|
@ -82,3 +97,10 @@ export class ConnectionsController {
|
|||
return { removed: true, token };
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublishGovernance(value: unknown): PublishGovernance | null {
|
||||
if (value === null) return null;
|
||||
const mode = PUBLISH_GOVERNANCE_MODES.find((candidate) => candidate === value);
|
||||
if (mode === undefined) throw new BadRequestException(`publishGovernance must be null or one of: ${PUBLISH_GOVERNANCE_MODES.join(", ")}`);
|
||||
return mode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { RbacGuard } from "../rbac/rbac.guard.js";
|
|||
import { logger } from "../telemetry/logger.js";
|
||||
import { ConnectionsController } from "./connections.controller.js";
|
||||
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
|
||||
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
|
||||
import { RedisPendingStore } from "./pending.js";
|
||||
import { MemoryPendingRedis } from "./testing.js";
|
||||
|
|
@ -50,6 +51,14 @@ class MemoryConnections implements ConnectionStore {
|
|||
this.rows.delete(id);
|
||||
this.audit.push({ action: "connection.remove" });
|
||||
}
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<void> {
|
||||
await this.assertAuthorized(session);
|
||||
const row = await this.get(session.workspaceId, id);
|
||||
const fields = { ...row.connection.fields };
|
||||
delete fields.publishGovernance;
|
||||
row.connection.fields = mode === null ? fields : { ...fields, publishGovernance: mode };
|
||||
this.audit.push({ action: "connection.publish_governance" });
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
const row = await this.get(workspaceId, id);
|
||||
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
|
||||
|
|
@ -333,6 +342,21 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
|
|||
bridgeConfigured = false;
|
||||
});
|
||||
|
||||
it("sets, validates and clears per-connection publish governance", async () => {
|
||||
await finish(await begin());
|
||||
const id = (await listed())[0]?.id ?? "";
|
||||
const set = await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "admin_majority" });
|
||||
expect(set.status).toBe(200);
|
||||
expect(await set.json()).toEqual({ connectionId: id, publishGovernance: "admin_majority" });
|
||||
expect((await listed())[0]?.fields.publishGovernance).toBe("admin_majority");
|
||||
expect(store.audit.at(-1)?.action).toBe("connection.publish_governance");
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: "everyone" })).status).toBe(400);
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", {})).status).toBe(400);
|
||||
expect((await api("/not-a-uuid/publish-governance", "PATCH", { publishGovernance: "auto" })).status).toBe(400);
|
||||
expect((await api(`/${id}/publish-governance`, "PATCH", { publishGovernance: null })).status).toBe(200);
|
||||
expect((await listed())[0]?.fields).not.toHaveProperty("publishGovernance");
|
||||
});
|
||||
|
||||
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
|
||||
await finish(await begin());
|
||||
const row = (await listed())[0];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { connectionRegistry } from "./connector-registry.js";
|
|||
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
|
||||
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
|
||||
import { oauthFetch } from "./oauth-fetch.js";
|
||||
import type { PublishGovernance } from "../publishing/publish-governance.js";
|
||||
|
||||
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
|
||||
export interface ConnectionSummary {
|
||||
|
|
@ -214,6 +215,11 @@ export class ConnectionsService {
|
|||
}
|
||||
return rows;
|
||||
}
|
||||
/** Per-connection publish governance (publish-governance.ts); null = manual. */
|
||||
async setPublishGovernance(session: SessionPayload, id: string, mode: PublishGovernance | null): Promise<{ connectionId: string; publishGovernance: PublishGovernance | null }> {
|
||||
await this.deps.store.setPublishGovernance(session, id, mode);
|
||||
return { connectionId: id, publishGovernance: mode };
|
||||
}
|
||||
async remove(session: SessionPayload, id: string): Promise<string> {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
await this.deps.store.remove(session, id, async (stored) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { PublishingExecutionRepository, assertPublishingSchema } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
import { PublishingExecutionRepository } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
|
||||
/**
|
||||
* chatgov P1 gates against a live PostgreSQL (chatgov-design.md §7 P1 row):
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export class ApprovalGateService {
|
|||
);
|
||||
adminApproval = membership.rows.some((row) => APPROVAL_GATE_ADMIN_ROLES.includes(String(row.role)));
|
||||
const poll = await client.query<{ state: string }>(
|
||||
"SELECT state FROM polls WHERE draft_id = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
"SELECT state FROM polls WHERE draft_id = $1 AND subject_kind <> 'draft_publish' ORDER BY created_at DESC LIMIT 1",
|
||||
[draftId],
|
||||
);
|
||||
pollState = String(poll.rows[0]?.state ?? "none");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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
|
||||
|
|
@ -42,4 +43,12 @@ export async function transitionPollDraft(client: PoolClient, input: {
|
|||
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' } });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
282
apps/api/src/publishing/publish-governance.integration.test.ts
Normal file
282
apps/api/src/publishing/publish-governance.integration.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { ConflictException, ForbiddenException } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Pool, type PoolClient } from "pg";
|
||||
import { PublishingExecutionRepository, assertPublishingSchema, type AttemptRef } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
|
||||
/**
|
||||
* Per-connection publish governance against a live PostgreSQL:
|
||||
* - auto: a console approval AND a passing proposal poll (E2) each schedule
|
||||
* the publish on the unchanged durable path
|
||||
* - admin1 / admin_majority / admin_consensus: approval opens the publish
|
||||
* poll, the publish route holds (202 body), the pass schedules it
|
||||
* - fail / expire / veto: the draft stays approved, the gate row is blocked
|
||||
* with reason codes, and a re-publish opens a fresh poll
|
||||
* - E3 (real DispatchGateService): a gated destination dispatches only once
|
||||
* its publish poll passed
|
||||
*
|
||||
* Owns its workspace in the chatgov IT database (CHATGOV_IT_DATABASE_URL +
|
||||
* CHATGOV_IT_SUPER_DATABASE_URL, migrated through 033). Skips clean.
|
||||
*/
|
||||
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
|
||||
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
|
||||
|
||||
type Modules = {
|
||||
publishing: typeof import("./publishing.service.js");
|
||||
polls: typeof import("../chatgov/poll.service.js");
|
||||
store: typeof import("../connections/connection-store.js");
|
||||
dispatchGate: typeof import("../../../scheduler/src/activities/dispatch-gate.js");
|
||||
db: typeof import("../db/client.js");
|
||||
};
|
||||
|
||||
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("publish governance (live DB)", () => {
|
||||
let m: Modules;
|
||||
let ownerPool: Pool;
|
||||
let appPool: Pool;
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
||||
let ws: string;
|
||||
let ownerId: string;
|
||||
let admin2: string;
|
||||
let admin3: string;
|
||||
let editorId: string;
|
||||
const conn: Record<"auto" | "admin1" | "admin_majority" | "admin_consensus" | "manual", string> = { auto: "", admin1: "", admin_majority: "", admin_consensus: "", manual: "" };
|
||||
const starts: AttemptRef[] = [];
|
||||
|
||||
async function inTenant<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await ownerPool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]);
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATABASE_URL = appUrl;
|
||||
process.env.SUPER_DATABASE_URL = ownerUrl;
|
||||
m = {
|
||||
publishing: await import("./publishing.service.js"),
|
||||
polls: await import("../chatgov/poll.service.js"),
|
||||
store: await import("../connections/connection-store.js"),
|
||||
dispatchGate: await import("../../../scheduler/src/activities/dispatch-gate.js"),
|
||||
db: await import("../db/client.js"),
|
||||
};
|
||||
ownerPool = new Pool({ connectionString: ownerUrl });
|
||||
appPool = new Pool({ connectionString: appUrl });
|
||||
const c = await ownerPool.connect();
|
||||
try {
|
||||
ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["pubgov", `pubgov-${suffix}`])).rows[0].id as string;
|
||||
const user = async (name: string): Promise<string> => (await c.query(
|
||||
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
[`pubgov:${name}:${suffix}`, `pubgov-${name}-${suffix}`, `pubgov-${name}-${suffix}@example.com`, name],
|
||||
)).rows[0].id as string;
|
||||
ownerId = await user("owner");
|
||||
admin2 = await user("admin2");
|
||||
admin3 = await user("admin3");
|
||||
editorId = await user("editor");
|
||||
for (const [id, role] of [[ownerId, "Owner"], [admin2, "Admin"], [admin3, "Admin"], [editorId, "Editor"]] as const) {
|
||||
await c.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, $3, 'active')", [ws, id, role]);
|
||||
}
|
||||
} finally {
|
||||
c.release();
|
||||
}
|
||||
await inTenant(async (client) => {
|
||||
for (const mode of Object.keys(conn) as Array<keyof typeof conn>) {
|
||||
const fields = mode === "manual" ? { handle: "manual" } : { handle: mode, publishGovernance: mode };
|
||||
conn[mode] = (await client.query("INSERT INTO connections (workspace_id, provider, channel_id, fields) VALUES ($1, 'mastodon', $2, $3) RETURNING id",
|
||||
[ws, `pubgov-${mode}`, fields])).rows[0].id as string;
|
||||
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext) VALUES ($1, $2, 'connector-oauth', '\\x00')", [ws, conn[mode]]);
|
||||
// A compiled immediate snapshot (no live rules: E1 stays ungated) so
|
||||
// E3 evaluates the publish gate instead of failing OUTSIDE_POLICY.
|
||||
await client.query(`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, effective, policy_version, resolved_from)
|
||||
VALUES ($1, 'account', $2, '{"version":1,"mode":"immediate"}', 'pv-immediate', '{}')`, [ws, conn[mode]]);
|
||||
}
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownerPool !== undefined) {
|
||||
await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]);
|
||||
await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`pubgov:%:${suffix}`]);
|
||||
await ownerPool.end();
|
||||
}
|
||||
await appPool?.end();
|
||||
await m?.db.pool.end();
|
||||
});
|
||||
|
||||
const owner = () => ({ userId: ownerId, role: "Owner" });
|
||||
|
||||
function service() {
|
||||
return new m.publishing.PublishingService({
|
||||
tenantTransaction: m.db.withTenant,
|
||||
workflowStarter: { startScheduledPublish: vi.fn(async (ref: AttemptRef) => { starts.push(ref); }) },
|
||||
repository: new PublishingExecutionRepository(m.db.pool),
|
||||
assertReady: () => assertPublishingSchema(m.db.pool),
|
||||
});
|
||||
}
|
||||
|
||||
async function approvedDraft(connectionId: string): Promise<string> {
|
||||
const publishing = service();
|
||||
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
|
||||
text: `governed ${connectionId} ${Math.random()}`, mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId },
|
||||
});
|
||||
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
|
||||
await publishing.approve(ws, owner(), draft.id);
|
||||
return draft.id;
|
||||
}
|
||||
|
||||
const vote = (pollId: string, userId: string, ballot: "yes" | "no" | "veto") =>
|
||||
new m.polls.PollService().vote(ws, pollId, { ballot }, { kind: "user", userId });
|
||||
|
||||
async function publishPolls(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT id, state, kind, rule, created_by, publish_connection_id FROM polls WHERE draft_id = $1 AND subject_kind = 'draft_publish' ORDER BY created_at",
|
||||
[draftId])).rows as Array<{ id: string; state: string; kind: string; rule: { mode: string; arms?: Array<{ x: number }>; join?: string }; created_by: string; publish_connection_id: string }>);
|
||||
}
|
||||
|
||||
async function schedules(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT id, state, connection_id, scheduled_by_user_id, required_permission, idempotency_key FROM scheduled_publications WHERE draft_id = $1",
|
||||
[draftId])).rows as Array<{ id: string; state: string; connection_id: string; scheduled_by_user_id: string; required_permission: string; idempotency_key: string }>);
|
||||
}
|
||||
|
||||
async function publishGates(draftId: string) {
|
||||
return inTenant(async (c) => (await c.query(
|
||||
"SELECT gate, outcome, reason_codes, inputs, poll_id FROM gate_decisions WHERE draft_id = $1 AND (inputs->>'phase') = 'publish' ORDER BY created_at, id",
|
||||
[draftId])).rows as Array<{ gate: string; outcome: string; reason_codes: string[]; inputs: Record<string, unknown>; poll_id: string }>);
|
||||
}
|
||||
|
||||
async function draftState(draftId: string): Promise<string> {
|
||||
return inTenant(async (c) => String((await c.query("SELECT state FROM drafts WHERE id = $1", [draftId])).rows[0]?.state));
|
||||
}
|
||||
|
||||
async function e3(draftId: string, connectionId: string, idempotencyKey: string) {
|
||||
return new m.dispatchGate.DispatchGateService(appPool).check({ workspaceId: ws, draftId, idempotencyKey, connectionId, platform: "mastodon" });
|
||||
}
|
||||
|
||||
it("auto: a console approval schedules the publish immediately and hints the workflow", async () => {
|
||||
starts.length = 0;
|
||||
const draftId = await approvedDraft(conn.auto);
|
||||
const [scheduled] = await schedules(draftId);
|
||||
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.auto, scheduled_by_user_id: ownerId, required_permission: "publication.publish" });
|
||||
expect(starts).toEqual([expect.objectContaining({ workspaceId: ws, scheduleId: scheduled?.id })]);
|
||||
expect(await publishPolls(draftId)).toEqual([]);
|
||||
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.scheduled'", [draftId])).rows);
|
||||
expect(audit[0]?.diff).toMatchObject({ trigger: "auto_on_approval", connectionId: conn.auto });
|
||||
expect((await e3(draftId, conn.auto, scheduled!.idempotency_key)).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("auto: a passing proposal poll (E2 execute-on-vote) schedules the publish too", async () => {
|
||||
const publishing = service();
|
||||
const draft = await publishing.createDraft(ws, { userId: editorId, role: "Editor" }, {
|
||||
text: "auto via poll", mediaAssetIds: [], visibility: "public", providerMetadata: { connectionId: conn.auto },
|
||||
});
|
||||
await publishing.submitForReview(ws, { userId: editorId, role: "Editor" }, draft.id);
|
||||
const card = await new m.polls.PollService().open(ws, {
|
||||
draftId: draft.id, kind: "yes_no", question: "Ship it?", rule: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } },
|
||||
}, { userId: ownerId });
|
||||
await vote(card.poll.id, admin2, "yes");
|
||||
expect(await draftState(draft.id)).toBe("approved");
|
||||
expect(await schedules(draft.id)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.auto, required_permission: "publication.publish" })]);
|
||||
});
|
||||
|
||||
it("admin1: approval opens the publish poll, the publish route holds, one admin yes releases", async () => {
|
||||
starts.length = 0;
|
||||
const draftId = await approvedDraft(conn.admin1);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll).toMatchObject({ state: "open", kind: "yes_no", created_by: ownerId, publish_connection_id: conn.admin1, rule: { mode: "admin_only" } });
|
||||
expect(await publishGates(draftId)).toEqual([expect.objectContaining({ gate: "E2", outcome: "pending", reason_codes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], poll_id: poll?.id })]);
|
||||
|
||||
const publishing = service();
|
||||
const held = await publishing.requestPublish(ws, { userId: admin2, role: "Admin" }, draftId, conn.admin1, new Date(), "publication.publish");
|
||||
expect(held).toMatchObject({ state: "awaiting_publish_approval", pollId: poll?.id, publishGovernance: "admin1", connectionId: conn.admin1 });
|
||||
await expect(publishing.schedule(ws, owner(), draftId, conn.admin1, new Date())).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(starts).toEqual([]);
|
||||
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
|
||||
const [scheduled] = await schedules(draftId);
|
||||
expect(scheduled).toMatchObject({ state: "pending", connection_id: conn.admin1, scheduled_by_user_id: ownerId });
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ gate: "E2", outcome: "fired", poll_id: poll?.id }));
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
expect((await e3(draftId, conn.admin1, scheduled!.idempotency_key)).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("admin_majority: needs 2 of 3 admins", async () => {
|
||||
const draftId = await approvedDraft(conn.admin_majority);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("open");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
await vote(poll!.id, admin3, "yes");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("passed");
|
||||
expect(await schedules(draftId)).toEqual([expect.objectContaining({ state: "pending", connection_id: conn.admin_majority })]);
|
||||
});
|
||||
|
||||
it("admin_consensus: one no blocks; expiry rejects, the draft stays approved and is re-publishable; a veto closes at once", async () => {
|
||||
const draftId = await approvedDraft(conn.admin_consensus);
|
||||
const [poll] = await publishPolls(draftId);
|
||||
expect(poll?.rule).toMatchObject({ mode: "quorum", join: "all", arms: [{ kind: "admins", x: 3 }] });
|
||||
await vote(poll!.id, ownerId, "yes");
|
||||
await vote(poll!.id, admin2, "yes");
|
||||
await vote(poll!.id, admin3, "no");
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("open");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
|
||||
await inTenant((c) => c.query("UPDATE polls SET opens_at = now() - interval '2 days', closes_at = now() - interval '1 second' WHERE id = $1", [poll!.id]));
|
||||
await new m.polls.PollService().close(ws, poll!.id);
|
||||
expect((await publishPolls(draftId))[0]?.state).toBe("failed");
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["TIMEBOX_EXPIRED_AUTO_REJECT"], poll_id: poll?.id }));
|
||||
|
||||
const again = await service().requestPublish(ws, owner(), draftId, conn.admin_consensus, new Date(), "publication.publish");
|
||||
expect(again.state).toBe("awaiting_publish_approval");
|
||||
const reopened = again.state === "awaiting_publish_approval" ? again.pollId : "";
|
||||
expect(reopened).not.toBe(poll?.id);
|
||||
await vote(reopened, admin3, "veto");
|
||||
expect((await publishPolls(draftId)).map((p) => p.state)).toEqual(["failed", "vetoed"]);
|
||||
expect(await publishGates(draftId)).toContainEqual(expect.objectContaining({ outcome: "blocked", reason_codes: ["VETOED"], poll_id: reopened }));
|
||||
expect(await schedules(draftId)).toEqual([]);
|
||||
expect(await draftState(draftId)).toBe("approved");
|
||||
});
|
||||
|
||||
it("E3 releases a gated destination only on poll pass (schedule made before the gate was switched on)", async () => {
|
||||
const draftId = await approvedDraft(conn.manual);
|
||||
expect(await publishPolls(draftId)).toEqual([]); // manual: approval changes nothing
|
||||
const scheduled = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(Date.now() + 3_600_000), "publication.publish");
|
||||
if (scheduled.state === "awaiting_publish_approval") throw new Error("manual connection must not hold");
|
||||
|
||||
const epoch = String((await ownerPool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws])).rows[0].authz_epoch);
|
||||
const session = (userId: string, role: string) => ({ userId, role, workspaceId: ws, authzEpoch: Number(epoch), issuedAt: 0, expiresAt: Math.floor(Date.now() / 1000) + 300 });
|
||||
const store = new m.store.PgConnectionStore(m.db.withTenant);
|
||||
await expect(store.setPublishGovernance(session(editorId, "Editor"), conn.manual, "auto")).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, "admin1");
|
||||
|
||||
const blocked = await e3(draftId, conn.manual, scheduled.idempotencyKey);
|
||||
expect(blocked).toMatchObject({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], pollState: "none" });
|
||||
|
||||
const held = await service().requestPublish(ws, owner(), draftId, conn.manual, new Date(), "publication.publish");
|
||||
if (held.state !== "awaiting_publish_approval") throw new Error("gated connection must hold");
|
||||
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).pollState).toBe("open");
|
||||
await vote(held.pollId, admin2, "yes");
|
||||
expect((await e3(draftId, conn.manual, scheduled.idempotencyKey)).outcome).toBe("fired");
|
||||
// The pass found the earlier pending schedule still active: the release is
|
||||
// refused (audited) rather than doubling the publish.
|
||||
const audit = await inTenant(async (c) => (await c.query("SELECT diff FROM audit_log WHERE target_id = $1 AND action = 'publication.publish_release_failed'", [draftId])).rows);
|
||||
expect(audit[0]?.diff).toMatchObject({ code: "SCHEDULE_ACTIVE", pollId: held.pollId });
|
||||
expect(await schedules(draftId)).toHaveLength(1);
|
||||
|
||||
await store.setPublishGovernance(session(ownerId, "Owner"), conn.manual, null);
|
||||
const fields = await inTenant(async (c) => (await c.query("SELECT fields FROM connections WHERE id = $1", [conn.manual])).rows[0].fields);
|
||||
expect(fields).toEqual({ handle: "manual" });
|
||||
});
|
||||
});
|
||||
93
apps/api/src/publishing/publish-governance.test.ts
Normal file
93
apps/api/src/publishing/publish-governance.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { ConflictException } from "@nestjs/common";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluatePoll, type EvaluatedBallot } from "../chatgov/poll-evaluator.js";
|
||||
import { compilePublishRule, isPublishGateMode, readPublishGovernance, type PublishGateMode } from "./publish-governance.js";
|
||||
|
||||
/**
|
||||
* Publish-phase gate: the per-connection setting compiles onto the SAME §1a
|
||||
* rule shapes as proposal polls, and the existing pure evaluator decides it.
|
||||
*/
|
||||
|
||||
const opensAt = new Date("2026-10-14T10:00:00Z");
|
||||
const closesAt = new Date("2026-10-15T10:00:00Z");
|
||||
|
||||
function admin(ballot: EvaluatedBallot["ballot"]): EvaluatedBallot {
|
||||
return { ballot, optionId: null, counted: true, admin: true, observed: false, participant: true };
|
||||
}
|
||||
|
||||
function decide(mode: PublishGateMode, admins: number, ballots: EvaluatedBallot[], now = new Date("2026-10-14T10:05:00Z")) {
|
||||
return evaluatePoll({
|
||||
kind: "yes_no", rule: compilePublishRule(mode, admins), opensAt, closesAt, now, ballots, options: [],
|
||||
possible: { admins, eligible: admins, observed: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("publish governance setting", () => {
|
||||
it("reads only the known modes; anything else is manual (null)", () => {
|
||||
expect(readPublishGovernance({ publishGovernance: "auto" })).toBe("auto");
|
||||
expect(readPublishGovernance({ publishGovernance: "admin_consensus" })).toBe("admin_consensus");
|
||||
expect(readPublishGovernance({ publishGovernance: "everyone" })).toBeNull();
|
||||
expect(readPublishGovernance({})).toBeNull();
|
||||
expect(readPublishGovernance(null)).toBeNull();
|
||||
expect(isPublishGateMode("auto")).toBe(false);
|
||||
expect(isPublishGateMode(null)).toBe(false);
|
||||
expect(isPublishGateMode("admin1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compilePublishRule", () => {
|
||||
it("admin1 compiles to admin_only (the evaluator's implied single admin arm)", () => {
|
||||
const rule = compilePublishRule("admin1", 3);
|
||||
expect(rule).toMatchObject({ mode: "admin_only", anonymity: "named", allowSelfApproval: true, timebox: { duration: "P1D", onExpire: "reject" } });
|
||||
expect(rule.arms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin_majority needs floor(n/2)+1 admins, snapshotted from the roster at open", () => {
|
||||
expect(compilePublishRule("admin_majority", 3)).toMatchObject({ mode: "quorum", join: "any", arms: [{ kind: "admins", x: 2 }] });
|
||||
expect(compilePublishRule("admin_majority", 4).arms).toEqual([{ kind: "admins", x: 3 }]);
|
||||
expect(compilePublishRule("admin_majority", 1).arms).toEqual([{ kind: "admins", x: 1 }]);
|
||||
});
|
||||
|
||||
it("admin_consensus needs every admin, join all, and allows a veto", () => {
|
||||
expect(compilePublishRule("admin_consensus", 3)).toMatchObject({ mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: 3 }] });
|
||||
});
|
||||
|
||||
it("refuses to open a gate nobody can pass", () => {
|
||||
expect(() => compilePublishRule("admin1", 0)).toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish gate decisions (pure evaluator)", () => {
|
||||
it("admin1 passes with one admin yes", () => {
|
||||
expect(decide("admin1", 3, []).state).toBeNull();
|
||||
expect(decide("admin1", 3, [admin("yes")])).toMatchObject({ state: "passed", reason: "quorum_early" });
|
||||
});
|
||||
|
||||
it("admin1 ignores a non-admin yes", () => {
|
||||
expect(decide("admin1", 3, [{ ...admin("yes"), admin: false }]).state).toBeNull();
|
||||
});
|
||||
|
||||
it("admin_majority needs 2 of 3", () => {
|
||||
expect(decide("admin_majority", 3, [admin("yes")]).state).toBeNull();
|
||||
expect(decide("admin_majority", 3, [admin("yes"), admin("yes")])).toMatchObject({ state: "passed", quorum: true });
|
||||
});
|
||||
|
||||
it("admin_consensus is held by one standing no and rejects at the timebox", () => {
|
||||
const ballots = [admin("yes"), admin("yes"), admin("no")];
|
||||
expect(decide("admin_consensus", 3, ballots)).toMatchObject({ state: null, quorum: false });
|
||||
expect(decide("admin_consensus", 3, ballots, closesAt)).toMatchObject({ state: "failed", reason: "timebox" });
|
||||
expect(decide("admin_consensus", 3, [admin("yes"), admin("yes"), admin("yes")]).state).toBe("passed");
|
||||
});
|
||||
|
||||
it("admin_consensus closes immediately on an admin veto", () => {
|
||||
expect(decide("admin_consensus", 3, [admin("yes"), admin("veto")])).toMatchObject({ state: "vetoed", reason: "veto" });
|
||||
});
|
||||
|
||||
it("a removed admin can make consensus unreachable (roster changes after open never lower the bar)", () => {
|
||||
const evaluation = evaluatePoll({
|
||||
kind: "yes_no", rule: compilePublishRule("admin_consensus", 3), opensAt, closesAt, now: new Date("2026-10-14T10:05:00Z"),
|
||||
ballots: [admin("yes")], options: [], possible: { admins: 2, eligible: 2, observed: 0 },
|
||||
});
|
||||
expect(evaluation.state).toBe("quorum_unreachable");
|
||||
});
|
||||
});
|
||||
345
apps/api/src/publishing/publish-governance.ts
Normal file
345
apps/api/src/publishing/publish-governance.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { ConflictException } from "@nestjs/common";
|
||||
import type { PoolClient } from "pg";
|
||||
import { isPublishingProvider } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
import { PublishingExecutionRepository, PublishingStateError, type ScheduledAttempt } from "@tactical-media-broadcast-hub/publishing-execution";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { parsePolicyRule, policyHash, type PolicyRule } from "../chatgov/policy.js";
|
||||
import { durationMs } from "../chatgov/poll-evaluator.js";
|
||||
import { pool } from "../db/client.js";
|
||||
|
||||
/**
|
||||
* Per-connection publish governance (the publish-phase gate).
|
||||
*
|
||||
* `connections.fields.publishGovernance` decides what happens once a draft
|
||||
* targeting that connection (providerMetadata.connectionId) is `approved`:
|
||||
*
|
||||
* unset manual — today's behavior: a publication.publish holder
|
||||
* calls POST /publishing/drafts/:id/publish.
|
||||
* auto the approval itself schedules the publish (now). No
|
||||
* second gate: the proposal vote was the decision.
|
||||
* admin1 a SECOND chatgov poll (subject_kind 'draft_publish',
|
||||
* admin_majority kind yes_no) opens on approval / on the publish route;
|
||||
* admin_consensus the publish is scheduled only when it passes
|
||||
* (execute-on-vote). Same rule shapes as proposal polls.
|
||||
*
|
||||
* The scheduling itself is always PublishingExecutionRepository.scheduleAttempt
|
||||
* — the durable pending row the scheduler sweep turns into the unchanged
|
||||
* `scheduledPublish` Temporal workflow — so every authorization fence the
|
||||
* claim re-reads (principal permission, approver, credential generation)
|
||||
* still applies to governed and automatic publishes. E3 additionally refuses
|
||||
* to dispatch to a gated connection unless the publish poll for the approved
|
||||
* revision passed (apps/scheduler dispatch-gate.ts).
|
||||
*/
|
||||
|
||||
export const PUBLISH_GOVERNANCE_MODES = ["auto", "admin1", "admin_majority", "admin_consensus"] as const;
|
||||
export type PublishGovernance = (typeof PUBLISH_GOVERNANCE_MODES)[number];
|
||||
export type PublishGateMode = Exclude<PublishGovernance, "auto">;
|
||||
|
||||
/** Publish polls close after a day; an unanswered gate rejects (re-publishable). */
|
||||
export const PUBLISH_GATE_TIMEBOX = { duration: "P1D", onExpire: "reject" } as const;
|
||||
|
||||
const ADMIN_ROLES = ["Owner", "Admin"];
|
||||
|
||||
/** Lenient read of stored fields: anything unrecognised is manual (null). */
|
||||
export function readPublishGovernance(fields: unknown): PublishGovernance | null {
|
||||
if (typeof fields !== "object" || fields === null) return null;
|
||||
const value = (fields as Record<string, unknown>).publishGovernance;
|
||||
return PUBLISH_GOVERNANCE_MODES.find((mode) => mode === value) ?? null;
|
||||
}
|
||||
|
||||
export function isPublishGateMode(value: PublishGovernance | null): value is PublishGateMode {
|
||||
return value === "admin1" || value === "admin_majority" || value === "admin_consensus";
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a gate mode into a §1a rule against the admin roster AT OPEN.
|
||||
* The count is snapshotted into the rule (and its policy_version): admins
|
||||
* added later do not raise the bar; admins removed later can make the arm
|
||||
* unreachable, which closes the poll `quorum_unreachable` (blocked).
|
||||
*
|
||||
* admin1 admin_only (the evaluator's implied {admins, x:1} arm)
|
||||
* admin_majority quorum, [{admins, x: floor(n/2)+1}], join any
|
||||
* admin_consensus quorum, [{admins, x: n}], join all, veto allowed
|
||||
*
|
||||
* Self-approval is allowed: the content was already approved under the
|
||||
* proposal policy; this gate is the admins' sign-off on the dispatch, and
|
||||
* excluding an author-admin would make consensus unreachable by design.
|
||||
*/
|
||||
export function compilePublishRule(mode: PublishGateMode, adminCount: number): PolicyRule {
|
||||
if (!Number.isInteger(adminCount) || adminCount < 1) {
|
||||
throw new ConflictException({ code: "PUBLISH_GATE_NO_ADMINS", message: "publish gate needs at least one active admin" });
|
||||
}
|
||||
const common = { version: 1, anonymity: "named", allowSelfApproval: true, resetOnChange: false, timebox: PUBLISH_GATE_TIMEBOX };
|
||||
switch (mode) {
|
||||
case "admin1":
|
||||
return parsePolicyRule({ ...common, mode: "admin_only" });
|
||||
case "admin_majority":
|
||||
return parsePolicyRule({ ...common, mode: "quorum", join: "any", arms: [{ kind: "admins", x: Math.floor(adminCount / 2) + 1 }] });
|
||||
case "admin_consensus":
|
||||
return parsePolicyRule({ ...common, mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: adminCount }] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins whose ballot counts toward an `admins` arm — the same definition the
|
||||
* poll tally uses (poll.service resolveTally): active Owner/Admin members
|
||||
* holding chatgov.vote, plus active members with an approved Admin/Owner
|
||||
* chat-identity role override.
|
||||
*/
|
||||
export async function governanceAdminIds(client: PoolClient, workspaceId: string): Promise<string[]> {
|
||||
const rows = await client.query<{ user_id: string }>(
|
||||
`SELECT m.user_id FROM memberships m
|
||||
WHERE m.workspace_id = $1 AND m.status = 'active'
|
||||
AND ((m.role = ANY($2::text[]) AND EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role = m.role AND rp.permission = 'chatgov.vote'))
|
||||
OR EXISTS (SELECT 1 FROM chat_identities ci WHERE ci.user_id = m.user_id AND ci.status = 'approved' AND ci.role_override = ANY($2::text[])))
|
||||
ORDER BY m.user_id`,
|
||||
[workspaceId, ADMIN_ROLES],
|
||||
);
|
||||
return rows.rows.map((row) => String(row.user_id));
|
||||
}
|
||||
|
||||
/** What the publish route answers when the publish is held for admins. */
|
||||
export interface PublishHold {
|
||||
state: "awaiting_publish_approval";
|
||||
draftId: string;
|
||||
connectionId: string;
|
||||
publishGovernance: PublishGateMode;
|
||||
pollId: string;
|
||||
closesAt: Date;
|
||||
rule: PolicyRule;
|
||||
}
|
||||
|
||||
interface PublishRequest {
|
||||
publishAt: string;
|
||||
requiredPermission: "publication.schedule" | "publication.publish";
|
||||
}
|
||||
|
||||
interface PublishPollRow {
|
||||
id: string;
|
||||
state: string;
|
||||
closes_at: Date;
|
||||
rule: PolicyRule;
|
||||
}
|
||||
|
||||
function holdFrom(draftId: string, connectionId: string, mode: PublishGateMode, poll: PublishPollRow): PublishHold {
|
||||
return { state: "awaiting_publish_approval", draftId, connectionId, publishGovernance: mode, pollId: poll.id, closesAt: new Date(poll.closes_at), rule: poll.rule };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish-route gate (runs inside PublishingService.schedule's transaction,
|
||||
* after its tenancy/provider/group checks). `proceed` = the publish poll for
|
||||
* the CURRENT approved revision passed, or the draft is not currently
|
||||
* approved (scheduleAttempt then refuses with its usual error). Otherwise
|
||||
* the open poll is reused, or a fresh one is opened (first request, or a
|
||||
* re-publish after a failed/expired/vetoed gate).
|
||||
*/
|
||||
export async function checkPublishGate(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode;
|
||||
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
|
||||
}): Promise<{ kind: "proceed" } | { kind: "held"; hold: PublishHold; opened: boolean }> {
|
||||
const draft = (await client.query(
|
||||
"SELECT state, revision, approved_revision, approved_hash FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId])).rows[0];
|
||||
if (draft === undefined || draft.state !== "approved" || draft.approved_hash === null
|
||||
|| draft.approved_revision === null || String(draft.approved_revision) !== String(draft.revision)) {
|
||||
return { kind: "proceed" };
|
||||
}
|
||||
const latest = await latestPublishPoll(client, input.draftId, input.connectionId, String(draft.approved_revision));
|
||||
if (latest?.state === "passed") return { kind: "proceed" };
|
||||
if (latest?.state === "open") return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, latest), opened: false };
|
||||
const poll = await openPublishPoll(client, { ...input, revision: String(draft.approved_revision) });
|
||||
return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, poll), opened: true };
|
||||
}
|
||||
|
||||
async function latestPublishPoll(client: PoolClient, draftId: string, connectionId: string, revision: string): Promise<PublishPollRow | undefined> {
|
||||
return (await client.query<PublishPollRow>(
|
||||
`SELECT id, state, closes_at, rule FROM polls
|
||||
WHERE draft_id = $1 AND subject_kind = 'draft_publish' AND publish_connection_id = $2 AND draft_revision = $3
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[draftId, connectionId, revision],
|
||||
)).rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the publish-phase poll + its pending E2 gate row + audit, in the
|
||||
* caller's transaction (the caller holds the draft row lock; same-transaction
|
||||
* FK KEY SHARE on gate_decisions/polls is safe). The requester is recorded as
|
||||
* created_by and becomes the scheduling principal on release.
|
||||
*/
|
||||
export async function openPublishPoll(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; revision: string;
|
||||
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
|
||||
}): Promise<PublishPollRow> {
|
||||
const admins = await governanceAdminIds(client, input.workspaceId);
|
||||
const rule = compilePublishRule(input.mode, admins.length);
|
||||
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1", [input.connectionId])).rows[0];
|
||||
const fields = (connection?.fields ?? {}) as Record<string, unknown>;
|
||||
const label = typeof fields.handle === "string" && fields.handle !== "" ? fields.handle : String(connection?.provider ?? "connection");
|
||||
const question = `Publish the approved draft to ${label}?`.slice(0, 300);
|
||||
const now = new Date();
|
||||
const request: PublishRequest = { publishAt: input.publishAt.toISOString(), requiredPermission: input.requiredPermission };
|
||||
const policyVersion = policyHash(rule);
|
||||
const poll = (await client.query<PublishPollRow>(
|
||||
`INSERT INTO polls (workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at,
|
||||
created_by, draft_revision, publish_connection_id, publish_request)
|
||||
VALUES ($1, $2, 'draft_publish', $3, 'yes_no', $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, state, closes_at, rule`,
|
||||
[input.workspaceId, input.draftId, question, rule, policyVersion, now, new Date(now.getTime() + durationMs(rule.timebox?.duration ?? "P1D")),
|
||||
input.requestedBy, input.revision, input.connectionId, request],
|
||||
)).rows[0]!;
|
||||
const needed = rule.mode === "admin_only" ? 1 : rule.arms?.[0]?.x ?? 1;
|
||||
await client.query(
|
||||
`INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
|
||||
VALUES ($1, $2, $3, 'E2', $4, 'pending', $5, $6, $7, $8)`,
|
||||
[input.workspaceId, input.draftId, { platform: String(connection?.provider ?? "unknown"), accountId: input.connectionId }, policyVersion,
|
||||
{ phase: "publish", publishGovernance: input.mode, adminCount: admins.length, arms: [{ kind: "admins", got: 0, needed }], pollState: "open" },
|
||||
["INSUFFICIENT_QUORUM", "PENDING_POLL"], `user:${input.requestedBy}`, poll.id],
|
||||
);
|
||||
await client.query("SELECT audit_append($1, $2, 'Member', 'poll.opened', 'poll', $3, $4)", [input.workspaceId, input.requestedBy, poll.id,
|
||||
scrub({ draftId: input.draftId, kind: "yes_no", phase: "publish", connectionId: input.connectionId, publishGovernance: input.mode,
|
||||
adminCount: admins.length, policyVersion, actor: `user:${input.requestedBy}` })]);
|
||||
return poll;
|
||||
}
|
||||
|
||||
type ScheduleRepository = Pick<PublishingExecutionRepository, "scheduleAttempt">;
|
||||
let defaultRepository: ScheduleRepository | undefined;
|
||||
function repositoryOrDefault(repository?: ScheduleRepository): ScheduleRepository {
|
||||
if (repository !== undefined) return repository;
|
||||
defaultRepository ??= new PublishingExecutionRepository(pool);
|
||||
return defaultRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule inside a SAVEPOINT so a refused publish (credential gone, approval
|
||||
* voided, schedule already active, principal revoked) never rolls back the
|
||||
* vote / approval that triggered it. The refusal is audited instead.
|
||||
*/
|
||||
async function scheduleGuarded(client: PoolClient, repository: ScheduleRepository, input: Parameters<ScheduleRepository["scheduleAttempt"]>[1]):
|
||||
Promise<{ ok: true; attempt: ScheduledAttempt } | { ok: false; code: string }> {
|
||||
await client.query("SAVEPOINT publish_governance");
|
||||
try {
|
||||
const attempt = await repository.scheduleAttempt(client, input);
|
||||
await client.query("RELEASE SAVEPOINT publish_governance");
|
||||
return { ok: true, attempt };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK TO SAVEPOINT publish_governance");
|
||||
if (error instanceof PublishingStateError) return { ok: false, code: error.code };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function auditDraft(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string }, action: string, draftId: string, diff: Record<string, unknown>): Promise<void> {
|
||||
await client.query("SELECT audit_append($1, $2, $3, $4, 'draft', $5, $6)", [workspaceId, actor.userId, actor.role, action, draftId, scrub(diff)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* E2 close seam for publish polls (called by poll.service evaluateLocked
|
||||
* under the workspace → draft → poll locks, after the exactly-once claim).
|
||||
* A passed poll schedules the publish the requester asked for; the scheduler
|
||||
* sweep starts the workflow. A refused schedule is audited, the poll stays
|
||||
* passed, and the holder can re-publish once the cause is fixed.
|
||||
*/
|
||||
export async function releasePublishPoll(client: PoolClient, poll: {
|
||||
id: string; workspace_id: string; draft_id: string | null; created_by: string;
|
||||
publish_connection_id: string | null; publish_request: unknown;
|
||||
}, repository?: ScheduleRepository): Promise<{ released: true; scheduleId: string } | { released: false; code: string }> {
|
||||
if (poll.draft_id === null || poll.publish_connection_id === null) return { released: false, code: "NOT_A_PUBLISH_POLL" };
|
||||
const request = (poll.publish_request ?? {}) as Partial<PublishRequest>;
|
||||
const requested = typeof request.publishAt === "string" ? new Date(request.publishAt) : new Date();
|
||||
const publishAt = Number.isFinite(requested.getTime()) && requested.getTime() > Date.now() ? requested : new Date();
|
||||
const requiredPermission = request.requiredPermission === "publication.schedule" ? "publication.schedule" : "publication.publish";
|
||||
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
|
||||
workspaceId: poll.workspace_id, draftId: poll.draft_id, connectionId: poll.publish_connection_id,
|
||||
scheduledByUserId: poll.created_by, requiredPermission, publishAt,
|
||||
});
|
||||
const system = { userId: null, role: "System" };
|
||||
if (!result.ok) {
|
||||
await auditDraft(client, poll.workspace_id, system, "publication.publish_release_failed", poll.draft_id,
|
||||
{ pollId: poll.id, connectionId: poll.publish_connection_id, code: result.code, actor: `system:chat-poll-gate:${poll.id}` });
|
||||
return { released: false, code: result.code };
|
||||
}
|
||||
await auditDraft(client, poll.workspace_id, system, "publication.scheduled", poll.draft_id, {
|
||||
connectionId: poll.publish_connection_id, publishAt: publishAt.toISOString(), trigger: "publish_poll_passed",
|
||||
pollId: poll.id, scheduledByUserId: poll.created_by, actor: `system:chat-poll-gate:${poll.id}`,
|
||||
});
|
||||
return { released: true, scheduleId: result.attempt.scheduleId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The principal a system-initiated publish runs as: the approving user when
|
||||
* they could publish to this connection themselves, else a live Owner/Admin
|
||||
* (the poll-approval delegation pattern). Mirrors the repository's authorize
|
||||
* predicate, which re-checks it at schedule and at every claim.
|
||||
*/
|
||||
async function publishPrincipal(client: PoolClient, workspaceId: string, connectionId: string, preferredUserId: string | null): Promise<string | null> {
|
||||
const row = (await client.query<{ user_id: string }>(
|
||||
`SELECT m.user_id FROM memberships m
|
||||
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'publication.publish'
|
||||
WHERE m.workspace_id = $1 AND m.status = 'active'
|
||||
AND (m.role = ANY($4::text[]) OR EXISTS (SELECT 1 FROM group_connections gc JOIN group_members gm ON gm.group_id = gc.group_id
|
||||
WHERE gc.connection_id = $2 AND gm.user_id = m.user_id))
|
||||
ORDER BY COALESCE(m.user_id = $3::uuid, false) DESC, CASE m.role WHEN 'Owner' THEN 0 WHEN 'Admin' THEN 1 ELSE 2 END, m.user_id
|
||||
LIMIT 1`,
|
||||
[workspaceId, connectionId, preferredUserId, ADMIN_ROLES],
|
||||
)).rows[0];
|
||||
return row === undefined ? null : String(row.user_id);
|
||||
}
|
||||
|
||||
export type ApprovalFollowUp =
|
||||
| { kind: "dispatched"; connectionId: string; attempt: ScheduledAttempt; publishAt: Date }
|
||||
| { kind: "held"; connectionId: string; pollId: string }
|
||||
| { kind: "skipped"; connectionId: string; code: string };
|
||||
|
||||
/**
|
||||
* Approval seam: runs in the SAME transaction that moved the draft to
|
||||
* `approved` (console approve, or the E2 proposal-poll pass), after the
|
||||
* UPDATE. Manual connections (no setting) return null — today's behavior.
|
||||
* Never throws for a refused follow-up: the approval must stand on its own.
|
||||
*/
|
||||
export async function onDraftApproved(client: PoolClient, input: {
|
||||
workspaceId: string; draftId: string; providerMetadata: Record<string, unknown>;
|
||||
preferredUserId: string | null; actor: { userId: string | null; role: string };
|
||||
}, repository?: ScheduleRepository): Promise<ApprovalFollowUp | null> {
|
||||
const connectionId = input.providerMetadata.connectionId;
|
||||
if (typeof connectionId !== "string" || connectionId === "") return null;
|
||||
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId])).rows[0];
|
||||
if (connection === undefined) return null;
|
||||
const mode = readPublishGovernance(connection.fields);
|
||||
if (mode === null) return null;
|
||||
const skip = async (code: string): Promise<ApprovalFollowUp> => {
|
||||
await auditDraft(client, input.workspaceId, input.actor, "publication.auto_publish_skipped", input.draftId, { connectionId, publishGovernance: mode, code });
|
||||
return { kind: "skipped", connectionId, code };
|
||||
};
|
||||
if (!isPublishingProvider(String(connection.provider))) return skip("PROVIDER_NOT_PUBLISHING");
|
||||
const principal = await publishPrincipal(client, input.workspaceId, connectionId, input.preferredUserId);
|
||||
if (principal === null) return skip("NO_AUTHORIZED_PRINCIPAL");
|
||||
|
||||
if (mode === "auto") {
|
||||
const publishAt = new Date();
|
||||
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
|
||||
workspaceId: input.workspaceId, draftId: input.draftId, connectionId,
|
||||
scheduledByUserId: principal, requiredPermission: "publication.publish", publishAt,
|
||||
});
|
||||
if (!result.ok) return skip(result.code);
|
||||
await auditDraft(client, input.workspaceId, input.actor, "publication.scheduled", input.draftId, {
|
||||
connectionId, publishAt: publishAt.toISOString(), trigger: "auto_on_approval", scheduledByUserId: principal,
|
||||
});
|
||||
return { kind: "dispatched", connectionId, attempt: result.attempt, publishAt };
|
||||
}
|
||||
|
||||
// Gate modes: the second gate starts running as soon as the draft is
|
||||
// approved; the publish route reuses this poll instead of opening another.
|
||||
await client.query("SAVEPOINT publish_governance_open");
|
||||
try {
|
||||
const gate = await checkPublishGate(client, {
|
||||
workspaceId: input.workspaceId, draftId: input.draftId, connectionId, mode,
|
||||
requestedBy: principal, publishAt: new Date(), requiredPermission: "publication.publish",
|
||||
});
|
||||
await client.query("RELEASE SAVEPOINT publish_governance_open");
|
||||
if (gate.kind === "held") return { kind: "held", connectionId, pollId: gate.hold.pollId };
|
||||
return null;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK TO SAVEPOINT publish_governance_open");
|
||||
if (error instanceof ConflictException) return skip("PUBLISH_GATE_UNAVAILABLE");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
type DraftPost,
|
||||
type DraftRecord,
|
||||
type DraftState,
|
||||
type ScheduledPublicationRecord,
|
||||
type PublishOutcome,
|
||||
} from "./publishing.service.js";
|
||||
|
||||
export interface CreateDraftDto {
|
||||
|
|
@ -163,10 +163,10 @@ export class PublishingController {
|
|||
@Req() request: Request & AuthenticatedRequest,
|
||||
@Param("draftId") draftId: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<ScheduledPublicationRecord> {
|
||||
): Promise<PublishOutcome> {
|
||||
const session = requireSession(request);
|
||||
const dto = parseScheduleDraft(body);
|
||||
return this.publishing.schedule(
|
||||
return this.publishing.requestPublish(
|
||||
session.workspaceId,
|
||||
actorFrom(session),
|
||||
parseId(draftId),
|
||||
|
|
@ -179,17 +179,17 @@ export class PublishingController {
|
|||
@HttpCode(202)
|
||||
@RequirePermission("publication.publish")
|
||||
@RequireGroupAccess("body")
|
||||
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
||||
async publishNow(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
const session = requireSession(request);
|
||||
const input = requireObject(body, "body");
|
||||
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish");
|
||||
return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), parseId(requireString(input.connectionId, "connectionId")), new Date(), "publication.publish");
|
||||
}
|
||||
|
||||
@Post(":draftId/retry")
|
||||
@HttpCode(202)
|
||||
@RequirePermission("publication.schedule")
|
||||
@RequireGroupAccess("body")
|
||||
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
||||
async retry(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
return this.resume(request, draftId, body, "failed");
|
||||
}
|
||||
|
||||
|
|
@ -197,16 +197,16 @@ export class PublishingController {
|
|||
@HttpCode(202)
|
||||
@RequirePermission("publication.schedule")
|
||||
@RequireGroupAccess("body")
|
||||
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<ScheduledPublicationRecord> {
|
||||
async reauthorize(@Req() request: Request & AuthenticatedRequest, @Param("draftId") draftId: string, @Body() body: unknown): Promise<PublishOutcome> {
|
||||
return this.resume(request, draftId, body, "needs_reauthorization");
|
||||
}
|
||||
|
||||
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<ScheduledPublicationRecord> {
|
||||
private async resume(request: Request & AuthenticatedRequest, draftId: string, body: unknown, state: "failed" | "needs_reauthorization"): Promise<PublishOutcome> {
|
||||
const session = requireSession(request);
|
||||
const dto = parseScheduleDraft(body);
|
||||
const input = requireObject(body, "body");
|
||||
if (typeof input.expectedGeneration !== "string" || !/^[1-9][0-9]*$/.test(input.expectedGeneration)) throw new BadRequestException("expectedGeneration must be a positive decimal string");
|
||||
return this.publishing.schedule(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration });
|
||||
return this.publishing.requestPublish(session.workspaceId, actorFrom(session), parseId(draftId), dto.connectionId, new Date(dto.publishAt), "publication.schedule", { state, generation: input.expectedGeneration });
|
||||
}
|
||||
|
||||
@Delete(":draftId/schedule")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { Client, Connection, WorkflowExecutionAlreadyStartedError } from "@temporalio/client";
|
||||
|
|
@ -11,6 +13,7 @@ import { markDraftPollsStale, autoOpenDraftPoll } from "../chatgov/poll.service.
|
|||
import { PgConnectionStore } from "../connections/connection-store.js";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
import { ApprovalGateService, ApprovalGateBlockedError } from "./approval-gate.service.js";
|
||||
import { checkPublishGate, isPublishGateMode, onDraftApproved, readPublishGovernance, type ApprovalFollowUp, type PublishHold } from "./publish-governance.js";
|
||||
import { pool, withTenant } from "../db/client.js";
|
||||
import { userCanUseConnection } from "../groups/groups.service.js";
|
||||
import { vault } from "../vault/vault.js";
|
||||
|
|
@ -47,6 +50,9 @@ export interface ScheduledPublicationRecord {
|
|||
state: "pending" | "in_flight" | "done" | "failed" | "needs_reauthorization" | "cancelled";
|
||||
}
|
||||
|
||||
/** The publish/schedule routes either schedule, or hold for the publish gate. */
|
||||
export type PublishOutcome = ScheduledPublicationRecord | PublishHold;
|
||||
|
||||
export interface Actor {
|
||||
userId: string;
|
||||
role: string;
|
||||
|
|
@ -380,15 +386,30 @@ export class PublishingService {
|
|||
*/
|
||||
async approve(workspaceId: string, actor: Actor, draftId: string): Promise<DraftRecord> {
|
||||
const gate = this.dependencies.approvalGate ?? null;
|
||||
let followUp: ApprovalFollowUp | null = null;
|
||||
try {
|
||||
return await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", {
|
||||
const approved = await this.transition(workspaceId, actor, draftId, "in_review", "approved", "draft.approved", {
|
||||
from: "in_review",
|
||||
to: "approved",
|
||||
}, actor.userId, gate === null ? undefined : async (client: PoolClient, draft: DraftRecord) => {
|
||||
const evaluation = await gate.evaluate(client, workspaceId, draftId, draft.post, actor.userId);
|
||||
if (evaluation.blocked.length > 0) throw new ApprovalGateBlockedError(evaluation.blocked);
|
||||
await gate.recordFiredInTxn(client, workspaceId, draftId, actor.userId, evaluation.verdicts);
|
||||
}, async (client: PoolClient, draft: DraftRecord) => {
|
||||
// Publish governance of the target connection: auto schedules now,
|
||||
// gate modes open the publish poll; manual connections are untouched.
|
||||
followUp = await onDraftApproved(client, {
|
||||
workspaceId, draftId, providerMetadata: draft.post.providerMetadata ?? {}, preferredUserId: actor.userId, actor,
|
||||
}, this.dependencies.repository);
|
||||
});
|
||||
// Assigned inside the transaction callback (TS cannot see that write).
|
||||
const dispatched = followUp as ApprovalFollowUp | null;
|
||||
if (dispatched !== null && dispatched.kind === "dispatched") {
|
||||
await this.dependencies.workflowStarter
|
||||
.startScheduledPublish({ workspaceId, scheduleId: dispatched.attempt.scheduleId, generation: dispatched.attempt.generation, idempotencyKey: dispatched.attempt.idempotencyKey }, dispatched.publishAt)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return approved;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalGateBlockedError) {
|
||||
// The approval rolled back; record the blocked verdicts with no draft
|
||||
|
|
@ -408,6 +429,12 @@ export class PublishingService {
|
|||
}, actor.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic scheduling. A connection under an admin publish gate cannot
|
||||
* be scheduled around it: while its publish poll is pending this refuses
|
||||
* with 409 PUBLISH_AWAITING_APPROVAL (the poll is opened/kept). The HTTP
|
||||
* routes use requestPublish, which answers 202 with the hold instead.
|
||||
*/
|
||||
async schedule(
|
||||
workspaceId: string,
|
||||
actor: Actor,
|
||||
|
|
@ -417,6 +444,23 @@ export class PublishingService {
|
|||
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
||||
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
||||
): Promise<ScheduledPublicationRecord> {
|
||||
const outcome = await this.requestPublish(workspaceId, actor, draftId, connectionId, publishAt, requiredPermission, expectedPrevious);
|
||||
if (outcome.state === "awaiting_publish_approval") {
|
||||
throw new ConflictException({ code: "PUBLISH_AWAITING_APPROVAL", message: "publish is held for the connection's admin publish gate", hold: outcome });
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Schedule, or hold behind the target connection's publish gate. */
|
||||
async requestPublish(
|
||||
workspaceId: string,
|
||||
actor: Actor,
|
||||
draftId: string,
|
||||
connectionId: string,
|
||||
publishAt: Date,
|
||||
requiredPermission: "publication.schedule" | "publication.publish" = "publication.schedule",
|
||||
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string },
|
||||
): Promise<PublishOutcome> {
|
||||
if (!Number.isFinite(publishAt.getTime())) throw new BadRequestException("publishAt must be a valid timestamp");
|
||||
|
||||
await this.dependencies.assertReady();
|
||||
|
|
@ -424,13 +468,29 @@ export class PublishingService {
|
|||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
// FK checks bypass RLS, so a foreign tenant's connection id would satisfy
|
||||
// the constraint; this RLS-scoped read is the actual tenancy check.
|
||||
const connection = await client.query("SELECT provider FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]);
|
||||
const connection = await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId]);
|
||||
if (connection.rowCount !== 1) throw new NotFoundException(`connection ${connectionId} not found`);
|
||||
if (!isPublishingProvider(String(connection.rows[0]?.provider))) throw new BadRequestException("provider is not enabled for publishing");
|
||||
if (!(await userCanUseConnection(client, workspaceId, actor.userId, connectionId))) {
|
||||
throw new ForbiddenException("connection is not shared with any of your groups");
|
||||
}
|
||||
|
||||
// Publish-phase gate: a gated connection schedules only once the admins'
|
||||
// publish poll for the current approved revision has passed.
|
||||
const governance = readPublishGovernance(connection.rows[0]?.fields);
|
||||
if (isPublishGateMode(governance)) {
|
||||
const gate = await checkPublishGate(client, {
|
||||
workspaceId, draftId, connectionId, mode: governance, requestedBy: actor.userId, publishAt, requiredPermission,
|
||||
});
|
||||
if (gate.kind === "held") {
|
||||
await this.audit(client, workspaceId, actor, "publication.publish_held", draftId, {
|
||||
connectionId, publishGovernance: governance, pollId: gate.hold.pollId, opened: gate.opened,
|
||||
publishAt: publishAt.toISOString(),
|
||||
});
|
||||
return gate.hold;
|
||||
}
|
||||
}
|
||||
|
||||
let attempt;
|
||||
try {
|
||||
attempt = await this.dependencies.repository.scheduleAttempt(client, {
|
||||
|
|
@ -448,6 +508,7 @@ export class PublishingService {
|
|||
});
|
||||
return scheduled;
|
||||
});
|
||||
if (scheduled.state === "awaiting_publish_approval") return scheduled;
|
||||
await this.dependencies.workflowStarter
|
||||
.startScheduledPublish({ workspaceId, scheduleId: scheduled.id, generation: scheduled.generation, idempotencyKey: scheduled.idempotencyKey }, publishAt)
|
||||
.catch(() => undefined);
|
||||
|
|
@ -500,6 +561,8 @@ export class PublishingService {
|
|||
/** E1 seam: runs inside the transition's transaction, under the draft row
|
||||
* lock, before the UPDATE — may abort the whole transition by throwing. */
|
||||
afterLock?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
||||
/** Runs after the UPDATE + audit, same transaction (publish governance). */
|
||||
afterTransition?: (client: PoolClient, draft: DraftRecord) => Promise<void>,
|
||||
): Promise<DraftRecord> {
|
||||
await this.dependencies.assertReady();
|
||||
return this.dependencies.tenantTransaction(workspaceId, async (client) => {
|
||||
|
|
@ -521,6 +584,7 @@ export class PublishingService {
|
|||
const draft = draftFromRow(requiredRow(result.rows[0], "transitioned draft"));
|
||||
if (to === "in_review") await autoOpenDraftPoll(client, workspaceId, draftId, actor, draft.post);
|
||||
await this.audit(client, workspaceId, actor, action, draftId, diff);
|
||||
if (afterTransition !== undefined) await afterTransition(client, draft);
|
||||
return draft;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,36 @@ describe("E3 pre-dispatch evaluation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("E3 publish-phase gate (connections.fields.publishGovernance)", () => {
|
||||
for (const mode of ["admin1", "admin_majority", "admin_consensus"]) {
|
||||
it(`${mode}: holds until the publish poll for the approved revision passed`, () => {
|
||||
for (const pending of [null, "open", "failed", "vetoed", "quorum_unreachable", "stale"]) {
|
||||
const outcome = evaluateE3(row({ publish_governance: mode, publish_poll_state: pending }));
|
||||
expect(outcome.outcome).toBe("blocked");
|
||||
expect(outcome.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
|
||||
expect(outcome.pollState).toBe(pending ?? "none");
|
||||
}
|
||||
expect(evaluateE3(row({ publish_governance: mode, publish_poll_state: "passed" })).outcome).toBe("fired");
|
||||
});
|
||||
}
|
||||
|
||||
it("auto and unset connections need no publish poll", () => {
|
||||
expect(evaluateE3(row({ publish_governance: "auto", publish_poll_state: null })).outcome).toBe("fired");
|
||||
expect(evaluateE3(row({ publish_governance: null, publish_poll_state: null })).outcome).toBe("fired");
|
||||
expect(evaluateE3(row({ publish_governance: "unknown-mode", publish_poll_state: null })).outcome).toBe("fired");
|
||||
});
|
||||
|
||||
it("a revoked approval still wins over a passed publish poll", () => {
|
||||
const outcome = evaluateE3(row({ state: "editing", publish_governance: "admin1", publish_poll_state: "passed" }));
|
||||
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
|
||||
});
|
||||
|
||||
it("a passed publish poll does not bypass the snapshot fail-closed posture", () => {
|
||||
const outcome = evaluateE3(row({ effective: null, policy_version: null, publish_governance: "admin1", publish_poll_state: "passed" }));
|
||||
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reason-code vocabulary mirror", () => {
|
||||
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
|
||||
expect(E3_REASON_CODES).toEqual([
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ export type ReasonCode = (typeof E3_REASON_CODES)[number];
|
|||
|
||||
export const E3_ACTOR = "system:gate-e3";
|
||||
|
||||
/**
|
||||
* Publish-phase gate modes (connections.fields.publishGovernance), mirrored
|
||||
* from apps/api/src/publishing/publish-governance.ts across the app package
|
||||
* boundary. "auto" and unset need no publish poll.
|
||||
*/
|
||||
export const PUBLISH_GATE_MODES = ["admin1", "admin_majority", "admin_consensus"] as const;
|
||||
|
||||
/** DB column order for the re-check query. */
|
||||
interface E3Row {
|
||||
state: string | null;
|
||||
|
|
@ -58,6 +65,10 @@ interface E3Row {
|
|||
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
|
||||
policy_version: string | null;
|
||||
poll_state: string | null;
|
||||
/** connections.fields->>'publishGovernance' of the attempt's destination. */
|
||||
publish_governance?: string | null;
|
||||
/** Latest publish poll for (draft, destination, approved revision). */
|
||||
publish_poll_state?: string | null;
|
||||
}
|
||||
|
||||
export interface E3Outcome {
|
||||
|
|
@ -87,6 +98,22 @@ export function evaluateE3(row: E3Row | undefined): E3Outcome {
|
|||
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
|
||||
}
|
||||
|
||||
// 1b) Publish-phase gate: a gated destination dispatches only after the
|
||||
// admins' publish poll for THIS approved revision passed. Attempts are
|
||||
// normally created by that pass; this re-check also catches schedules
|
||||
// made before the connection was switched to a gate mode.
|
||||
if ((PUBLISH_GATE_MODES as readonly string[]).includes(row.publish_governance ?? "")
|
||||
&& row.publish_poll_state !== "passed") {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
|
||||
policyVersion: row.policy_version ?? "none",
|
||||
mode: row.effective?.mode ?? "none",
|
||||
approvedHash,
|
||||
pollState: row.publish_poll_state ?? "none",
|
||||
};
|
||||
}
|
||||
|
||||
// 2) A compiled snapshot exists for this destination. None => fail closed
|
||||
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
|
||||
if (row.effective === null || row.policy_version === null) {
|
||||
|
|
@ -159,7 +186,12 @@ export class DispatchGateService {
|
|||
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
|
||||
s.effective, s.policy_version,
|
||||
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
|
||||
ORDER BY p.created_at DESC LIMIT 1) AS poll_state
|
||||
AND p.subject_kind <> 'draft_publish'
|
||||
ORDER BY p.created_at DESC LIMIT 1) AS poll_state,
|
||||
(SELECT c.fields->>'publishGovernance' FROM connections c WHERE c.id = $2::uuid) AS publish_governance,
|
||||
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.subject_kind = 'draft_publish'
|
||||
AND p.publish_connection_id = $2::uuid AND p.draft_revision = d.approved_revision
|
||||
ORDER BY p.created_at DESC LIMIT 1) AS publish_poll_state
|
||||
FROM drafts d
|
||||
LEFT JOIN posting_policy_snapshots s
|
||||
ON s.workspace_id = d.workspace_id
|
||||
|
|
@ -168,7 +200,8 @@ export class DispatchGateService {
|
|||
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
|
||||
WHERE d.id = (SELECT draft_id FROM attempt)
|
||||
)
|
||||
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state FROM target`,
|
||||
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state,
|
||||
publish_governance, publish_poll_state FROM target`,
|
||||
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
|
||||
);
|
||||
const row = result.rows[0] as E3Row | undefined;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ describe("E3 gate in the dispatch path", () => {
|
|||
expect(h.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publish gate pending (admin poll not passed): known no-send with zero HTTP requests", async () => {
|
||||
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: "open" });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_INSUFFICIENT_QUORUM" });
|
||||
expect(h.fetchImpl).not.toHaveBeenCalled();
|
||||
expect(h.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gate evaluated per destination with the lease's idempotency key", async () => {
|
||||
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
|
||||
const h = harness({ kind: "claimed", job }, gate);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Badge, Button } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
|
|
@ -9,7 +9,7 @@ import { api, errorMessage } from "../../../lib/api-client";
|
|||
import { PLATFORMS } from "../../../lib/platforms";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
|
||||
import type { ConnectionSummary, DraftRecord, PublicationsResponse } from "../../../lib/api-types";
|
||||
|
||||
/**
|
||||
* Calendar = the scheduled slice of the publishing pipeline. There is no
|
||||
|
|
@ -119,6 +119,19 @@ function relativeLabel(iso: string, now: number): { text: string; overdue: boole
|
|||
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest dispatch receipt wins: the API orders publications by attempt
|
||||
* number descending, and a retry's outcome supersedes the earlier attempt.
|
||||
*/
|
||||
function latestPublication(response: PublicationsResponse | null | undefined): {
|
||||
state: string;
|
||||
remoteUrl: string | null;
|
||||
} | null {
|
||||
const latest = response?.publications[0];
|
||||
if (latest === undefined) return null;
|
||||
return { state: latest.state, remoteUrl: latest.remoteUrl ?? null };
|
||||
}
|
||||
|
||||
export default function CalendarPage(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
||||
|
|
@ -134,18 +147,46 @@ export default function CalendarPage(): React.ReactElement {
|
|||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
|
||||
// Dispatch receipts per draft: one GET per scheduled draft (the API has no
|
||||
// bulk endpoint), refreshed with the list. Only drafts that actually have
|
||||
// publications surface a badge.
|
||||
const [publications, setPublications] = useState<Record<string, PublicationsResponse | null>>({});
|
||||
const fetchPublications = useCallback((ids: string[]) => {
|
||||
if (!live || ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
void api
|
||||
.get<PublicationsResponse>(`/publishing/drafts/${id}/publications`)
|
||||
.then((response) => setPublications((current) => ({ ...current, [id]: response })))
|
||||
.catch(() => setPublications((current) => ({ ...current, [id]: null })));
|
||||
}
|
||||
}, [live]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
|
||||
// The scheduled set as a stable string so the fetch effect below only fires
|
||||
// when it actually changes, and the timer below always reads the latest ids.
|
||||
const draftIds = rows.map((row) => row.id);
|
||||
const draftIdsRef = useRef<string[]>([]);
|
||||
draftIdsRef.current = draftIds;
|
||||
const draftIdsKey = draftIds.join(",");
|
||||
|
||||
// Fetch receipts on load and whenever the scheduled set changes (a new
|
||||
// schedule appears, or one is cancelled).
|
||||
useEffect(() => {
|
||||
fetchPublications(draftIdsKey === "" ? [] : draftIdsKey.split(","));
|
||||
}, [fetchPublications, draftIdsKey]);
|
||||
|
||||
// Same cadence as the streams wall: refresh on a 30s timer.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
drafts.reload();
|
||||
connections.reload();
|
||||
fetchPublications(draftIdsRef.current);
|
||||
setNow(Date.now());
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [drafts.reload, connections.reload]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
}, [drafts.reload, connections.reload, fetchPublications]);
|
||||
|
||||
async function cancel(row: ScheduledRow): Promise<void> {
|
||||
if (!live) {
|
||||
|
|
@ -176,13 +217,14 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<span className="ml-auto text-2xs text-text-faint">
|
||||
{rows.length} scheduled · refreshes every 30s
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); fetchPublications(draftIdsRef.current); setNow(Date.now()); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
||||
UTC time beside it; execution state lives in the audit log, not this list.
|
||||
UTC time beside it; drafts with dispatch receipts show their latest outcome, with the remote link when the
|
||||
provider returned one.
|
||||
</p>
|
||||
|
||||
{notice !== null && (
|
||||
|
|
@ -214,6 +256,7 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
||||
{group.rows.map((row) => {
|
||||
const relative = relativeLabel(row.publishAt, now);
|
||||
const latest = latestPublication(publications[row.id]);
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
||||
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
||||
|
|
@ -225,6 +268,16 @@ export default function CalendarPage(): React.ReactElement {
|
|||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
||||
<Badge tone="accent">scheduled</Badge>
|
||||
{latest !== null && (
|
||||
<Badge tone={latest.state === "completed" ? "live" : latest.state === "failed" ? "danger" : "warn"}>
|
||||
{latest.state.replace(/_/g, " ")}
|
||||
{latest.remoteUrl !== null && (
|
||||
<a href={latest.remoteUrl} target="_blank" rel="noreferrer" className="underline decoration-current/40 underline-offset-2 hover:decoration-current">
|
||||
open ↗
|
||||
</a>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{row.targetLabel}</span>
|
||||
{!row.fromScheduledAt && (
|
||||
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
||||
|
|
|
|||
|
|
@ -164,3 +164,26 @@ export interface ScheduledPublicationRecord {
|
|||
publishAt: string;
|
||||
state: "pending" | "in_flight" | "done" | "failed";
|
||||
}
|
||||
|
||||
/** One dispatch attempt for a draft, newest first — GET /publishing/drafts/:draftId/publications. */
|
||||
export interface DraftPublication {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
attemptNo: string;
|
||||
dispatchCount: number;
|
||||
state: "ready" | "in_progress" | "completed" | "failed" | "unknown" | "abandoned";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
firstDispatchStartedAt?: string;
|
||||
dispatchStartedAt?: string;
|
||||
providerRetryDeadline?: string;
|
||||
receivedAt?: string;
|
||||
publishedAt?: string;
|
||||
remoteId?: string;
|
||||
remoteUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PublicationsResponse {
|
||||
publications: DraftPublication[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue