feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 00:09:34 +02:00
parent 5a21067e44
commit c2f083e5f6
25 changed files with 2315 additions and 24 deletions
@@ -0,0 +1,426 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { MessageSquare, ShieldCheck, UserRound } from "lucide-react";
import {
Button,
Card,
ConfirmDialog,
DismissibleAlert,
FormField,
PageActionBar,
PageLayout,
ReferenceMultiSelect,
StatusBadge,
hasScope
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo } from "../../types";
import {
campaignCollaborationMentionProvider,
createCampaignCollaborationEntry,
listCampaignCollaboration,
redactCampaignCollaborationEntry,
withdrawCampaignCollaborationEntry,
type CampaignCollaborationCreate,
type CampaignCollaborationEntry,
type CampaignCollaborationReferenceKind
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
type PendingTombstone = {
action: "withdraw" | "redact";
entry: CampaignCollaborationEntry;
} | null;
export default function CampaignCollaborationPage({
settings,
auth,
campaignId
}: {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
}) {
const workspace = useCampaignWorkspaceData(settings, campaignId);
const [entries, setEntries] = useState<CampaignCollaborationEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loadingThread, setLoadingThread] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [content, setContent] = useState("");
const [visibility, setVisibility] = useState<CampaignCollaborationEntry["visibility"]>("collaborators");
const [mentionUserIds, setMentionUserIds] = useState<string[]>([]);
const [referenceKind, setReferenceKind] = useState<CampaignCollaborationReferenceKind | "">("");
const [referenceId, setReferenceId] = useState("");
const [posting, setPosting] = useState(false);
const [pendingTombstone, setPendingTombstone] = useState<PendingTombstone>(null);
const [tombstoneBusy, setTombstoneBusy] = useState(false);
const canPost = hasScope(auth, "campaigns:discussion:post");
const canModerate = hasScope(auth, "campaigns:discussion:moderate");
const mentionProvider = useMemo(
() => campaignCollaborationMentionProvider(settings, campaignId),
[campaignId, settings]
);
const loadThread = useCallback(async () => {
setLoadingThread(true);
setError("");
try {
const response = await listCampaignCollaboration(settings, campaignId);
setEntries(response.items);
setNextCursor(response.next_cursor ?? null);
setHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setLoadingThread(false);
}
}, [campaignId, settings]);
useEffect(() => {
void loadThread();
}, [loadThread]);
async function reload() {
setMessage("");
await Promise.all([loadThread(), workspace.reload({ force: true })]);
}
async function loadOlder() {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
setError("");
try {
const response = await listCampaignCollaboration(settings, campaignId, {
cursor: nextCursor
});
setEntries((current) => [
...current,
...response.items.filter((entry) => !current.some((item) => item.id === entry.id))
]);
setNextCursor(response.next_cursor ?? null);
setHasMore(response.has_more);
} catch (err) {
setError(errorText(err));
} finally {
setLoadingMore(false);
}
}
async function postEntry() {
const cleanContent = content.trim();
if (!cleanContent || posting || (referenceKind && !referenceId.trim())) return;
setPosting(true);
setError("");
setMessage("");
try {
const payload: CampaignCollaborationCreate = {
content: cleanContent,
visibility,
mention_user_ids: mentionUserIds
};
if (referenceKind) {
payload.reference = {
kind: referenceKind,
id: referenceId.trim()
};
}
const created = await createCampaignCollaborationEntry(settings, campaignId, payload);
setEntries((current) => [created, ...current.filter((entry) => entry.id !== created.id)]);
setContent("");
setVisibility("collaborators");
setMentionUserIds([]);
setReferenceKind("");
setReferenceId("");
setMessage("i18n:govoplan-campaign.collaboration_posted");
} catch (err) {
setError(errorText(err));
} finally {
setPosting(false);
}
}
async function applyTombstone() {
if (!pendingTombstone || tombstoneBusy) return;
setTombstoneBusy(true);
setError("");
try {
const updated = pendingTombstone.action === "withdraw"
? await withdrawCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id)
: await redactCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id);
setEntries((current) => current.map((entry) => entry.id === updated.id ? updated : entry));
setMessage(pendingTombstone.action === "withdraw"
? "i18n:govoplan-campaign.collaboration_withdrawn"
: "i18n:govoplan-campaign.collaboration_redacted");
setPendingTombstone(null);
} catch (err) {
setError(errorText(err));
} finally {
setTombstoneBusy(false);
}
}
const referenceNeedsTypedId = Boolean(referenceKind && referenceKind !== "campaign_version");
return (
<PageLayout
archetype="collection"
mode="workspace"
interfaceId="campaigns.page.activity"
helpContextId="campaign.activity"
helpModuleId="campaign"
title="i18n:govoplan-campaign.collaboration"
description="i18n:govoplan-campaign.collaboration_description"
loading={loadingThread || workspace.loading}
loadingLabel="i18n:govoplan-campaign.loading_collaboration"
error={error || workspace.error}
success={message}
actions={
<PageActionBar
variant="collection"
refreshable
reloadAction={{
onReload: () => void reload(),
loading: loadingThread || workspace.loading
}}
/>
}
>
<DismissibleAlert tone="info" resetKey={campaignId}>
i18n:govoplan-campaign.collaboration_audit_boundary
</DismissibleAlert>
{canPost ? (
<Card
title="i18n:govoplan-campaign.new_collaboration_entry"
interfaceId="campaigns.activity.composer"
helpContextId="campaign.activity.composer"
helpModuleId="campaign"
>
<div className="campaign-collaboration-composer">
<FormField
label="i18n:govoplan-campaign.comment"
help="i18n:govoplan-campaign.comment_help"
>
<textarea
rows={5}
maxLength={8000}
value={content}
onChange={(event) => setContent(event.target.value)}
disabled={posting}
/>
</FormField>
<div className="campaign-collaboration-counter" aria-live="polite">
{content.length} / 8000
</div>
<div className="campaign-collaboration-options">
{canModerate ? (
<FormField
label="i18n:govoplan-campaign.visibility"
help="i18n:govoplan-campaign.visibility_help"
>
<select
value={visibility}
onChange={(event) => setVisibility(event.target.value as CampaignCollaborationEntry["visibility"])}
disabled={posting}
>
<option value="collaborators">i18n:govoplan-campaign.visibility_collaborators</option>
<option value="moderators">i18n:govoplan-campaign.visibility_moderators</option>
</select>
</FormField>
) : null}
<FormField
label="i18n:govoplan-campaign.reference_context"
help="i18n:govoplan-campaign.reference_context_help"
>
<select
value={referenceKind}
onChange={(event) => {
setReferenceKind(event.target.value as CampaignCollaborationReferenceKind | "");
setReferenceId("");
}}
disabled={posting}
>
<option value="">i18n:govoplan-campaign.no_reference</option>
<option value="campaign_version">i18n:govoplan-campaign.reference_campaign_version</option>
<option value="recipient_import_batch">i18n:govoplan-campaign.reference_recipient_import</option>
<option value="attachment_rule">i18n:govoplan-campaign.reference_attachment_rule</option>
<option value="delivery_job">i18n:govoplan-campaign.reference_delivery_job</option>
<option value="report">i18n:govoplan-campaign.reference_report</option>
</select>
</FormField>
{referenceKind === "campaign_version" ? (
<FormField label="i18n:govoplan-campaign.campaign_version">
<select
value={referenceId}
onChange={(event) => setReferenceId(event.target.value)}
disabled={posting}
>
<option value="">i18n:govoplan-campaign.select_version</option>
{workspace.data.versions.map((version) => (
<option key={version.id} value={version.id}>
Version {version.version_number}
</option>
))}
</select>
</FormField>
) : null}
{referenceNeedsTypedId ? (
<FormField
label="i18n:govoplan-campaign.stable_reference_id"
help="i18n:govoplan-campaign.stable_reference_id_help"
>
<input
value={referenceId}
maxLength={500}
onChange={(event) => setReferenceId(event.target.value)}
disabled={posting}
/>
</FormField>
) : null}
<FormField
label="i18n:govoplan-campaign.mentions"
help="i18n:govoplan-campaign.mentions_help"
>
<ReferenceMultiSelect
values={mentionUserIds}
onChange={setMentionUserIds}
provider={mentionProvider}
disabled={posting}
aria-label="Mention campaign collaborators"
placeholder="i18n:govoplan-campaign.add_mention"
searchLimit={20}
/>
</FormField>
</div>
<div className="button-row compact-actions campaign-collaboration-submit">
<Button
variant="primary"
onClick={() => void postEntry()}
disabled={posting || !content.trim() || Boolean(referenceKind && !referenceId.trim())}
helpContextId="campaign.activity.action.post"
helpModuleId="campaign"
>
<MessageSquare size={16} aria-hidden="true" />
{posting ? "i18n:govoplan-campaign.posting" : "i18n:govoplan-campaign.post_comment"}
</Button>
</div>
</div>
</Card>
) : (
<DismissibleAlert tone="info" resetKey={`${campaignId}:read-only`}>
i18n:govoplan-campaign.collaboration_read_only
</DismissibleAlert>
)}
<Card title="i18n:govoplan-campaign.discussion" interfaceId="campaigns.activity.thread">
{entries.length === 0 ? (
<p className="muted">i18n:govoplan-campaign.no_collaboration_entries</p>
) : (
<ol className="campaign-collaboration-thread" aria-label="Campaign collaboration thread">
{entries.map((entry) => {
const ownEntry = entry.actor_user_id === auth.user.id;
return (
<li key={entry.id} className="campaign-collaboration-entry">
<article aria-labelledby={`campaign-collaboration-${entry.id}-actor`}>
<header className="campaign-collaboration-entry-header">
<span className="campaign-collaboration-actor" id={`campaign-collaboration-${entry.id}-actor`}>
<UserRound size={16} aria-hidden="true" />
{entry.actor_label}
</span>
<time dateTime={entry.created_at}>{formatDateTime(entry.created_at)}</time>
{entry.visibility === "moderators" ? (
<StatusBadge status="restricted" label="i18n:govoplan-campaign.moderators_only" />
) : null}
</header>
{entry.tombstone ? (
<div className="campaign-collaboration-tombstone" role="status">
<ShieldCheck size={17} aria-hidden="true" />
<span>
{entry.tombstone === "redacted"
? "i18n:govoplan-campaign.entry_redacted_tombstone"
: "i18n:govoplan-campaign.entry_withdrawn_tombstone"}
</span>
</div>
) : (
<p className="campaign-collaboration-content">{entry.content}</p>
)}
{entry.reference ? (
<p className="campaign-collaboration-reference">
<strong>i18n:govoplan-campaign.reference_label</strong>{" "}
{entry.reference.label || entry.reference.kind.replaceAll("_", " ")}
<code>{entry.reference.id}</code>
</p>
) : null}
{!entry.tombstone && (ownEntry || canModerate) ? (
<div className="button-row compact-actions campaign-collaboration-entry-actions">
{ownEntry && canPost ? (
<Button
onClick={() => setPendingTombstone({ action: "withdraw", entry })}
helpContextId="campaign.activity.action.withdraw"
helpModuleId="campaign"
>
i18n:govoplan-campaign.withdraw
</Button>
) : null}
{canModerate ? (
<Button
variant="danger"
onClick={() => setPendingTombstone({ action: "redact", entry })}
helpContextId="campaign.activity.action.redact"
helpModuleId="campaign"
>
i18n:govoplan-campaign.redact
</Button>
) : null}
</div>
) : null}
</article>
</li>
);
})}
</ol>
)}
{hasMore ? (
<div className="button-row compact-actions campaign-collaboration-load-more">
<Button onClick={() => void loadOlder()} disabled={loadingMore}>
{loadingMore ? "i18n:govoplan-campaign.loading_older_entries" : "i18n:govoplan-campaign.load_older_entries"}
</Button>
</div>
) : null}
</Card>
<ConfirmDialog
open={Boolean(pendingTombstone)}
title={pendingTombstone?.action === "redact"
? "i18n:govoplan-campaign.redact_collaboration_entry"
: "i18n:govoplan-campaign.withdraw_collaboration_entry"}
message={pendingTombstone?.action === "redact"
? "i18n:govoplan-campaign.redact_collaboration_entry_confirmation"
: "i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation"}
confirmLabel={pendingTombstone?.action === "redact"
? "i18n:govoplan-campaign.redact"
: "i18n:govoplan-campaign.withdraw"}
tone="danger"
busy={tombstoneBusy}
helpContextId={pendingTombstone?.action === "redact"
? "campaign.activity.action.redact"
: "campaign.activity.action.withdraw"}
helpModuleId="campaign"
onCancel={() => !tombstoneBusy && setPendingTombstone(null)}
onConfirm={() => void applyTombstone()}
/>
</PageLayout>
);
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -3,10 +3,11 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
import {
ConcurrencyConflictProvider,
WorkspaceLayout,
hasScope,
useGuardedNavigate
} from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import type { ApiSettings, AuthInfo } from "../../types";
import SectionSidebar, { type CampaignWorkspaceNavigationSection } from "../../layout/SectionSidebar";
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
@@ -23,8 +24,9 @@ const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
overview: "",
campaign: "recipients",
"global-settings": "global-settings",
@@ -38,6 +40,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
"mail-policy": "mail-policy",
review: "review",
report: "report",
activity: "activity",
audit: "audit",
json: "json"
};
@@ -73,7 +76,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
function select(section: CampaignWorkspaceSection) {
function select(section: CampaignWorkspaceNavigationSection) {
const path = sectionPaths[section];
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
const params = new URLSearchParams(location.search);
@@ -87,7 +90,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
return (
<WorkspaceLayout
primary={<SectionSidebar active={active} onSelect={select} />}
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} />}
primaryLabel="Campaign sections"
contentLabel="Campaign workspace"
interfaceId="campaign.workspace"
@@ -112,6 +115,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
@@ -130,7 +134,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
);
}
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
function sectionFromPath(pathname: string): CampaignWorkspaceNavigationSection {
const segments = pathname.split("/").filter(Boolean);
const section = segments[2];
@@ -148,6 +152,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
if (section === "review") return "review";
if (section === "send") return "review";
if (section === "report" || section === "reports") return "report";
if (section === "activity" || section === "collaboration") return "activity";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";