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.
392 lines
30 KiB
TypeScript
392 lines
30 KiB
TypeScript
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import type { PoolClient } from 'pg';
|
|
import { withTenant } from '../db/client.js';
|
|
import { parseId, requireObject, requireString } from '../http/validation.js';
|
|
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';
|
|
|
|
export interface PollActor { userId: string; role?: string }
|
|
export interface PollVoter { kind: 'user' | 'chat_identity'; userId?: string; provider?: string; remoteId?: string; handle?: string }
|
|
interface PollRow {
|
|
id: string; workspace_id: string; draft_id: string | null; draft_revision: string | null;
|
|
channel_id: string | null; topic_binding_id: string | null; question: string;
|
|
kind: 'yes_no' | 'variant_selection'; state: string; rule: PolicyRule; policy_version: string;
|
|
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;
|
|
ballot: EvaluatedBallot['ballot']; option_id: string | null; counted: boolean; eligible: boolean; ineligible_reason: string | null }
|
|
export interface PollCard {
|
|
poll: PollRow; options: OptionRow[]; evaluation: PollEvaluation;
|
|
votes: VoteRow[]; voterEligibility: { eligible: boolean; admin: boolean; reason: string | null } | null;
|
|
}
|
|
|
|
/** All writes use workspace → draft → poll lock order, matching publishing
|
|
* and membership epoch updates. The outbox claim and E2 transition share one
|
|
* transaction; rollback also releases the claim. Late ballots are no-ops. */
|
|
@Injectable()
|
|
export class PollService {
|
|
async open(workspaceId: string, body: unknown, actor: PollActor): Promise<PollCard> {
|
|
return withTenant(workspaceId, async client => {
|
|
await lockWorkspace(client, workspaceId);
|
|
const poll = await openPollInTransaction(client, workspaceId, body, actor);
|
|
return card(client, poll, actor.userId);
|
|
});
|
|
}
|
|
async forkEdit(workspaceId: string, pollId: string, body: unknown, actor: PollActor): Promise<PollCard> {
|
|
const dto = requireObject(body, 'body'); const text = variantText(dto.text);
|
|
return this.locked(workspaceId, pollId, async (client, poll) => {
|
|
await requireVoter(client, workspaceId, actor.userId);
|
|
await evaluateLocked(client, poll);
|
|
if (poll.state !== 'open' || poll.kind !== 'variant_selection') throw new ConflictException('only open variant polls accept forks');
|
|
const seq = Number((await client.query('SELECT COALESCE(max(seq), 0) + 1 AS seq FROM poll_options WHERE poll_id = $1', [poll.id])).rows[0]?.seq);
|
|
await insertOption(client, poll, seq, text, actor.userId);
|
|
await audit(client, poll, 'poll.option_submitted', actor.userId, { seq });
|
|
return card(client, poll, actor.userId);
|
|
});
|
|
}
|
|
async vote(workspaceId: string, pollId: string, body: unknown, voter: PollVoter): Promise<PollCard> {
|
|
const dto = requireObject(body, 'body');
|
|
const optionId = dto.optionId === undefined || dto.optionId === null ? null : parseId(requireString(dto.optionId, 'optionId'));
|
|
const ballot = dto.ballot ?? (optionId ? 'yes' : undefined);
|
|
if (!['yes', 'no', 'veto', 'abstain'].includes(String(ballot))) throw new BadRequestException('invalid ballot');
|
|
return this.locked(workspaceId, pollId, async (client, poll) => {
|
|
// Expiry is resolved before accepting a late ballot; retries after close
|
|
// render the frozen artifact without changing the decision.
|
|
if (new Date() >= poll.closes_at) await evaluateLocked(client, poll);
|
|
if (poll.state !== 'open') return card(client, poll, voter.userId);
|
|
if (poll.kind === 'variant_selection' && ballot === 'yes' && optionId === null) throw new BadRequestException('variant vote requires optionId');
|
|
if (optionId && (poll.kind !== 'variant_selection' || ballot !== 'yes')) throw new BadRequestException('optionId only applies to a variant choice');
|
|
if (optionId && !(await client.query('SELECT id FROM poll_options WHERE id = $1 AND poll_id = $2', [optionId, poll.id])).rowCount) throw new BadRequestException('option does not belong to this poll');
|
|
if (ballot === 'veto' && !poll.rule.allowVeto) throw new BadRequestException('veto is disabled');
|
|
let userId = voter.userId ?? null;
|
|
let key: string;
|
|
let identityId: string | null = null;
|
|
if (voter.kind === 'user') {
|
|
if (!userId) throw new BadRequestException('user voter needs userId');
|
|
parseId(userId, 'voter.userId'); key = `user:${userId}`;
|
|
} else {
|
|
const provider = requireString(voter.provider, 'provider');
|
|
const remote = requireString(voter.remoteId, 'remoteId');
|
|
if (!/^[0-9]{1,20}$/.test(remote) || !/^[a-z0-9_-]+$/.test(provider)) throw new BadRequestException('invalid immutable provider identity');
|
|
key = `${provider}:${remote}`;
|
|
// Never trust a supplied linked user id from the gateway.
|
|
const identity = (await client.query("SELECT id, user_id, status FROM chat_identities WHERE provider = $1 AND remote_user_id = $2", [provider, remote])).rows[0];
|
|
identityId = identity?.id as string | undefined ?? null;
|
|
userId = identity?.status === 'approved' ? identity.user_id as string | null : null;
|
|
}
|
|
// Linked identities and web users share ONE ballot. Alternate provider
|
|
// accounts cannot inflate turnout. Unlinked ballots are still retained.
|
|
if (userId) key = `user:${userId}`;
|
|
await client.query(`INSERT INTO poll_votes (workspace_id, poll_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, eligible, counted, voter_identity_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, false, $9)
|
|
ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET voter_kind = EXCLUDED.voter_kind,
|
|
voter_user_id = EXCLUDED.voter_user_id, voter_identity_id = EXCLUDED.voter_identity_id, voter_handle = EXCLUDED.voter_handle,
|
|
ballot = EXCLUDED.ballot, option_id = EXCLUDED.option_id, cast_at = clock_timestamp()`,
|
|
[workspaceId, poll.id, voter.kind, userId, key, voter.handle ?? key, ballot, optionId, identityId]);
|
|
await audit(client, poll, 'poll.voted', userId, { voter: key, ballot, optionId });
|
|
await evaluateLocked(client, poll);
|
|
return card(client, poll, userId ?? undefined);
|
|
});
|
|
}
|
|
async get(workspaceId: string, pollId: string, userId?: string): Promise<PollCard> {
|
|
return this.locked(workspaceId, pollId, async (client, poll) => {
|
|
await evaluateLocked(client, poll); return card(client, poll, userId);
|
|
});
|
|
}
|
|
async evaluate(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
|
|
/** Deliberately re-evaluates the authoritative conditions; callers cannot
|
|
* force a pass or supply a stale tally. Suitable for competing timeboxes. */
|
|
async close(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
|
|
async sweep(workspaceId: string): Promise<void> {
|
|
const ids = await withTenant(workspaceId, c => c.query<{ id: string }>("SELECT id FROM polls WHERE state = 'open' AND (closes_at <= now() OR opens_at + COALESCE((rule->>'minDwellSeconds')::integer,0) * interval '1 second' <= now())", []));
|
|
for (const { id } of ids.rows) await this.evaluate(workspaceId, id);
|
|
}
|
|
private async locked<T>(workspaceId: string, pollId: string, fn: (client: PoolClient, poll: PollRow) => Promise<T>): Promise<T> {
|
|
parseId(pollId, 'pollId');
|
|
return withTenant(workspaceId, async client => {
|
|
await lockWorkspace(client, workspaceId);
|
|
const found = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1', [pollId])).rows[0];
|
|
if (!found) throw new NotFoundException('poll not found');
|
|
if (found.draft_id) await client.query('SELECT id FROM drafts WHERE id = $1 FOR UPDATE', [found.draft_id]);
|
|
const poll = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1 FOR UPDATE', [pollId])).rows[0]!;
|
|
return fn(client, poll);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function lockWorkspace(client: PoolClient, workspaceId: string): Promise<void> {
|
|
await client.query('SELECT id FROM workspaces WHERE id = $1 FOR UPDATE', [workspaceId]);
|
|
}
|
|
async function members(client: PoolClient, workspaceId: string): Promise<Member[]> {
|
|
return (await client.query<Member>(`SELECT m.user_id, m.role, EXISTS(SELECT 1 FROM role_permissions rp
|
|
WHERE rp.role = m.role AND rp.permission = 'chatgov.vote') AS can_vote
|
|
FROM memberships m WHERE workspace_id = $1 AND status = 'active'`, [workspaceId])).rows;
|
|
}
|
|
async function requireVoter(client: PoolClient, workspaceId: string, userId: string): Promise<void> {
|
|
parseId(userId, 'actor.userId');
|
|
if (!(await members(client, workspaceId)).some(m => m.user_id === userId && m.can_vote)) throw new ForbiddenException('chatgov.vote required');
|
|
}
|
|
function variantText(value: unknown): string {
|
|
const text = requireString(value, 'text');
|
|
if (text.length > 5000) throw new BadRequestException('variant text must be at most 5000 characters');
|
|
return text;
|
|
}
|
|
async function insertOption(client: PoolClient, poll: PollRow, seq: number, text: string, userId: string): Promise<void> {
|
|
await client.query('INSERT INTO poll_options (workspace_id, poll_id, seq, text, submitted_by) VALUES ($1,$2,$3,$4,$5)', [poll.workspace_id, poll.id, seq, text, userId]);
|
|
}
|
|
async function options(client: PoolClient, pollId: string): Promise<OptionRow[]> {
|
|
return (await client.query<OptionRow>(`SELECT o.*, u.display_name AS author FROM poll_options o
|
|
JOIN users u ON u.id = o.submitted_by WHERE poll_id = $1 ORDER BY seq`, [pollId])).rows;
|
|
}
|
|
async function audit(client: PoolClient, poll: PollRow, action: string, userId: string | null, diff: Record<string, unknown>): Promise<void> {
|
|
await client.query("SELECT audit_append($1,$2,$3,$4,'poll',$5,$6)", [poll.workspace_id, userId, userId ? 'Member' : 'System', action, poll.id,
|
|
scrub({ ...diff, policyVersion: poll.policy_version, actor: userId ? `user:${userId}` : `system:chat-poll-gate:${poll.id}` })]);
|
|
}
|
|
|
|
/** Snapshot all applicable arms. Different OR-policy groups cannot be
|
|
* flattened into a single §1a object without changing their meaning. */
|
|
export async function draftPollRule(client: PoolClient, draft: { post: DraftPost }, supplied?: unknown): Promise<PolicyRule | null> {
|
|
const accountIds = draftDestinationAccounts(draft.post);
|
|
const live = await client.query("SELECT 1 FROM posting_rules WHERE superseded_by IS NULL LIMIT 1");
|
|
const rules: PolicyRule[] = [];
|
|
if (accountIds.length && live.rowCount) {
|
|
for (const account of accountIds) {
|
|
const topic = typeof draft.post.providerMetadata.topicBindingId === 'string' ? draft.post.providerMetadata.topicBindingId : null;
|
|
const snapshot = (await client.query(`SELECT effective FROM posting_policy_snapshots WHERE account_id = $1
|
|
AND content_type = '' AND (topic_binding_id IS NULL OR topic_binding_id = $2)
|
|
ORDER BY topic_binding_id NULLS LAST LIMIT 1`, [account, topic])).rows[0];
|
|
if (!snapshot) throw new ConflictException('OUTSIDE_POLICY: destination snapshot missing');
|
|
const rule = parsePolicyRule(snapshot.effective);
|
|
if (rule.mode !== 'immediate' && !rule.dryRun) rules.push(rule);
|
|
}
|
|
}
|
|
let rule: PolicyRule | null = rules[0] ?? null;
|
|
if (rules.some(r => policyHash(r) !== policyHash(rule))) {
|
|
if (rules.some(r => r.join === 'any')) throw new ConflictException('incompatible OR policy groups require independent polls');
|
|
rule = parsePolicyRule({ ...rules[0], mode: 'quorum', join: 'all',
|
|
arms: rules.flatMap<QuorumArm>(r => r.mode === 'admin_only' ? [{ kind: 'admins', x: 1 }] : r.arms ?? []).filter((a, i, all) => all.findIndex(b => b.kind === a.kind && b.of === a.of && b.x === a.x) === i),
|
|
allowVeto: rules.some(r => r.allowVeto), allowSelfApproval: rules.every(r => r.allowSelfApproval),
|
|
resetOnChange: rules.some(r => r.resetOnChange), minDwellSeconds: Math.max(...rules.map(r => r.minDwellSeconds ?? 0)),
|
|
timebox: { duration: rules.reduce((a,b) => durationMs(a.timebox!.duration) >= durationMs(b.timebox!.duration) ? a : b).timebox!.duration, onExpire: 'reject' } });
|
|
}
|
|
if (supplied !== undefined) {
|
|
const requested = parsePolicyRule(supplied);
|
|
if (rule && policyHash(rule) !== policyHash(requested)) throw new BadRequestException('rule must match the compiled destination policy');
|
|
rule ??= requested;
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
export async function openPollInTransaction(client: PoolClient, workspaceId: string, body: unknown, actor: PollActor): Promise<PollRow> {
|
|
const dto = requireObject(body, 'body');
|
|
await requireVoter(client, workspaceId, actor.userId);
|
|
if (dto.contentKind !== undefined && dto.contentKind !== 'short_post') throw new BadRequestException('document polls are a future track');
|
|
const draftId = dto.draftId == null ? null : parseId(requireString(dto.draftId, 'draftId'));
|
|
const draft = draftId ? (await client.query('SELECT * FROM drafts WHERE id = $1 FOR UPDATE', [draftId])).rows[0] : null;
|
|
if (draftId && !draft) throw new NotFoundException('draft not found');
|
|
if (draft && !['editing', 'in_review'].includes(String(draft.state))) throw new ConflictException('draft is not reviewable');
|
|
const rule = draft ? await draftPollRule(client, draft as { post: DraftPost }, dto.rule) : parsePolicyRule(dto.rule);
|
|
if (!rule) throw new BadRequestException('a rule or compiled chat-signal policy is required');
|
|
if (rule.anonymity !== 'named') throw new BadRequestException('anonymous sentiment belongs to the separate Telegram track; governance needs attributable ballots');
|
|
const kind = dto.kind ?? (draft ? 'variant_selection' : 'yes_no');
|
|
if (kind !== 'yes_no' && kind !== 'variant_selection') throw new BadRequestException('invalid poll kind');
|
|
if (kind === 'variant_selection' && !draft) throw new BadRequestException('variant selection requires a draft');
|
|
if (dto.variants !== undefined && !Array.isArray(dto.variants)) throw new BadRequestException('variants must be an array');
|
|
const variants = (dto.variants as unknown[] | undefined ?? []).map(v => variantText(typeof v === 'string' ? v : requireObject(v, 'variant').text));
|
|
if (kind === 'yes_no' && variants.length) throw new BadRequestException('yes/no polls do not accept variants');
|
|
if (variants.length > 99) throw new BadRequestException('at most 99 initial forks');
|
|
const eligibleIds = dto.eligibleUserIds === undefined ? null : (Array.isArray(dto.eligibleUserIds) ? dto.eligibleUserIds.map(v => parseId(requireString(v, 'eligibleUserId'))) : (() => { throw new BadRequestException('eligibleUserIds must be an array'); })());
|
|
const question = requireString(dto.question ?? 'Choose the post to approve', 'question');
|
|
if (question.length > 300) throw new BadRequestException('question too long');
|
|
let duration: number;
|
|
try { duration = durationMs(rule.timebox?.duration ?? 'PT1H'); } catch (error) { throw new BadRequestException(String(error)); }
|
|
const now = new Date();
|
|
const poll = (await client.query<PollRow>(`INSERT INTO polls
|
|
(workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at, created_by, draft_revision, parent_poll_id, channel_id, topic_binding_id, eligible_user_ids)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
|
[workspaceId, draftId, draftId ? 'draft' : 'none', question, kind, rule, policyHash(rule), now, new Date(now.getTime() + duration), actor.userId,
|
|
draft?.revision ?? null, dto.parentPollId == null ? null : parseId(requireString(dto.parentPollId, 'parentPollId')),
|
|
dto.channelId == null ? null : parseId(requireString(dto.channelId, 'channelId')),
|
|
dto.topicBindingId == null ? null : parseId(requireString(dto.topicBindingId, 'topicBindingId')), eligibleIds])).rows[0]!;
|
|
if (kind === 'variant_selection') {
|
|
await insertOption(client, poll, 1, variantText((draft!.post as DraftPost).text), String(draft!.author_user_id));
|
|
for (const [i, text] of variants.entries()) await insertOption(client, poll, i + 2, text, actor.userId);
|
|
}
|
|
await audit(client, poll, 'poll.opened', actor.userId, { draftId, kind, parentPollId: poll.parent_poll_id });
|
|
return poll;
|
|
}
|
|
|
|
async function resolveTally(client: PoolClient, poll: PollRow, persist: boolean): Promise<{ evaluation: PollEvaluation; votes: VoteRow[] }> {
|
|
const roster = await members(client, poll.workspace_id);
|
|
const draft = poll.draft_id ? (await client.query('SELECT author_user_id FROM drafts WHERE id = $1', [poll.draft_id])).rows[0] : null;
|
|
const opts = await options(client, poll.id);
|
|
const votes = (await client.query<VoteRow & { identity_status: string | null; identity_user_id: string | null; role_override: string | null; cast_at: Date; voter_identity_id: string | null }>(`SELECT v.*, ci.status AS identity_status, ci.user_id AS identity_user_id, ci.role_override
|
|
FROM poll_votes v LEFT JOIN chat_identities ci ON ci.id = v.voter_identity_id
|
|
WHERE poll_id = $1 ORDER BY cast_at DESC, voter_remote_id`, [poll.id])).rows;
|
|
// Ingress currently persists handles, not immutable remote ids. Only
|
|
// numeric author_handle evidence can bind to approved identities safely.
|
|
// A username must never become an authorization key.
|
|
const observed = new Set<string>();
|
|
if (poll.channel_id) {
|
|
const rows = await client.query<{ user_id: string }>(`SELECT DISTINCT ci.user_id FROM live_chat_messages lm
|
|
JOIN chat_identities ci ON ci.provider = lm.provider AND ci.remote_user_id = lm.author_handle
|
|
WHERE lm.channel_id = $1 AND lm.received_at >= $2 AND ci.status = 'approved' AND ci.user_id IS NOT NULL`, [poll.channel_id, poll.opens_at]);
|
|
for (const row of rows.rows) observed.add(row.user_id);
|
|
}
|
|
const seen = new Set<string>();
|
|
const ballots: EvaluatedBallot[] = [];
|
|
const eligibleMembers = roster.filter(m => m.can_vote);
|
|
const identities = (await client.query<{ user_id: string; role_override: string }>("SELECT user_id, role_override FROM chat_identities WHERE status = 'approved' AND role_override IN ('Admin','Owner')")).rows;
|
|
const adminIds = new Set(roster.filter(m => m.can_vote && ['Admin', 'Owner'].includes(m.role)).map(m => m.user_id));
|
|
for (const identity of identities) if (roster.some(m => m.user_id === identity.user_id)) adminIds.add(identity.user_id);
|
|
for (const vote of votes) {
|
|
const identityOkay = !vote.voter_identity_id || (vote.identity_status === 'approved' && vote.identity_user_id === vote.voter_user_id);
|
|
const member = roster.find(m => m.user_id === vote.voter_user_id);
|
|
const eligible = identityOkay && member !== undefined && (member.can_vote || ['Admin','Owner'].includes(vote.role_override ?? ''));
|
|
const selfDenied = !poll.rule.allowSelfApproval && vote.ballot === 'yes' && (poll.kind === 'variant_selection'
|
|
? opts.some(o => o.id === vote.option_id && o.submitted_by === vote.voter_user_id)
|
|
: draft?.author_user_id === vote.voter_user_id);
|
|
const duplicate = vote.voter_user_id !== null && seen.has(vote.voter_user_id);
|
|
if (eligible && vote.voter_user_id) seen.add(vote.voter_user_id);
|
|
vote.eligible = eligible;
|
|
vote.counted = eligible && !selfDenied && !duplicate;
|
|
vote.ineligible_reason = !eligible || duplicate ? 'INELIGIBLE_ACTOR' : selfDenied ? 'SELF_APPROVAL_DENIED' : null;
|
|
if (persist) await client.query(`UPDATE poll_votes SET eligible = $3, counted = $4, ineligible_reason = $5
|
|
WHERE poll_id = $1 AND voter_remote_id = $2`, [poll.id, vote.voter_remote_id, vote.eligible, vote.counted, vote.ineligible_reason]);
|
|
ballots.push({ ballot: vote.ballot, optionId: vote.option_id, counted: vote.counted,
|
|
admin: adminIds.has(vote.voter_user_id ?? ''), observed: observed.has(vote.voter_user_id ?? ''),
|
|
participant: poll.eligible_user_ids === null || poll.eligible_user_ids.includes(vote.voter_user_id ?? '') });
|
|
}
|
|
const eligible = eligibleMembers.filter(m => poll.eligible_user_ids === null || poll.eligible_user_ids.includes(m.user_id));
|
|
// Potential self-voters must have at least one option authored by someone
|
|
// else. A no/self ballot can be recast, so it is never subtracted twice.
|
|
const canChoose = (id: string): boolean => poll.rule.allowSelfApproval === true || (poll.kind === 'variant_selection'
|
|
? opts.some(o => o.submitted_by !== id) : draft?.author_user_id !== id);
|
|
const evaluation = evaluatePoll({ kind: poll.kind, rule: poll.rule, opensAt: poll.opens_at, closesAt: poll.closes_at, now: new Date(), ballots,
|
|
options: opts.map(o => ({ id: o.id, seq: o.seq, submittedAt: o.submitted_at.toISOString() })),
|
|
possible: { admins: [...adminIds].filter(canChoose).length, eligible: eligible.filter(m => canChoose(m.user_id)).length, observed: observed.size } });
|
|
// Keep the public DTO separate from identity resolution internals.
|
|
return { evaluation, votes: votes.map(v => ({ voter_kind: v.voter_kind, voter_user_id: v.voter_user_id,
|
|
voter_remote_id: v.voter_remote_id, voter_handle: v.voter_handle, ballot: v.ballot, option_id: v.option_id,
|
|
counted: v.counted, eligible: v.eligible, ineligible_reason: v.ineligible_reason })) };
|
|
}
|
|
|
|
async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void> {
|
|
if (poll.state !== 'open') return;
|
|
if (poll.draft_id && poll.draft_revision !== null) {
|
|
const draft = (await client.query('SELECT revision FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
|
|
if (String(draft?.revision) !== String(poll.draft_revision)) {
|
|
await stalePoll(client, poll); return;
|
|
}
|
|
}
|
|
const { evaluation } = await resolveTally(client, poll, true);
|
|
await audit(client, poll, 'policy.evaluated', null, { evaluation });
|
|
// Shadow polls retain their artifact and terminal outcome but cannot
|
|
// approve/reject drafts. Their E2 decision is pending, never a release.
|
|
if (!evaluation.state) return;
|
|
const claimed = await client.query(`UPDATE polls SET outbox_state = 'claimed', state = $2, closed_at = clock_timestamp(),
|
|
close_reason = $3, winner_option_id = $4, final_evaluation = $5
|
|
WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`,
|
|
[poll.id, evaluation.state, evaluation.reason,
|
|
evaluation.state === 'passed' && poll.kind === 'variant_selection' ? evaluation.winnerOptionId : null, evaluation]);
|
|
if (!claimed.rowCount) return;
|
|
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 && 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 }),
|
|
reason: reasons[0] ?? 'quorum_early', escalate: evaluation.reason === 'timebox' && poll.rule.timebox?.onExpire === 'escalateOwner' });
|
|
}
|
|
await recordE2(client, poll, evaluation, poll.rule.dryRun ? ['POLICY_DRY_RUN'] : reasons);
|
|
await audit(client, poll, 'poll.closed', null, { state: poll.state, reason: poll.close_reason, winnerOptionId: poll.winner_option_id, evaluation });
|
|
await audit(client, poll, evaluation.state === 'passed' && !poll.rule.dryRun ? 'release.fired' : 'release.blocked', null, { reasons });
|
|
await client.query("UPDATE polls SET outbox_state = 'done' WHERE id = $1 AND outbox_state = 'claimed'", [poll.id]);
|
|
poll.outbox_state = 'done';
|
|
}
|
|
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];
|
|
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, ...(isPublishPoll(poll) ? { phase: 'publish' } : {}) }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
|
|
}
|
|
}
|
|
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
|
|
const final = (poll as PollRow & { final_evaluation?: PollEvaluation }).final_evaluation;
|
|
const tally = final && poll.state !== 'open'
|
|
? { evaluation: final, votes: (await client.query<VoteRow>('SELECT voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, counted, eligible, ineligible_reason FROM poll_votes WHERE poll_id = $1 ORDER BY cast_at', [poll.id])).rows }
|
|
: await resolveTally(client, poll, false);
|
|
const member = userId ? (await members(client, poll.workspace_id)).find(m => m.user_id === userId) : undefined;
|
|
return { poll, options: await options(client, poll.id), evaluation: tally.evaluation, votes: tally.votes,
|
|
voterEligibility: userId ? { eligible: member?.can_vote ?? false, admin: member !== undefined && ['Admin','Owner'].includes(member.role), reason: member?.can_vote ? null : 'INELIGIBLE_ACTOR' } : null };
|
|
}
|
|
|
|
async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
|
|
const { evaluation } = await resolveTally(client, poll, true);
|
|
const changed = await client.query(`UPDATE polls SET state = 'stale', closed_at = clock_timestamp(), close_reason = 'reset_on_change',
|
|
outbox_state = 'done', final_evaluation = $2 WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`, [poll.id, evaluation]);
|
|
if (!changed.rowCount) return;
|
|
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']);
|
|
// 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,
|
|
parentPollId: poll.id, channelId: poll.channel_id, topicBindingId: poll.topic_binding_id,
|
|
...(poll.eligible_user_ids ? { eligibleUserIds: poll.eligible_user_ids } : {}),
|
|
}, { userId: await rearmActor(client, poll) });
|
|
} else {
|
|
// Stale content can never be auto-approved, even with an approve expiry
|
|
// policy. escalateOwner keeps it in review; all other stale gates reject.
|
|
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
|
|
passed: false, reason: 'RESET_ON_CHANGE', escalate: poll.rule.timebox?.onExpire === 'escalateOwner' });
|
|
}
|
|
}
|
|
async function rearmActor(client: PoolClient, poll: PollRow): Promise<string> {
|
|
const roster = await members(client, poll.workspace_id);
|
|
const actor = roster.find(m => m.user_id === poll.created_by && m.can_vote) ?? roster.find(m => m.can_vote && ['Owner','Admin'].includes(m.role));
|
|
if (!actor) throw new ConflictException('cannot re-arm without an eligible opener');
|
|
return actor.user_id;
|
|
}
|
|
/** Called under publishing's workspace/draft lock, after its revision trigger.
|
|
* The old options and ballots stay attached to the stale artifact forever. */
|
|
export async function markDraftPollsStale(client: PoolClient, workspaceId: string, draftId: string): Promise<void> {
|
|
const polls = (await client.query<PollRow>("SELECT * FROM polls WHERE workspace_id = $1 AND draft_id = $2 AND state = 'open' FOR UPDATE", [workspaceId, draftId])).rows;
|
|
for (const poll of polls) await evaluateLocked(client, poll);
|
|
}
|
|
/** Auto-arm on submit, using ONLY compiled destination snapshots. */
|
|
export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string, draftId: string, actor: PollActor, post: DraftPost): Promise<void> {
|
|
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' AND subject_kind <> 'draft_publish'", [draftId])).rowCount) return;
|
|
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
|
|
}
|