Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
+44 -10
View File
@@ -5,6 +5,7 @@ import {
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
import { campaignVersionUpdateForRequest } from "../features/campaigns/utils/editorState";
export {
fetchResourceAccessExplanation,
fetchResourceAccessExplanationSubjects
@@ -340,6 +341,7 @@ export type CampaignScheduleCreate = {
};
export type CampaignVersionDetail = CampaignVersionListItem & {
review_build_token?: string | null;
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
mail_profile_migration_required?: boolean;
@@ -936,6 +938,7 @@ export type CampaignSendNowPayload = {
};
export type CampaignAppendSentPayload = {
version_id?: string;
dry_run?: boolean;
enqueue_celery?: boolean;
run_inline?: boolean;
@@ -1017,6 +1020,7 @@ export type CampaignAttachmentLinkMatchesResponse = {
};
export type CampaignMockSendPayload = {
use_reviewed_build?: boolean;
version_id?: string | null;
send?: boolean;
include_warnings?: boolean;
@@ -1027,6 +1031,10 @@ export type CampaignMockSendPayload = {
};
export type CampaignReviewStatePayload = {
merge_progress?: boolean;
build_token?: string | null;
base_revision?: number | null;
decision_category_key?: string | null;
inspection_complete: boolean;
reviewed_message_keys: string[];
issue_decisions: Array<{
@@ -1517,7 +1525,8 @@ options: CampaignWorkspaceQuery = {})
export async function getCampaignWorkspaceDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {})
options: CampaignWorkspaceQuery = {},
requestOptions?: Pick<RequestInit, "cache" | "signal">)
: Promise<CampaignWorkspaceDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
@@ -1527,7 +1536,7 @@ options: CampaignWorkspaceQuery = {})
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`, requestOptions);
}
export async function listCampaignVersions(
@@ -1542,7 +1551,9 @@ settings: ApiSettings,
campaignId: string,
versionId: string)
: Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
// Conflict reconciliation and discard/reload require the current revision,
// never an older coalesced or short-lived cached read.
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { cache: "no-store" });
}
export async function unlockCampaignVersionValidation(
@@ -1595,7 +1606,7 @@ ifMatch: string)
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
});
}
@@ -1607,7 +1618,7 @@ payload: CampaignVersionUpdatePayload = {})
: Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST",
body: JSON.stringify(payload)
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
});
}
@@ -1621,7 +1632,7 @@ ifMatch: string)
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
headers: { "If-Match": ifMatch },
body: JSON.stringify(payload)
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
});
}
@@ -1719,24 +1730,46 @@ versionId?: string)
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
}
export type CampaignDeliveryProgress = {
delivery_mode?: string | null;
workflow_state?: string;
campaign_id: string;
version_id: string;
generated_at: string;
total_jobs: number;
smtp: { total: number; processed: number; accepted: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number; paused: number; cancelled: number };
imap: { total: number; processed: number; appended: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number };
status_counts: { send: Record<string, number>; queue: Record<string, number>; imap: Record<string, number> };
};
export async function getCampaignDeliveryProgress(
settings: ApiSettings, campaignId: string, versionId: string, signal?: AbortSignal
): Promise<CampaignDeliveryProgress> {
return apiFetch<CampaignDeliveryProgress>(settings,
`/api/v1/campaigns/${campaignId}/delivery-progress?version_id=${encodeURIComponent(versionId)}`,
{ cache: "no-store", signal });
}
export async function getCampaignJobs(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery = {})
options: CampaignJobsQuery = {},
init?: Pick<RequestInit, "cache" | "signal">)
: Promise<CampaignJobsResponse> {
const params = campaignJobsQueryParams(options);
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`, init);
}
export async function getCampaignJobsDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {},
init?: Pick<RequestInit, "cache" | "signal">)
: Promise<CampaignJobsDeltaResponse> {
const params = campaignJobsQueryParams(options);
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`, init);
}
export async function getCampaignJobDetail(
@@ -1941,6 +1974,7 @@ payload: CampaignAppendSentPayload = {})
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST",
body: JSON.stringify({
version_id: payload.version_id,
dry_run: payload.dry_run ?? false,
enqueue_celery: payload.enqueue_celery ?? true,
run_inline: payload.run_inline ?? false
+24
View File
@@ -0,0 +1,24 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type CampaignDeliveryPolicyScope = "system" | "tenant";
export type CampaignDeliveryPolicy = {
scope: CampaignDeliveryPolicyScope;
synchronous_send_max_recipients: number | null;
revision: string;
max_configurable_recipients: number;
effective_max_recipients: number;
inherited_max_recipients: number;
absolute_max_recipients: number;
deployment_ceiling_explicit: boolean;
deployment_max_recipients: number;
};
export function getCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, signal?: AbortSignal) {
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, { cache: "no-store", signal });
}
export function saveCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, value: number | null, revision: string) {
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, {
method: "PUT", body: JSON.stringify({ synchronous_send_max_recipients: value, expected_revision: revision })
});
}
+37
View File
@@ -0,0 +1,37 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type CampaignRecoveryChannel = "smtp" | "imap";
export type CampaignInlineRecoveryAction = "retry" | "send-unattempted";
export type CampaignInlineRecoveryResult = {
selected_count: number;
remaining_count?: number;
attempted_count?: number;
sent_count?: number;
failed_count?: number;
outcome_unknown_count?: number;
enqueued_count?: number;
run_inline?: boolean;
};
export async function runCampaignInlineRecovery(settings: ApiSettings, campaignId: string, action: CampaignInlineRecoveryAction, payload: {
version_id: string;
job_ids: string[];
include_permanent?: boolean;
}): Promise<CampaignInlineRecoveryResult> {
const response = await apiFetch<{ result: CampaignInlineRecoveryResult }>(settings,
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${action}`, {
method: "POST",
body: JSON.stringify({ ...payload, run_inline: true, enqueue_celery: false })
});
return response.result;
}
export async function recoverCampaignJobClaim(settings: ApiSettings, campaignId: string, jobId: string, payload: {
channel: CampaignRecoveryChannel;
expected_revision: string;
note: string;
}): Promise<void> {
await apiFetch(settings, `/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${encodeURIComponent(jobId)}/recover-claim`, {
method: "POST", body: JSON.stringify(payload)
});
}
+12 -6
View File
@@ -72,8 +72,10 @@ export async function testMailProfileSmtp(
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
): Promise<MailConnectionTestResponse & { protocol: "smtp" }> {
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
if (result.protocol !== "smtp") throw new Error("Unexpected protocol in SMTP test response.");
return { ...result, protocol: result.protocol };
}
export async function testMailProfileImap(
@@ -82,8 +84,10 @@ export async function testMailProfileImap(
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
): Promise<MailConnectionTestResponse & { protocol: "imap" }> {
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP test response.");
return { ...result, protocol: result.protocol };
}
export async function listMailProfileImapFolders(
@@ -92,8 +96,10 @@ export async function listMailProfileImapFolders(
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
): Promise<MailImapFolderListResponse & { protocol: "imap" }> {
const result = await runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP folder response.");
return { ...result, protocol: result.protocol };
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {