@valentinkolb/cloud 0.5.4 → 0.5.6
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
package/src/services/index.ts
CHANGED
|
@@ -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";
|
|
@@ -40,22 +40,20 @@
|
|
|
40
40
|
* comment on focus-trap rationale.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack, For, Show } from "solid-js";
|
|
44
43
|
import { timed } from "@valentinkolb/stdlib/solid";
|
|
44
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, untrack } from "solid-js";
|
|
45
45
|
import {
|
|
46
|
-
type Completion,
|
|
47
|
-
type QueryContext,
|
|
48
|
-
type Suggestion,
|
|
49
46
|
applySuggestion,
|
|
50
47
|
buildSuggestContext,
|
|
51
|
-
|
|
48
|
+
type Completion,
|
|
52
49
|
detectQuery,
|
|
53
50
|
displayLabel,
|
|
54
51
|
plainTextHighlight,
|
|
52
|
+
type QueryContext,
|
|
55
53
|
renderWithOverlay,
|
|
56
54
|
resetCompletionState,
|
|
57
55
|
resolveSuggestions,
|
|
58
|
-
|
|
56
|
+
type Suggestion,
|
|
59
57
|
tryExpand,
|
|
60
58
|
tryRestore,
|
|
61
59
|
} from "../completion";
|
|
@@ -162,7 +160,6 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
162
160
|
/* ── Memoised inputs ──────────────────────────────────── */
|
|
163
161
|
|
|
164
162
|
const completions = createMemo(() => props.completions);
|
|
165
|
-
const knownLabels = createMemo(() => collectKnownLabels(completions()));
|
|
166
163
|
const useOverlay = createMemo(() => Boolean(props.highlight));
|
|
167
164
|
|
|
168
165
|
/* ── External value sync ──────────────────────────────── */
|
|
@@ -214,6 +211,11 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
214
211
|
currentAbort = null;
|
|
215
212
|
};
|
|
216
213
|
|
|
214
|
+
const isAbortError = (e: unknown): boolean =>
|
|
215
|
+
e instanceof DOMException
|
|
216
|
+
? e.name === "AbortError"
|
|
217
|
+
: Boolean(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
|
|
218
|
+
|
|
217
219
|
const recomputeCompletion = (): void => {
|
|
218
220
|
if (!textareaEl) return;
|
|
219
221
|
const ctx = detectQuery(textareaEl, completions());
|
|
@@ -230,7 +232,15 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
230
232
|
currentAbort = new AbortController();
|
|
231
233
|
const signal = currentAbort.signal;
|
|
232
234
|
|
|
233
|
-
|
|
235
|
+
let result: ReturnType<typeof resolveSuggestions>;
|
|
236
|
+
try {
|
|
237
|
+
result = resolveSuggestions(ctx.completion, ctx.query, suggestCtx, signal);
|
|
238
|
+
} catch (e: unknown) {
|
|
239
|
+
if (isAbortError(e)) return;
|
|
240
|
+
setLoading(false);
|
|
241
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
234
244
|
|
|
235
245
|
if (result.kind === "sync") {
|
|
236
246
|
setError(null);
|
|
@@ -239,14 +249,25 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
239
249
|
return;
|
|
240
250
|
}
|
|
241
251
|
|
|
242
|
-
// Async path: keep previous suggestions visible
|
|
243
|
-
//
|
|
252
|
+
// Async path: keep previous suggestions visible only while the
|
|
253
|
+
// same completion token is being refined. A new token/context can
|
|
254
|
+
// mean a different language slot (e.g. GQL source → predicate), so
|
|
255
|
+
// stale rows would be actively misleading.
|
|
256
|
+
const prev = completionState();
|
|
257
|
+
if (prev && (prev.ctx.start !== ctx.start || prev.ctx.completion !== ctx.completion)) {
|
|
258
|
+
setCompletionState(null);
|
|
259
|
+
closeDropdown();
|
|
260
|
+
}
|
|
244
261
|
setError(null);
|
|
245
262
|
setLoading(true);
|
|
246
263
|
lastAsyncCompletion = ctx.completion;
|
|
247
264
|
lastAsyncQuery = ctx.query;
|
|
248
265
|
lastAsyncCtx = suggestCtx;
|
|
249
|
-
|
|
266
|
+
const promise = result.promise.catch((e: unknown) => {
|
|
267
|
+
if (signal.aborted || isAbortError(e)) return [];
|
|
268
|
+
throw e;
|
|
269
|
+
});
|
|
270
|
+
debouncedFetch.debouncedFn(ctx, suggestCtx, promise, signal);
|
|
250
271
|
};
|
|
251
272
|
|
|
252
273
|
/** Take a fresh suggestion list, filter to usable ones, and merge
|
|
@@ -308,7 +329,7 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
308
329
|
applySuggestionList(ctx, list);
|
|
309
330
|
} catch (e: unknown) {
|
|
310
331
|
if (signal.aborted) return;
|
|
311
|
-
if (
|
|
332
|
+
if (isAbortError(e)) return;
|
|
312
333
|
setLoading(false);
|
|
313
334
|
setError(e instanceof Error ? e.message : String(e));
|
|
314
335
|
}
|
|
@@ -652,5 +673,5 @@ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
|
|
|
652
673
|
};
|
|
653
674
|
|
|
654
675
|
export default AutocompleteEditor;
|
|
655
|
-
export type { Completion,
|
|
676
|
+
export type { Completion, SuggestContext, Suggestion } from "../completion";
|
|
656
677
|
export { abbreviations } from "../completion";
|