@valentinkolb/cloud 0.5.3 → 0.5.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
2
2
  import { sql } from "bun";
3
+ import { toPgUuidArray } from "../../services/postgres";
3
4
 
4
5
  // ==========================
5
6
  // Permission Levels
@@ -85,14 +86,6 @@ type DbAccessUser = {
85
86
  // Helper Functions
86
87
  // ==========================
87
88
 
88
- /**
89
- * Converts UUID strings into a PostgreSQL uuid[] literal for relation queries.
90
- */
91
- const toPgUuidArray = (values: string[] | null | undefined): string => {
92
- if (!Array.isArray(values) || values.length === 0) return "{}";
93
- return `{${values.join(",")}}`;
94
- };
95
-
96
89
  const uniqueIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))];
97
90
 
98
91
  const escapeLikePattern = (value: string): string => value.replace(/[\\%_]/g, (match) => `\\${match}`);
@@ -1,3 +1,5 @@
1
+ import { toPgTextArray as serializePgTextArray } from "../../../services/postgres";
2
+
1
3
  export class IpaError extends Error {
2
4
  constructor(
3
5
  message: string,
@@ -54,7 +56,4 @@ export const mapIpaErrorCode = (code: number): 400 | 401 | 403 => {
54
56
  return 400;
55
57
  };
56
58
 
57
- export const toPgTextArray = (values: string[] | null | undefined): string => {
58
- if (!Array.isArray(values) || values.length === 0) return "{}";
59
- return `{${values.map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(",")}}`;
60
- };
59
+ export const toPgTextArray = serializePgTextArray;
@@ -23,8 +23,14 @@ export {
23
23
  } from "./gateway";
24
24
  export type { GatewayRouteSnapshot, GatewayRouteSnapshotInput, GatewayRouteWarning, GatewayTelemetryEvent } from "./gateway";
25
25
 
26
- export { notifications } from "./notifications";
26
+ export { notificationBatches, notifications } from "./notifications";
27
27
  export type {
28
+ NotificationBatch,
29
+ NotificationBatchPreview,
30
+ NotificationBatchRecipient,
31
+ NotificationBatchRecipientStatus,
32
+ NotificationBatchSelection,
33
+ NotificationBatchStatus,
28
34
  NotificationType,
29
35
  NotificationStatus,
30
36
  SendNotificationParams,
@@ -0,0 +1,172 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sql } from "bun";
3
+ import { __notificationBatchTest } from "./batches";
4
+
5
+ const canUseDatabase = async () => {
6
+ try {
7
+ const [row] = await sql<{
8
+ users: string | null;
9
+ groups: string | null;
10
+ user_groups: string | null;
11
+ group_manager_users: string | null;
12
+ group_manager_groups: string | null;
13
+ }[]>`
14
+ SELECT
15
+ to_regclass('auth.users')::text AS users,
16
+ to_regclass('auth.groups')::text AS groups,
17
+ to_regclass('auth.user_groups_v2')::text AS user_groups,
18
+ to_regclass('auth.group_manager_users_v2')::text AS group_manager_users,
19
+ to_regclass('auth.group_manager_groups_v2')::text AS group_manager_groups
20
+ `;
21
+ return Boolean(row?.users && row.groups && row.user_groups && row.group_manager_users && row.group_manager_groups);
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+
27
+ const insertUser = async (suffix: string, label: string) => {
28
+ const [row] = await sql<{ id: string }[]>`
29
+ INSERT INTO auth.users (uid, provider, profile, display_name, mail, given_name, sn)
30
+ VALUES (
31
+ ${`notification-batch-${label}-${suffix}`},
32
+ 'local',
33
+ 'user',
34
+ ${`Notification ${label}`},
35
+ ${`notification-batch-${label}-${suffix}@example.test`},
36
+ 'Notification',
37
+ ${label}
38
+ )
39
+ RETURNING id
40
+ `;
41
+ return row!.id;
42
+ };
43
+
44
+ const insertGroup = async (suffix: string, label: string) => {
45
+ const [row] = await sql<{ id: string }[]>`
46
+ INSERT INTO auth.groups (cn, provider, name, description)
47
+ VALUES (${`notification-batch-${label}-${suffix}`}, 'local', ${`Notification ${label} ${suffix}`}, 'notification batch test')
48
+ RETURNING id
49
+ `;
50
+ return row!.id;
51
+ };
52
+
53
+ const cleanupAuthFixture = async (userIds: string[], groupIds: string[]) => {
54
+ for (const groupId of groupIds) {
55
+ await sql`DELETE FROM auth.group_manager_groups_v2 WHERE group_id = ${groupId}::uuid OR manager_group_id = ${groupId}::uuid`;
56
+ await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${groupId}::uuid`;
57
+ await sql`DELETE FROM auth.user_groups_v2 WHERE group_id = ${groupId}::uuid`;
58
+ }
59
+ for (const groupId of groupIds) {
60
+ await sql`DELETE FROM auth.groups WHERE id = ${groupId}::uuid`;
61
+ }
62
+ for (const userId of userIds) {
63
+ await sql`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
64
+ }
65
+ };
66
+
67
+ describe("notification batch selections", () => {
68
+ test("normalizes duplicate and unordered ids for stable drafts", () => {
69
+ const selection = __notificationBatchTest.normalizeSelection({
70
+ userIds: ["user-b", "user-a", "user-a"],
71
+ groupIds: ["group-b", "group-a"],
72
+ accountManagers: {
73
+ mode: "groups",
74
+ groupIds: ["manager-b", "manager-a", "manager-b"],
75
+ },
76
+ providers: ["ipa", "local", "ipa"],
77
+ profiles: ["guest", "user", "guest"],
78
+ });
79
+
80
+ expect(selection.userIds).toEqual(["user-a", "user-b"]);
81
+ expect(selection.groupIds).toEqual(["group-a", "group-b"]);
82
+ expect(selection.accountManagers?.groupIds).toEqual(["manager-a", "manager-b"]);
83
+ expect(selection.providers).toEqual(["ipa", "local"]);
84
+ expect(selection.profiles).toEqual(["guest", "user"]);
85
+ });
86
+
87
+ test("defaults recursive member and manager resolution on", () => {
88
+ const selection = __notificationBatchTest.normalizeSelection({});
89
+
90
+ expect(selection.includeGroupMembers).toBe(true);
91
+ expect(selection.accountManagers?.mode).toBe("none");
92
+ expect(selection.accountManagers?.recursive).toBe(true);
93
+ });
94
+
95
+ test("normalizes modern rule selections", () => {
96
+ const selection = __notificationBatchTest.normalizeSelection({
97
+ mode: "rules",
98
+ rules: ["ipa", "account_manager", "ipa", "guest"],
99
+ groupIds: ["group-b", "group-a", "group-b"],
100
+ });
101
+
102
+ expect(selection.mode).toBe("rules");
103
+ expect(selection.rules).toEqual(["account_manager", "guest", "ipa"]);
104
+ expect(selection.groupIds).toEqual(["group-a", "group-b"]);
105
+ expect(selection.includeGroupMembers).toBe(true);
106
+ });
107
+
108
+ test("keeps selection hash stable across duplicate and order-only changes", () => {
109
+ const left = __notificationBatchTest.selectionHash({
110
+ userIds: ["user-b", "user-a", "user-a"],
111
+ accountManagers: { mode: "groups", groupIds: ["group-b", "group-a"] },
112
+ providers: ["local", "ipa"],
113
+ });
114
+ const right = __notificationBatchTest.selectionHash({
115
+ providers: ["ipa", "local", "ipa"],
116
+ accountManagers: { groupIds: ["group-a", "group-b"], mode: "groups" },
117
+ userIds: ["user-a", "user-b"],
118
+ });
119
+
120
+ expect(left).toBe(right);
121
+ });
122
+
123
+ test("hashes deliverable recipients by user id only", () => {
124
+ const left = __notificationBatchTest.recipientHash([
125
+ { id: "user-b", uid: "b", display_name: "B", mail: "b@example.test", provider: "local", profile: "user", source_hits: 1 },
126
+ { id: "user-a", uid: "a", display_name: "A", mail: "a@example.test", provider: "local", profile: "user", source_hits: 1 },
127
+ { id: "user-c", uid: "c", display_name: "C", mail: null, provider: "local", profile: "user", source_hits: 1 },
128
+ ]);
129
+ const right = __notificationBatchTest.recipientHash([
130
+ { id: "user-a", uid: "changed", display_name: "Changed", mail: "changed@example.test", provider: "ipa", profile: "guest", source_hits: 3 },
131
+ { id: "user-b", uid: "b", display_name: "B", mail: "b@example.test", provider: "local", profile: "user", source_hits: 1 },
132
+ ]);
133
+
134
+ expect(left).toBe(right);
135
+ });
136
+
137
+ test("resolves account managers for scoped groups without requiring group membership", async () => {
138
+ if (!(await canUseDatabase())) {
139
+ console.warn("Skipping notification batch DB test: auth tables are not available.");
140
+ return;
141
+ }
142
+
143
+ const suffix = crypto.randomUUID();
144
+ const targetMemberId = await insertUser(suffix, "target-member");
145
+ const directManagerId = await insertUser(suffix, "direct-manager");
146
+ const groupManagerId = await insertUser(suffix, "group-manager");
147
+ const targetGroupId = await insertGroup(suffix, "target-group");
148
+ const managerGroupId = await insertGroup(suffix, "manager-group");
149
+ const userIds = [targetMemberId, directManagerId, groupManagerId];
150
+ const groupIds = [targetGroupId, managerGroupId];
151
+
152
+ try {
153
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${targetMemberId}::uuid, ${targetGroupId}::uuid)`;
154
+ await sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${groupManagerId}::uuid, ${managerGroupId}::uuid)`;
155
+ await sql`INSERT INTO auth.group_manager_users_v2 (group_id, user_id) VALUES (${targetGroupId}::uuid, ${directManagerId}::uuid)`;
156
+ await sql`INSERT INTO auth.group_manager_groups_v2 (group_id, manager_group_id) VALUES (${targetGroupId}::uuid, ${managerGroupId}::uuid)`;
157
+
158
+ const candidates = await __notificationBatchTest.resolveCandidates({
159
+ mode: "rules",
160
+ rules: ["account_manager"],
161
+ groupIds: [targetGroupId],
162
+ });
163
+ const candidateIds = candidates.map((candidate) => candidate.id);
164
+
165
+ expect(candidateIds).toContain(directManagerId);
166
+ expect(candidateIds).toContain(groupManagerId);
167
+ expect(candidateIds).not.toContain(targetMemberId);
168
+ } finally {
169
+ await cleanupAuthFixture(userIds, groupIds);
170
+ }
171
+ });
172
+ });
@@ -0,0 +1,822 @@
1
+ import { sql } from "bun";
2
+ import { createHash } from "node:crypto";
3
+ import { job } from "@valentinkolb/sync";
4
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
5
+ import { markdown } from "../../shared/markdown";
6
+ import { logger } from "../logging";
7
+ import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
8
+ import { sendEmail } from "./email";
9
+
10
+ const log = logger("notifications:batches");
11
+ const CHUNK_SIZE = 100;
12
+ const MAX_PAGE = 10_000;
13
+ const MAX_PER_PAGE = 100;
14
+
15
+ export type NotificationBatchStatus =
16
+ | "draft"
17
+ | "ready"
18
+ | "running"
19
+ | "completed"
20
+ | "completed_with_errors"
21
+ | "failed"
22
+ | "cancelled";
23
+ export type NotificationBatchRecipientStatus = "pending" | "sending" | "sent" | "skipped" | "error";
24
+ export type NotificationBatchSelectionMode = "specific" | "rules";
25
+ export type NotificationBatchRule = "account_manager" | "local" | "ipa" | "guest" | "user";
26
+
27
+ export type NotificationBatchSelection = {
28
+ mode?: NotificationBatchSelectionMode;
29
+ rules?: NotificationBatchRule[];
30
+ all?: boolean;
31
+ userIds?: string[];
32
+ groupIds?: string[];
33
+ includeGroupMembers?: boolean;
34
+ accountManagers?: {
35
+ mode?: "none" | "all" | "groups";
36
+ groupIds?: string[];
37
+ recursive?: boolean;
38
+ };
39
+ providers?: ("local" | "ipa")[];
40
+ profiles?: ("user" | "guest")[];
41
+ };
42
+
43
+ export type NotificationBatchPreview = {
44
+ targetCount: number;
45
+ deliverableCount: number;
46
+ skippedNoEmailCount: number;
47
+ duplicateCount: number;
48
+ recipientHash: string;
49
+ };
50
+
51
+ export type NotificationBatch = {
52
+ id: string;
53
+ subject: string;
54
+ bodyMarkdown: string;
55
+ bodyHtml: string;
56
+ selection: NotificationBatchSelection;
57
+ selectionHash: string;
58
+ status: NotificationBatchStatus;
59
+ createdBy: string | null;
60
+ finalizedBy: string | null;
61
+ createdAt: string;
62
+ finalizedAt: string | null;
63
+ startedAt: string | null;
64
+ completedAt: string | null;
65
+ targetCount: number;
66
+ deliverableCount: number;
67
+ sentCount: number;
68
+ skippedCount: number;
69
+ errorCount: number;
70
+ lastError: string | null;
71
+ };
72
+
73
+ export type NotificationBatchRecipient = {
74
+ batchId: string;
75
+ userId: string;
76
+ recipient: string | null;
77
+ uid: string;
78
+ displayName: string;
79
+ provider: "local" | "ipa";
80
+ profile: "user" | "guest";
81
+ status: NotificationBatchRecipientStatus;
82
+ notificationId: string | null;
83
+ error: string | null;
84
+ attemptCount: number;
85
+ sentAt: string | null;
86
+ updatedAt: string;
87
+ };
88
+
89
+ type RecipientCandidate = {
90
+ id: string;
91
+ uid: string;
92
+ display_name: string;
93
+ mail: string | null;
94
+ provider: "local" | "ipa";
95
+ profile: "user" | "guest";
96
+ source_hits: number;
97
+ };
98
+
99
+ type BatchRow = Record<string, unknown>;
100
+
101
+ const normalizeIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))].sort();
102
+ const normalizeRules = (values: NotificationBatchRule[] | null | undefined): NotificationBatchRule[] =>
103
+ normalizeIds(values).filter((value): value is NotificationBatchRule =>
104
+ value === "account_manager" || value === "local" || value === "ipa" || value === "guest" || value === "user",
105
+ );
106
+
107
+ const normalizeSelection = (selection: NotificationBatchSelection): NotificationBatchSelection => {
108
+ const providers = normalizeIds(selection.providers).filter((value): value is "local" | "ipa" => value === "local" || value === "ipa");
109
+ const profiles = normalizeIds(selection.profiles).filter((value): value is "user" | "guest" => value === "user" || value === "guest");
110
+ const managerMode = selection.accountManagers?.mode ?? "none";
111
+ return {
112
+ mode: selection.mode === "specific" || selection.mode === "rules" ? selection.mode : undefined,
113
+ rules: normalizeRules(selection.rules),
114
+ all: Boolean(selection.all),
115
+ userIds: normalizeIds(selection.userIds),
116
+ groupIds: normalizeIds(selection.groupIds),
117
+ includeGroupMembers: selection.includeGroupMembers !== false,
118
+ accountManagers: {
119
+ mode: managerMode,
120
+ groupIds: normalizeIds(selection.accountManagers?.groupIds),
121
+ recursive: selection.accountManagers?.recursive !== false,
122
+ },
123
+ providers,
124
+ profiles,
125
+ };
126
+ };
127
+
128
+ const selectionHash = (selection: NotificationBatchSelection): string =>
129
+ createHash("sha256").update(JSON.stringify(normalizeSelection(selection))).digest("hex");
130
+
131
+ const recipientHash = (candidates: RecipientCandidate[]): string =>
132
+ createHash("sha256")
133
+ .update(JSON.stringify(candidates.filter((candidate) => candidate.mail).map((candidate) => candidate.id).sort()))
134
+ .digest("hex");
135
+
136
+ const mapBatch = (row: BatchRow): NotificationBatch => ({
137
+ id: row.id as string,
138
+ subject: row.subject as string,
139
+ bodyMarkdown: row.body_markdown as string,
140
+ bodyHtml: row.body_html as string,
141
+ selection: (parsePgJsonValue(row.selection) ?? {}) as NotificationBatchSelection,
142
+ selectionHash: row.selection_hash as string,
143
+ status: row.status as NotificationBatchStatus,
144
+ createdBy: row.created_by as string | null,
145
+ finalizedBy: row.finalized_by as string | null,
146
+ createdAt: (row.created_at as Date).toISOString(),
147
+ finalizedAt: row.finalized_at ? (row.finalized_at as Date).toISOString() : null,
148
+ startedAt: row.started_at ? (row.started_at as Date).toISOString() : null,
149
+ completedAt: row.completed_at ? (row.completed_at as Date).toISOString() : null,
150
+ targetCount: Number(row.target_count ?? 0),
151
+ deliverableCount: Number(row.deliverable_count ?? 0),
152
+ sentCount: Number(row.sent_count ?? 0),
153
+ skippedCount: Number(row.skipped_count ?? 0),
154
+ errorCount: Number(row.error_count ?? 0),
155
+ lastError: row.last_error as string | null,
156
+ });
157
+
158
+ const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
159
+ batchId: row.batch_id as string,
160
+ userId: row.user_id as string,
161
+ recipient: row.recipient as string | null,
162
+ uid: row.uid as string,
163
+ displayName: row.display_name as string,
164
+ provider: row.provider as "local" | "ipa",
165
+ profile: row.profile as "user" | "guest",
166
+ status: row.status as NotificationBatchRecipientStatus,
167
+ notificationId: row.notification_id as string | null,
168
+ error: row.error as string | null,
169
+ attemptCount: Number(row.attempt_count ?? 0),
170
+ sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
171
+ updatedAt: (row.updated_at as Date).toISOString(),
172
+ });
173
+
174
+ const resolveCandidates = async (rawSelection: NotificationBatchSelection): Promise<RecipientCandidate[]> => {
175
+ const selection = normalizeSelection(rawSelection);
176
+ const userIds = selection.userIds ?? [];
177
+ const groupIds = selection.groupIds ?? [];
178
+ const rules = selection.rules ?? [];
179
+
180
+ if (selection.mode === "specific") {
181
+ if (userIds.length === 0) return [];
182
+ return await sql<RecipientCandidate[]>`
183
+ SELECT u.id, u.uid, u.display_name, u.mail, u.provider, u.profile, 1::int AS source_hits
184
+ FROM auth.users u
185
+ WHERE u.id = ANY(${toPgUuidArray(userIds)}::uuid[])
186
+ ORDER BY u.uid
187
+ `;
188
+ }
189
+
190
+ if (selection.mode === "rules") {
191
+ const providerRules = rules.filter((rule): rule is "local" | "ipa" => rule === "local" || rule === "ipa");
192
+ const profileRules = rules.filter((rule): rule is "user" | "guest" => rule === "user" || rule === "guest");
193
+ const providers = providerRules;
194
+ const profiles = profileRules;
195
+ const requiresAccountManager = rules.includes("account_manager");
196
+ return await sql<RecipientCandidate[]>`
197
+ WITH RECURSIVE
198
+ selected_groups(group_id) AS (
199
+ SELECT unnest(${toPgUuidArray(groupIds)}::uuid[])
200
+ ),
201
+ scope_group_tree(group_id) AS (
202
+ SELECT group_id FROM selected_groups
203
+ UNION
204
+ SELECT gg.child_group_id
205
+ FROM auth.group_groups_v2 gg
206
+ JOIN scope_group_tree tree ON tree.group_id = gg.parent_group_id
207
+ ),
208
+ scoped_users(user_id) AS (
209
+ SELECT u.id
210
+ FROM auth.users u
211
+ WHERE ${groupIds.length === 0}
212
+ UNION
213
+ SELECT ug.user_id
214
+ FROM auth.user_groups_v2 ug
215
+ JOIN scope_group_tree tree ON tree.group_id = ug.group_id
216
+ ),
217
+ user_all_groups(user_id, group_id) AS (
218
+ SELECT ug.user_id, ug.group_id
219
+ FROM auth.user_groups_v2 ug
220
+ UNION
221
+ SELECT ag.user_id, gg.parent_group_id
222
+ FROM auth.group_groups_v2 gg
223
+ JOIN user_all_groups ag ON ag.group_id = gg.child_group_id
224
+ ),
225
+ manager_seed_groups(group_id) AS (
226
+ SELECT gmg.manager_group_id
227
+ FROM auth.group_manager_groups_v2 gmg
228
+ WHERE ${groupIds.length === 0}
229
+ UNION
230
+ SELECT gmg.manager_group_id
231
+ FROM auth.group_manager_groups_v2 gmg
232
+ JOIN scope_group_tree tree ON tree.group_id = gmg.group_id
233
+ WHERE ${groupIds.length > 0}
234
+ ),
235
+ manager_group_tree(group_id) AS (
236
+ SELECT group_id FROM manager_seed_groups
237
+ UNION
238
+ SELECT gg.parent_group_id
239
+ FROM auth.group_groups_v2 gg
240
+ JOIN manager_group_tree tree ON tree.group_id = gg.child_group_id
241
+ ),
242
+ manager_users(user_id) AS (
243
+ SELECT gmu.user_id
244
+ FROM auth.group_manager_users_v2 gmu
245
+ WHERE ${groupIds.length === 0}
246
+ UNION
247
+ SELECT gmu.user_id
248
+ FROM auth.group_manager_users_v2 gmu
249
+ JOIN scope_group_tree tree ON tree.group_id = gmu.group_id
250
+ WHERE ${groupIds.length > 0}
251
+ UNION
252
+ SELECT ag.user_id
253
+ FROM user_all_groups ag
254
+ JOIN manager_group_tree tree ON tree.group_id = ag.group_id
255
+ ),
256
+ candidate_counts AS (
257
+ SELECT su.user_id, 1::int AS source_hits
258
+ FROM scoped_users su
259
+ WHERE ${!requiresAccountManager}
260
+ UNION
261
+ SELECT mu.user_id, 1::int AS source_hits
262
+ FROM manager_users mu
263
+ WHERE ${requiresAccountManager}
264
+ )
265
+ SELECT u.id, u.uid, u.display_name, u.mail, u.provider, u.profile, c.source_hits
266
+ FROM candidate_counts c
267
+ JOIN auth.users u ON u.id = c.user_id
268
+ WHERE (cardinality(${toPgTextArray(providers)}::text[]) = 0 OR u.provider = ANY(${toPgTextArray(providers)}::text[]))
269
+ AND (cardinality(${toPgTextArray(profiles)}::text[]) = 0 OR u.profile = ANY(${toPgTextArray(profiles)}::text[]))
270
+ ORDER BY u.uid
271
+ `;
272
+ }
273
+
274
+ const managerGroupIds = selection.accountManagers?.groupIds ?? [];
275
+ const providers = selection.providers ?? [];
276
+ const profiles = selection.profiles ?? [];
277
+ const managerMode = selection.accountManagers?.mode ?? "none";
278
+ const includeMembers = selection.includeGroupMembers !== false;
279
+ const recursiveManagers = selection.accountManagers?.recursive !== false;
280
+ const hasAnySource =
281
+ selection.all ||
282
+ userIds.length > 0 ||
283
+ (includeMembers && groupIds.length > 0) ||
284
+ managerMode === "all" ||
285
+ (managerMode === "groups" && managerGroupIds.length > 0);
286
+
287
+ if (!hasAnySource) return [];
288
+
289
+ return await sql<RecipientCandidate[]>`
290
+ WITH RECURSIVE
291
+ selected_users(user_id) AS (
292
+ SELECT unnest(${toPgUuidArray(userIds)}::uuid[])
293
+ ),
294
+ selected_groups(group_id) AS (
295
+ SELECT unnest(${toPgUuidArray(groupIds)}::uuid[])
296
+ ),
297
+ selected_manager_groups(group_id) AS (
298
+ SELECT unnest(${toPgUuidArray(managerGroupIds)}::uuid[])
299
+ ),
300
+ member_group_tree(group_id) AS (
301
+ SELECT group_id FROM selected_groups
302
+ UNION
303
+ SELECT gg.child_group_id
304
+ FROM auth.group_groups_v2 gg
305
+ JOIN member_group_tree tree ON tree.group_id = gg.parent_group_id
306
+ WHERE ${includeMembers}
307
+ ),
308
+ user_all_groups(user_id, group_id) AS (
309
+ SELECT ug.user_id, ug.group_id
310
+ FROM auth.user_groups_v2 ug
311
+ UNION
312
+ SELECT ag.user_id, gg.parent_group_id
313
+ FROM auth.group_groups_v2 gg
314
+ JOIN user_all_groups ag ON ag.group_id = gg.child_group_id
315
+ ),
316
+ manager_seed_groups(group_id) AS (
317
+ SELECT gmg.manager_group_id
318
+ FROM auth.group_manager_groups_v2 gmg
319
+ WHERE ${managerMode === "all"}
320
+ UNION
321
+ SELECT gmg.manager_group_id
322
+ FROM auth.group_manager_groups_v2 gmg
323
+ JOIN selected_manager_groups sg ON sg.group_id = gmg.group_id
324
+ WHERE ${managerMode === "groups"}
325
+ ),
326
+ manager_group_tree(group_id) AS (
327
+ SELECT group_id FROM manager_seed_groups
328
+ UNION
329
+ SELECT gg.parent_group_id
330
+ FROM auth.group_groups_v2 gg
331
+ JOIN manager_group_tree tree ON tree.group_id = gg.child_group_id
332
+ WHERE ${recursiveManagers}
333
+ ),
334
+ candidates(user_id) AS (
335
+ SELECT u.id
336
+ FROM auth.users u
337
+ WHERE ${Boolean(selection.all)}
338
+ UNION ALL
339
+ SELECT user_id FROM selected_users
340
+ UNION ALL
341
+ SELECT ug.user_id
342
+ FROM auth.user_groups_v2 ug
343
+ JOIN member_group_tree tree ON tree.group_id = ug.group_id
344
+ WHERE ${includeMembers}
345
+ UNION ALL
346
+ SELECT gmu.user_id
347
+ FROM auth.group_manager_users_v2 gmu
348
+ WHERE ${managerMode === "all"}
349
+ UNION ALL
350
+ SELECT gmu.user_id
351
+ FROM auth.group_manager_users_v2 gmu
352
+ JOIN selected_manager_groups sg ON sg.group_id = gmu.group_id
353
+ WHERE ${managerMode === "groups"}
354
+ UNION ALL
355
+ SELECT ag.user_id
356
+ FROM user_all_groups ag
357
+ JOIN manager_group_tree tree ON tree.group_id = ag.group_id
358
+ WHERE ${managerMode === "all" || managerMode === "groups"}
359
+ ),
360
+ candidate_counts AS (
361
+ SELECT user_id, COUNT(*)::int AS source_hits
362
+ FROM candidates
363
+ GROUP BY user_id
364
+ )
365
+ SELECT u.id, u.uid, u.display_name, u.mail, u.provider, u.profile, c.source_hits
366
+ FROM candidate_counts c
367
+ JOIN auth.users u ON u.id = c.user_id
368
+ WHERE (cardinality(${toPgTextArray(providers)}::text[]) = 0 OR u.provider = ANY(${toPgTextArray(providers)}::text[]))
369
+ AND (cardinality(${toPgTextArray(profiles)}::text[]) = 0 OR u.profile = ANY(${toPgTextArray(profiles)}::text[]))
370
+ ORDER BY u.uid
371
+ `;
372
+ };
373
+
374
+ const sendBatchEmail = async (params: {
375
+ recipient: string;
376
+ subject: string;
377
+ rawHtml: string;
378
+ sentBy?: string;
379
+ }): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
380
+ const rows = await sql<BatchRow[]>`
381
+ INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
382
+ VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
383
+ RETURNING id
384
+ `;
385
+ const id = rows[0]!.id as string;
386
+ try {
387
+ await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
388
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
389
+ return { id, status: "sent" };
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
393
+ return { id, status: "error", error: message };
394
+ }
395
+ };
396
+
397
+ export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
398
+ const candidates = await resolveCandidates(selection);
399
+ return {
400
+ targetCount: candidates.length,
401
+ deliverableCount: candidates.filter((candidate) => candidate.mail).length,
402
+ skippedNoEmailCount: candidates.filter((candidate) => !candidate.mail).length,
403
+ duplicateCount: candidates.reduce((sum, candidate) => sum + Math.max(0, Number(candidate.source_hits ?? 1) - 1), 0),
404
+ recipientHash: recipientHash(candidates),
405
+ };
406
+ };
407
+
408
+ const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch | null> => {
409
+ const rows = await sql<BatchRow[]>`
410
+ WITH counts AS (
411
+ SELECT
412
+ COUNT(*)::int AS target_count,
413
+ COUNT(*) FILTER (WHERE r.recipient IS NOT NULL)::int AS deliverable_count,
414
+ COUNT(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
415
+ COUNT(*) FILTER (WHERE r.status = 'skipped')::int AS skipped_count,
416
+ COUNT(*) FILTER (WHERE r.status = 'error')::int AS error_count,
417
+ COUNT(*) FILTER (WHERE r.status IN ('pending', 'sending'))::int AS pending_count,
418
+ MAX(COALESCE(m.error, r.error)) FILTER (WHERE r.status = 'error') AS last_error
419
+ FROM notifications.batch_recipients r
420
+ LEFT JOIN notifications.messages m ON m.id = r.notification_id
421
+ WHERE r.batch_id = ${batchId}::uuid
422
+ )
423
+ UPDATE notifications.batches b
424
+ SET
425
+ target_count = counts.target_count,
426
+ deliverable_count = counts.deliverable_count,
427
+ sent_count = counts.sent_count,
428
+ skipped_count = counts.skipped_count,
429
+ error_count = counts.error_count,
430
+ last_error = counts.last_error,
431
+ status = CASE
432
+ WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.status
433
+ WHEN counts.pending_count > 0 THEN 'running'
434
+ WHEN counts.error_count > 0 THEN 'completed_with_errors'
435
+ ELSE 'completed'
436
+ END,
437
+ completed_at = CASE
438
+ WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.completed_at
439
+ WHEN counts.pending_count = 0 AND b.completed_at IS NULL THEN now()
440
+ ELSE b.completed_at
441
+ END
442
+ FROM counts
443
+ WHERE b.id = ${batchId}::uuid
444
+ RETURNING b.*
445
+ `;
446
+ return rows[0] ? mapBatch(rows[0]) : null;
447
+ };
448
+
449
+ const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
450
+ await sql`
451
+ UPDATE notifications.batches
452
+ SET status = 'running', started_at = COALESCE(started_at, now())
453
+ WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
454
+ `;
455
+
456
+ const recipients = await sql<BatchRow[]>`
457
+ UPDATE notifications.batch_recipients r
458
+ SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
459
+ WHERE (r.batch_id, r.user_id) IN (
460
+ SELECT batch_id, user_id
461
+ FROM notifications.batch_recipients
462
+ WHERE batch_id = ${batchId}::uuid
463
+ AND recipient IS NOT NULL
464
+ AND (
465
+ status = 'pending'
466
+ OR (status = 'sending' AND updated_at < now() - interval '5 minutes')
467
+ )
468
+ ORDER BY updated_at ASC
469
+ LIMIT ${CHUNK_SIZE}
470
+ FOR UPDATE SKIP LOCKED
471
+ )
472
+ RETURNING r.*
473
+ `;
474
+
475
+ const [batchRow] = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${batchId}::uuid`;
476
+ if (!batchRow || batchRow.status === "cancelled") return { processed: 0, remaining: 0 };
477
+ const batch = mapBatch(batchRow);
478
+
479
+ for (const recipient of recipients) {
480
+ try {
481
+ const result = await sendBatchEmail({
482
+ recipient: recipient.recipient as string,
483
+ subject: batch.subject,
484
+ rawHtml: batch.bodyHtml,
485
+ sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
486
+ });
487
+ await sql`
488
+ UPDATE notifications.batch_recipients
489
+ SET
490
+ status = ${result.status === "sent" ? "sent" : "error"},
491
+ notification_id = ${result.id}::uuid,
492
+ error = NULL,
493
+ sent_at = ${result.status === "sent" ? sql`now()` : null},
494
+ updated_at = now()
495
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
496
+ `;
497
+ } catch (error) {
498
+ const message = error instanceof Error ? error.message : String(error);
499
+ log.error("Batch recipient send failed", { batchId, userId: recipient.user_id, error: message });
500
+ await sql`
501
+ UPDATE notifications.batch_recipients
502
+ SET status = 'error', error = ${message}, updated_at = now()
503
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
504
+ `;
505
+ }
506
+ }
507
+
508
+ const [pendingRow] = await sql<BatchRow[]>`
509
+ SELECT COUNT(*)::int AS count
510
+ FROM notifications.batch_recipients
511
+ WHERE batch_id = ${batchId}::uuid
512
+ AND recipient IS NOT NULL
513
+ AND status IN ('pending', 'sending')
514
+ `;
515
+ const remaining = Number(pendingRow?.count ?? 0);
516
+ await refreshBatchCounters(batchId);
517
+ return { processed: recipients.length, remaining };
518
+ };
519
+
520
+ const batchJob = job<{ batchId: string }, { processed: number; remaining: number }>({
521
+ id: "notifications:batches",
522
+ defaults: { leaseMs: 180_000 },
523
+ process: async ({ ctx }) => {
524
+ if (ctx.signal.aborted) return { processed: 0, remaining: 0 };
525
+ const result = await processBatchChunk(ctx.input.batchId);
526
+ await ctx.heartbeat();
527
+ return result;
528
+ },
529
+ after: async ({ ctx }) => {
530
+ if (ctx.error && ctx.failureCount < 3) {
531
+ ctx.reschedule({ delayMs: ctx.expBackoff({ baseMs: 1000, maxMs: 30_000 }) });
532
+ return;
533
+ }
534
+ if (ctx.error) {
535
+ const message = ctx.error.message;
536
+ log.error("Notification batch job failed", { batchId: ctx.input.batchId, failureCount: ctx.failureCount, error: message });
537
+ await sql`
538
+ UPDATE notifications.batches
539
+ SET status = 'failed', completed_at = now(), last_error = ${message}
540
+ WHERE id = ${ctx.input.batchId}::uuid AND status IN ('ready', 'running')
541
+ `;
542
+ return;
543
+ }
544
+ if (ctx.data && ctx.data.remaining > 0) {
545
+ ctx.reschedule({ delayMs: ctx.data.processed > 0 ? 0 : 60_000 });
546
+ }
547
+ },
548
+ });
549
+
550
+ export const createDraft = async (params: {
551
+ subject: string;
552
+ bodyMarkdown: string;
553
+ selection: NotificationBatchSelection;
554
+ createdBy: string;
555
+ }): Promise<Result<NotificationBatch>> => {
556
+ const subject = params.subject.trim();
557
+ const bodyMarkdown = params.bodyMarkdown.trim();
558
+ if (!subject) return fail(err.badInput("Subject is required"));
559
+ if (!bodyMarkdown) return fail(err.badInput("Message is required"));
560
+ const selection = normalizeSelection(params.selection);
561
+ const candidates = await resolveCandidates(selection);
562
+ if (!candidates.some((candidate) => candidate.mail)) {
563
+ return fail(err.badInput("No deliverable recipients match this selection"));
564
+ }
565
+ const hash = selectionHash(selection);
566
+ const bodyHtml = markdown.renderSync(bodyMarkdown);
567
+ const rows = await sql<BatchRow[]>`
568
+ INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
569
+ VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::jsonb, ${hash}, ${params.createdBy}::uuid)
570
+ RETURNING *
571
+ `;
572
+ return ok(mapBatch(rows[0]!));
573
+ };
574
+
575
+ export const get = async (id: string): Promise<NotificationBatch | null> => {
576
+ const rows = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${id}::uuid`;
577
+ return rows[0] ? mapBatch(rows[0]) : null;
578
+ };
579
+
580
+ export const list = async (params?: {
581
+ page?: number;
582
+ perPage?: number;
583
+ status?: NotificationBatchStatus;
584
+ }): Promise<{ items: NotificationBatch[]; total: number; page: number; perPage: number }> => {
585
+ const page = Math.min(Math.max(params?.page ?? 1, 1), MAX_PAGE);
586
+ const perPage = Math.min(Math.max(params?.perPage ?? 50, 1), MAX_PER_PAGE);
587
+ const offset = (page - 1) * perPage;
588
+ const rows = await sql<BatchRow[]>`
589
+ SELECT *, COUNT(*) OVER() AS total
590
+ FROM notifications.batches
591
+ WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
592
+ ORDER BY created_at DESC
593
+ LIMIT ${perPage} OFFSET ${offset}
594
+ `;
595
+ return {
596
+ items: rows.map(mapBatch),
597
+ total: Number(rows[0]?.total ?? 0),
598
+ page,
599
+ perPage,
600
+ };
601
+ };
602
+
603
+ export const listRecipients = async (params: {
604
+ batchId: string;
605
+ page?: number;
606
+ perPage?: number;
607
+ status?: NotificationBatchRecipientStatus;
608
+ }): Promise<{ items: NotificationBatchRecipient[]; total: number; page: number; perPage: number }> => {
609
+ const page = Math.min(Math.max(params.page ?? 1, 1), MAX_PAGE);
610
+ const perPage = Math.min(Math.max(params.perPage ?? 100, 1), MAX_PER_PAGE);
611
+ const offset = (page - 1) * perPage;
612
+ const rows = await sql<BatchRow[]>`
613
+ SELECT
614
+ r.batch_id,
615
+ r.user_id,
616
+ r.recipient,
617
+ r.uid,
618
+ r.display_name,
619
+ r.provider,
620
+ r.profile,
621
+ r.status,
622
+ r.notification_id,
623
+ COALESCE(m.error, r.error) AS error,
624
+ r.attempt_count,
625
+ r.sent_at,
626
+ r.updated_at,
627
+ COUNT(*) OVER() AS total
628
+ FROM notifications.batch_recipients r
629
+ LEFT JOIN notifications.messages m ON m.id = r.notification_id
630
+ WHERE r.batch_id = ${params.batchId}::uuid
631
+ AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
632
+ ORDER BY
633
+ CASE r.status WHEN 'error' THEN 0 WHEN 'pending' THEN 1 WHEN 'sending' THEN 2 WHEN 'skipped' THEN 3 ELSE 4 END,
634
+ r.uid
635
+ LIMIT ${perPage} OFFSET ${offset}
636
+ `;
637
+ return {
638
+ items: rows.map(mapRecipient),
639
+ total: Number(rows[0]?.total ?? 0),
640
+ page,
641
+ perPage,
642
+ };
643
+ };
644
+
645
+ export const finalize = async (params: {
646
+ id: string;
647
+ actorUserId: string;
648
+ expectedSelectionHash: string;
649
+ expectedDeliverableCount: number;
650
+ expectedRecipientHash: string;
651
+ }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
652
+ const candidates = await resolveCandidates((await get(params.id))?.selection ?? {});
653
+ const deliverableCount = candidates.filter((candidate) => candidate.mail).length;
654
+ const currentRecipientHash = recipientHash(candidates);
655
+ if (deliverableCount === 0) {
656
+ return fail(err.badInput("No deliverable recipients match this batch"));
657
+ }
658
+ const result = await sql.begin(async (tx): Promise<Result<NotificationBatch>> => {
659
+ const rows = await tx<BatchRow[]>`
660
+ SELECT * FROM notifications.batches WHERE id = ${params.id}::uuid FOR UPDATE
661
+ `;
662
+ const batchRow = rows[0];
663
+ if (!batchRow) return fail(err.notFound("Notification batch not found"));
664
+ if (batchRow.status !== "draft") return fail(err.conflict("Notification batch has already been finalized"));
665
+ if (batchRow.selection_hash !== params.expectedSelectionHash) return fail(err.conflict("Notification selection changed. Refresh the preview."));
666
+ if (deliverableCount !== params.expectedDeliverableCount) {
667
+ return fail(err.conflict("Recipient count changed. Refresh the preview before sending."));
668
+ }
669
+ if (currentRecipientHash !== params.expectedRecipientHash) {
670
+ return fail(err.conflict("Recipient list changed. Refresh the preview before sending."));
671
+ }
672
+
673
+ await tx`
674
+ INSERT INTO notifications.batch_recipients (
675
+ batch_id, user_id, recipient, uid, display_name, provider, profile, status, error
676
+ )
677
+ SELECT
678
+ ${params.id}::uuid,
679
+ row.user_id,
680
+ NULLIF(row.recipient, ''),
681
+ row.uid,
682
+ row.display_name,
683
+ row.provider,
684
+ row.profile,
685
+ row.status,
686
+ NULLIF(row.error, '')
687
+ FROM unnest(
688
+ ${toPgUuidArray(candidates.map((candidate) => candidate.id))}::uuid[],
689
+ ${toPgTextArray(candidates.map((candidate) => candidate.mail ?? ""))}::text[],
690
+ ${toPgTextArray(candidates.map((candidate) => candidate.uid))}::text[],
691
+ ${toPgTextArray(candidates.map((candidate) => candidate.display_name ?? ""))}::text[],
692
+ ${toPgTextArray(candidates.map((candidate) => candidate.provider))}::text[],
693
+ ${toPgTextArray(candidates.map((candidate) => candidate.profile))}::text[],
694
+ ${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "pending" : "skipped")))}::text[],
695
+ ${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "" : "User has no email address")))}::text[]
696
+ ) AS row(user_id, recipient, uid, display_name, provider, profile, status, error)
697
+ ON CONFLICT (batch_id, user_id) DO NOTHING
698
+ `;
699
+
700
+ const updated = await tx<BatchRow[]>`
701
+ UPDATE notifications.batches
702
+ SET
703
+ status = 'ready',
704
+ finalized_by = ${params.actorUserId}::uuid,
705
+ finalized_at = now(),
706
+ target_count = ${candidates.length},
707
+ deliverable_count = ${deliverableCount},
708
+ skipped_count = ${candidates.length - deliverableCount}
709
+ WHERE id = ${params.id}::uuid
710
+ RETURNING *
711
+ `;
712
+ return ok(mapBatch(updated[0]!));
713
+ });
714
+
715
+ if (!result.ok) return result;
716
+ const jobId = await batchJob.submit({ key: params.id, input: { batchId: params.id } });
717
+ return ok({ batch: result.data, jobId });
718
+ };
719
+
720
+ export const retryFailed = async (params: { id: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
721
+ const batch = await get(params.id);
722
+ if (!batch) return fail(err.notFound("Notification batch not found"));
723
+ if (batch.status === "draft" || batch.status === "cancelled") {
724
+ return fail(err.conflict("Only finalized notification batches can retry recipients"));
725
+ }
726
+
727
+ const updated = await sql<BatchRow[]>`
728
+ UPDATE notifications.batch_recipients
729
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
730
+ WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
731
+ RETURNING user_id
732
+ `;
733
+ if (updated.length === 0) {
734
+ return fail(err.conflict("No failed deliverable recipients can be retried"));
735
+ }
736
+
737
+ await sql`
738
+ UPDATE notifications.batches
739
+ SET status = 'ready', completed_at = NULL, last_error = NULL
740
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
741
+ `;
742
+ const refreshed = await refreshBatchCounters(params.id);
743
+ const jobId = await batchJob.submit({ key: `${params.id}:retry:${Date.now()}`, input: { batchId: params.id } });
744
+ return ok({ batch: refreshed ?? batch, jobId });
745
+ };
746
+
747
+ export const retryRecipient = async (params: { id: string; userId: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
748
+ const batch = await get(params.id);
749
+ if (!batch) return fail(err.notFound("Notification batch not found"));
750
+ if (batch.status === "draft" || batch.status === "cancelled") {
751
+ return fail(err.conflict("Only finalized notification batches can retry recipients"));
752
+ }
753
+
754
+ const updated = await sql<BatchRow[]>`
755
+ UPDATE notifications.batch_recipients
756
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
757
+ WHERE batch_id = ${params.id}::uuid
758
+ AND user_id = ${params.userId}::uuid
759
+ AND status = 'error'
760
+ AND recipient IS NOT NULL
761
+ RETURNING *
762
+ `;
763
+ if (!updated[0]) {
764
+ const existing = await sql<BatchRow[]>`
765
+ SELECT status, recipient
766
+ FROM notifications.batch_recipients
767
+ WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
768
+ `;
769
+ if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
770
+ return fail(err.conflict("Only failed deliverable recipients can be retried"));
771
+ }
772
+
773
+ await sql`
774
+ UPDATE notifications.batches
775
+ SET status = 'ready', completed_at = NULL, last_error = NULL
776
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
777
+ `;
778
+ const refreshed = await refreshBatchCounters(params.id);
779
+ const jobId = await batchJob.submit({ key: `${params.id}:recipient:${params.userId}:${Date.now()}`, input: { batchId: params.id } });
780
+ return ok({ batch: refreshed ?? batch, jobId });
781
+ };
782
+
783
+ export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
784
+ const rows = await sql<BatchRow[]>`
785
+ DELETE FROM notifications.batches
786
+ WHERE id = ${params.id}::uuid AND status = 'draft'
787
+ RETURNING id
788
+ `;
789
+ if (rows[0]) return ok({ id: rows[0].id as string });
790
+
791
+ const batch = await get(params.id);
792
+ if (!batch) return fail(err.notFound("Notification batch not found"));
793
+ return fail(err.conflict("Only draft notification batches can be deleted"));
794
+ };
795
+
796
+ export const start = async (): Promise<void> => {
797
+ // Jobs are submitted manually. This hook keeps service startup symmetrical
798
+ // with other background services and gives future schedulers a stable home.
799
+ };
800
+
801
+ export const stop = async (): Promise<void> => {};
802
+
803
+ export const notificationBatches = {
804
+ createDraft,
805
+ preview,
806
+ get,
807
+ list,
808
+ listRecipients,
809
+ finalize,
810
+ retryFailed,
811
+ retryRecipient,
812
+ removeDraft,
813
+ start,
814
+ stop,
815
+ };
816
+
817
+ export const __notificationBatchTest = {
818
+ normalizeSelection,
819
+ selectionHash,
820
+ recipientHash,
821
+ resolveCandidates,
822
+ };
@@ -570,3 +570,13 @@ export const notifications = {
570
570
  getStatusSummary,
571
571
  getSearchSummary,
572
572
  };
573
+
574
+ export { notificationBatches } from "./batches";
575
+ export type {
576
+ NotificationBatch,
577
+ NotificationBatchPreview,
578
+ NotificationBatchRecipient,
579
+ NotificationBatchRecipientStatus,
580
+ NotificationBatchSelection,
581
+ NotificationBatchStatus,
582
+ } from "./batches";
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { toPgTextArray, toPgUuidArray } from "./postgres";
3
+
4
+ describe("Postgres array helpers", () => {
5
+ test("serializes UUID arrays and treats non-arrays as empty arrays", () => {
6
+ expect(toPgUuidArray(["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"])).toBe(
7
+ "{11111111-1111-4111-8111-111111111111,22222222-2222-4222-8222-222222222222}",
8
+ );
9
+ expect(toPgUuidArray([])).toBe("{}");
10
+ expect(toPgUuidArray("{}" as unknown as string[])).toBe("{}");
11
+ });
12
+
13
+ test("serializes text arrays with escaping and treats non-arrays as empty arrays", () => {
14
+ expect(toPgTextArray(["alpha", "has space", 'has "quote"', "has\\slash"])).toBe('{"alpha","has space","has \\"quote\\"","has\\\\slash"}');
15
+ expect(toPgTextArray([])).toBe("{}");
16
+ expect(toPgTextArray("{}" as unknown as string[])).toBe("{}");
17
+ });
18
+ });