@ikenga/contract 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Approve-gate draft types — the run-then-pause (`ux_mode: approve`) payload.
3
+ *
4
+ * An approve-aware action does its work, then — instead of performing the
5
+ * external side effect — hands the shell a batch of `DraftItem`s plus an
6
+ * `ApproveGateMeta` via the (forthcoming) `host.paActionsPause` verb. The shell
7
+ * persists them to `pa_action_drafts` (ikenga.db), emits `pa-action-paused`, and
8
+ * mounts the approve-gate panel
9
+ * (`shell/src/shell/atelier/surfaces/approve-gate-panel.tsx`) at
10
+ * `/outbox/approvals`. The panel renders the rich `PausedDraft` view-model;
11
+ * `fromDraftItem` derives it from the lean producer `DraftItem` so producers
12
+ * stay simple (they never hand-build display state).
13
+ *
14
+ * Scope: `plans/atelier/10-approve-gate-seam.md` (WP-1); behaviour:
15
+ * `07-fe-button-renderer.md` §3.5. Plain-TS by convention (mirrors
16
+ * `host-verbs.ts`) — runtime validation, when needed, lives at the host boundary.
17
+ */
18
+
19
+ /** Outbound channel an approve gate commits to. Locked to provider identity. */
20
+ export type DraftChannel = 'smtp' | 'resend' | 'listmonk' | 'buffer';
21
+
22
+ /** Human channel label (display). */
23
+ export const CHANNEL_LABEL: Record<DraftChannel, string> = {
24
+ smtp: 'SMTP',
25
+ resend: 'Resend',
26
+ listmonk: 'Listmonk',
27
+ buffer: 'Buffer',
28
+ };
29
+
30
+ /** A draft's position inside an outreach sequence/drip, if any. */
31
+ export interface DraftSequence {
32
+ name: string;
33
+ step: number;
34
+ total: number;
35
+ /** Audience size for this step (e.g. a 388-recipient newsletter section). */
36
+ recipients: number;
37
+ }
38
+
39
+ /**
40
+ * What an approve-aware action emits per draft — the lean, authoritative
41
+ * producer shape. The shell derives the panel's `PausedDraft` from this via
42
+ * `fromDraftItem` (computes preview, time bucketing, consequence), so an action
43
+ * never hand-builds display state.
44
+ */
45
+ export interface DraftItem {
46
+ id: string;
47
+ /** Recipient display (a person, or a broadcast target like "L5 Winback · 388 recipients"). */
48
+ recipient: string;
49
+ recipientEmail: string | null;
50
+ tenantId?: string | null;
51
+ subject: string;
52
+ body: string;
53
+ channel: DraftChannel;
54
+ /** Sending address, e.g. "chinedum@royalti.io" or "ned@getroyalti.com". */
55
+ senderAddress: string;
56
+ /** Sending route display, e.g. "SMTP · Fastmail". */
57
+ fromProvider: string;
58
+ /** Cold outreach (separate sender domain / reputation). */
59
+ cold?: boolean;
60
+ /**
61
+ * Audience size for the consequence line; defaults to 1.
62
+ * Kept for display (e.g. "388 recipients"); use `recipientsList` for the actual
63
+ * per-recipient send list on direct-email channels.
64
+ */
65
+ recipients?: number;
66
+ /**
67
+ * Actual recipient list for direct-email channels (smtp, resend).
68
+ * Adapters iterate this for per-recipient sends + partial-success tracking (DEC-9).
69
+ * Broadcast channels (listmonk, buffer) address a list/channel audience and
70
+ * ignore this field.
71
+ */
72
+ recipientsList?: { name?: string; email: string }[];
73
+ /**
74
+ * Body content type — adapters set the MIME part accordingly.
75
+ * Defaults to `'text'` when absent.
76
+ */
77
+ bodyFormat?: 'html' | 'text';
78
+ /** Reply-To header for direct-email channels (smtp, resend). */
79
+ replyTo?: string;
80
+ /** Machine schedule time (ISO) for overdue/today bucketing; null = unscheduled. */
81
+ scheduledIso: string | null;
82
+ /** Row time display, e.g. "scheduled 17:00" / "today" / "2d late". */
83
+ scheduledLabel: string;
84
+ /** Detail-chip schedule display, e.g. "Scheduled · today 17:00". Falls back to scheduledLabel. */
85
+ scheduledChip?: string;
86
+ sequence?: DraftSequence | null;
87
+ deal?: string | null;
88
+ threadCount?: string;
89
+ /** Explicit section bucket; otherwise derived from `scheduledIso`. */
90
+ section?: string;
91
+ }
92
+
93
+ /**
94
+ * Partial-success result returned by a `ChannelAdapter.send()` call (DEC-9).
95
+ * `ok` is true when at least one recipient was accepted; `failed` lists
96
+ * per-recipient errors so callers can surface them without dropping success.
97
+ * Shared here so the contract module is the single SoT; the daemon's
98
+ * `lib/channels/types.ts` imports it directly from `@ikenga/contract`.
99
+ */
100
+ export interface SendResult {
101
+ /** False only when ALL recipients failed (or a batch-level error occurred). */
102
+ ok: boolean;
103
+ /** Provider message/campaign/post id — write around the network call where supported (G-05). */
104
+ externalId?: string;
105
+ /** Addresses the provider accepted (partial success — DEC-9). */
106
+ sent?: string[];
107
+ /** Per-recipient failures for partial-success scenarios. */
108
+ failed?: { email: string; error: string }[];
109
+ /** Batch-level error message (to error_text column) when ok is false. */
110
+ error?: string;
111
+ /** True for transient errors (5xx/429/network) — drives in-worker retry vs permanent fail. */
112
+ retryable?: boolean;
113
+ }
114
+
115
+ /** Batch-level metadata for one approve-mode run (the gate header + undo window). */
116
+ export interface ApproveGateMeta {
117
+ /** `<pkgId>/<verb>` that produced the batch. */
118
+ actionId: string;
119
+ /** Human action name for the gate header. */
120
+ actionName: string;
121
+ /** Drafting agent label, e.g. "PA" / "CMO" / "CBO". */
122
+ agent: string;
123
+ /** Engine/model that drafted, e.g. "Opus 4.7". */
124
+ model: string;
125
+ /** Undo window before commit, ms. Defaults to 10_000. */
126
+ undoMs?: number;
127
+ }
128
+
129
+ /**
130
+ * The rich view-model the approve-gate panel renders. Kept byte-identical to the
131
+ * panel's prior local definition so the panel just imports it. Derived from a
132
+ * `DraftItem` + `ApproveGateMeta` via `fromDraftItem`; the panel flips
133
+ * `everEdited`/`status` locally as the operator edits.
134
+ */
135
+ export interface PausedDraft {
136
+ id: string;
137
+ recipient: string;
138
+ recipientEmail: string | null;
139
+ tenantId: string | null;
140
+ subject: string;
141
+ body: string;
142
+ bodyPreview: string;
143
+ channel: DraftChannel;
144
+ agent: string;
145
+ senderAddress: string;
146
+ cold: boolean;
147
+ /**
148
+ * Panel display status. `'failed'` means the send worker exhausted retries
149
+ * and the draft needs operator attention (see `errorMessage` / `attempts`).
150
+ */
151
+ status: 'awaiting' | 'edited' | 'overdue' | 'failed';
152
+ scheduledAt: string;
153
+ scheduledLabel: string;
154
+ timeVariant: 'is-today' | 'is-overdue' | null;
155
+ overdue: boolean;
156
+ everEdited: boolean;
157
+ section: string;
158
+ sequence: DraftSequence | null;
159
+ fromProvider: string;
160
+ model: string;
161
+ threadCount: string;
162
+ deal: string | null;
163
+ consequence: {
164
+ target: string;
165
+ recipients: number;
166
+ channel: string;
167
+ time: string;
168
+ undoMs: number;
169
+ };
170
+ /** Last error surfaced by the send worker (from pa_action_drafts.error_text). */
171
+ errorMessage?: string;
172
+ /** Number of send attempts so far (from pa_action_drafts.attempts). */
173
+ attempts?: number;
174
+ }
175
+
176
+ const PREVIEW_MAX = 160;
177
+ const DEFAULT_UNDO_MS = 10_000;
178
+
179
+ /** Collapse whitespace + truncate `body` for the row preview line. */
180
+ export function draftPreview(body: string, max = PREVIEW_MAX): string {
181
+ const flat = body.replace(/\s+/g, ' ').trim();
182
+ return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
183
+ }
184
+
185
+ function sameLocalDay(a: number, b: number): boolean {
186
+ const da = new Date(a);
187
+ const db = new Date(b);
188
+ return (
189
+ da.getFullYear() === db.getFullYear() &&
190
+ da.getMonth() === db.getMonth() &&
191
+ da.getDate() === db.getDate()
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Derive the panel's `PausedDraft` from a producer `DraftItem` + the batch
197
+ * `ApproveGateMeta`. `now` is injectable for deterministic tests; it only affects
198
+ * time bucketing (overdue / today / section). Pure + side-effect-free.
199
+ *
200
+ * `errorMessage` and `attempts` are not derivable from a `DraftItem` (they live
201
+ * on the DB row after worker runs); callers that reconstruct a `PausedDraft` from
202
+ * a stored row should set them on the returned object directly.
203
+ */
204
+ export function fromDraftItem(
205
+ item: DraftItem,
206
+ meta: ApproveGateMeta,
207
+ now: number = Date.now()
208
+ ): PausedDraft {
209
+ const scheduledMs = item.scheduledIso ? Date.parse(item.scheduledIso) : null;
210
+ const hasTime = scheduledMs !== null && !Number.isNaN(scheduledMs);
211
+ const overdue = hasTime && (scheduledMs as number) < now;
212
+ const today = hasTime && !overdue && sameLocalDay(scheduledMs as number, now);
213
+ const timeVariant: PausedDraft['timeVariant'] = overdue
214
+ ? 'is-overdue'
215
+ : today
216
+ ? 'is-today'
217
+ : null;
218
+ const section = item.section ?? (overdue ? 'Overdue' : today ? 'Today' : 'This week');
219
+
220
+ return {
221
+ id: item.id,
222
+ recipient: item.recipient,
223
+ recipientEmail: item.recipientEmail,
224
+ tenantId: item.tenantId ?? null,
225
+ subject: item.subject,
226
+ body: item.body,
227
+ bodyPreview: draftPreview(item.body),
228
+ channel: item.channel,
229
+ agent: meta.agent,
230
+ senderAddress: item.senderAddress,
231
+ cold: item.cold ?? false,
232
+ status: overdue ? 'overdue' : 'awaiting',
233
+ scheduledAt: item.scheduledLabel,
234
+ scheduledLabel: item.scheduledChip ?? item.scheduledLabel,
235
+ timeVariant,
236
+ overdue,
237
+ everEdited: false,
238
+ section,
239
+ sequence: item.sequence ?? null,
240
+ fromProvider: item.fromProvider,
241
+ model: meta.model,
242
+ threadCount: item.threadCount ?? '',
243
+ deal: item.deal ?? null,
244
+ consequence: {
245
+ target: item.recipient,
246
+ recipients: item.recipients ?? 1,
247
+ channel: CHANNEL_LABEL[item.channel],
248
+ time: item.scheduledLabel,
249
+ undoMs: meta.undoMs ?? DEFAULT_UNDO_MS,
250
+ },
251
+ };
252
+ }