@cosmicdrift/kumiko-samples 0.356.0 → 0.358.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/i18n.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +8 -1
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +71 -21
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +99 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +143 -43
- package/packages/bundled-features/src/cap-counter/changes.json +7 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +18 -32
- package/packages/bundled-features/src/cap-counter/feature.ts +7 -1
- package/packages/bundled-features/src/cap-counter/index.ts +5 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -25
- package/packages/bundled-features/src/cap-overview/screens.ts +3 -1
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +15 -1
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +1 -1
- package/packages/bundled-features/src/delivery/constants.ts +5 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +118 -50
- package/packages/bundled-features/src/delivery/feature.ts +7 -1
- package/packages/bundled-features/src/delivery/jobs.ts +27 -1
- package/packages/bundled-features/src/delivery/testing.ts +1 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -15
- package/packages/bundled-features/src/jobs/job-run-table.ts +10 -3
- package/packages/bundled-features/src/notes-history/changes.json +6 -0
- package/packages/bundled-features/src/notes-history/entity.ts +9 -7
- package/packages/bundled-features/src/notes-history-user-data/hooks.ts +84 -76
- package/packages/bundled-features/src/notes-history-user-data/index.ts +3 -1
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +2 -0
- package/packages/bundled-features/src/sessions/screens.ts +2 -0
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/constants.ts +6 -3
- package/packages/bundled-features/src/tenant/feature.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/member-role-availability.query.ts +25 -0
- package/packages/bundled-features/src/tenant/member-role-options.ts +6 -0
- package/packages/bundled-features/src/tenant/screens.ts +9 -6
- package/packages/bundled-features/src/user/screens.ts +8 -1
- package/packages/bundled-features/src/user-data-rights/screens.ts +8 -1
- package/packages/bundled-features/src/waitlist/screens.ts +8 -1
- package/samples/apps/marketing-demo/src/features/assets/schema/asset.ts +4 -4
- package/samples/apps/marketing-demo/src/features/helpdesk/schema/ticket.ts +4 -4
- package/samples/apps/showcase/src/features/items/schema/item.ts +1 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +3 -3
- package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +6 -2
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +9 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { createEntityExecutor
|
|
1
|
+
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { KumikoError, reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type CapCounterContext,
|
|
6
|
+
markCapSoftWarned,
|
|
7
|
+
readRollingCapUsage,
|
|
8
|
+
resolveCapCounterScope,
|
|
9
|
+
} from "./book-cap-usage.js";
|
|
5
10
|
import { capCounterEntity } from "./entity.js";
|
|
6
11
|
|
|
7
12
|
const { table } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
@@ -95,7 +100,7 @@ export function assertBelowHardCap(
|
|
|
95
100
|
* level (e.g. uniqueness-index on apps.tenantId+slot-number).
|
|
96
101
|
*/
|
|
97
102
|
export async function enforceCap(
|
|
98
|
-
ctx:
|
|
103
|
+
ctx: CapCounterContext,
|
|
99
104
|
options: {
|
|
100
105
|
readonly capName: string;
|
|
101
106
|
readonly periodStartIso: string;
|
|
@@ -106,9 +111,7 @@ export async function enforceCap(
|
|
|
106
111
|
readonly amount?: number;
|
|
107
112
|
},
|
|
108
113
|
): Promise<EnforceCapResult> {
|
|
109
|
-
|
|
110
|
-
throw new Error("cap-counter.enforceCap: ctx.db missing — run inside a handler context");
|
|
111
|
-
}
|
|
114
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
112
115
|
|
|
113
116
|
const amount = options.amount ?? 1;
|
|
114
117
|
if (!Number.isInteger(amount) || amount < 1) {
|
|
@@ -120,9 +123,9 @@ export async function enforceCap(
|
|
|
120
123
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
121
124
|
const softThreshold = options.limit * tolerance.soft;
|
|
122
125
|
|
|
123
|
-
const rows = await
|
|
126
|
+
const rows = await readDb.selectMany(
|
|
124
127
|
table,
|
|
125
|
-
{ capName: options.capName, periodStart: options.periodStartIso },
|
|
128
|
+
{ tenantId: user.tenantId, capName: options.capName, periodStart: options.periodStartIso },
|
|
126
129
|
{ limit: 1 },
|
|
127
130
|
);
|
|
128
131
|
|
|
@@ -170,7 +173,7 @@ export async function enforceCap(
|
|
|
170
173
|
* vorgezogen.
|
|
171
174
|
*/
|
|
172
175
|
export async function enforceRollingCap(
|
|
173
|
-
ctx:
|
|
176
|
+
ctx: CapCounterContext,
|
|
174
177
|
options: {
|
|
175
178
|
readonly capName: string;
|
|
176
179
|
readonly windowDays: number;
|
|
@@ -178,20 +181,13 @@ export async function enforceRollingCap(
|
|
|
178
181
|
readonly profile: CapToleranceProfileName;
|
|
179
182
|
},
|
|
180
183
|
): Promise<EnforceCapResult> {
|
|
181
|
-
|
|
182
|
-
throw new Error("cap-counter.enforceRollingCap: ctx.db missing — run inside a handler context");
|
|
183
|
-
}
|
|
184
|
-
if (!ctx.user?.tenantId) {
|
|
185
|
-
throw new Error(
|
|
186
|
-
"cap-counter.enforceRollingCap: ctx.user.tenantId missing — required to compute aggregate-id",
|
|
187
|
-
);
|
|
188
|
-
}
|
|
184
|
+
const { user, readDb } = resolveCapCounterScope(ctx, false);
|
|
189
185
|
|
|
190
186
|
const tolerance = CAP_TOLERANCES[options.profile];
|
|
191
187
|
const softThreshold = options.limit * tolerance.soft;
|
|
192
188
|
const hardThreshold = options.limit * tolerance.hard;
|
|
193
189
|
|
|
194
|
-
const value = await readRollingCapUsage(
|
|
190
|
+
const value = await readRollingCapUsage(readDb, user.tenantId, {
|
|
195
191
|
capName: options.capName,
|
|
196
192
|
windowDays: options.windowDays,
|
|
197
193
|
});
|
|
@@ -295,7 +291,7 @@ export type SoftHitNotifier = (info: {
|
|
|
295
291
|
* Email-Notification).
|
|
296
292
|
*/
|
|
297
293
|
export async function enforceCapAndMaybeNotify(
|
|
298
|
-
ctx:
|
|
294
|
+
ctx: CapCounterContext,
|
|
299
295
|
options: {
|
|
300
296
|
readonly capName: string;
|
|
301
297
|
readonly periodStartIso: string;
|
|
@@ -315,16 +311,11 @@ export async function enforceCapAndMaybeNotify(
|
|
|
315
311
|
});
|
|
316
312
|
|
|
317
313
|
if (result.state === "soft-hit" && result.crossed) {
|
|
318
|
-
if (!ctx.user?.tenantId) {
|
|
319
|
-
throw new Error(
|
|
320
|
-
"cap-counter.enforceCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
314
|
await options.notify({
|
|
324
315
|
capName: options.capName,
|
|
325
316
|
value: result.value,
|
|
326
317
|
limit: options.limit,
|
|
327
|
-
tenantId: ctx.user.tenantId,
|
|
318
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
328
319
|
});
|
|
329
320
|
// Flip the soft-warned flag so the same period doesn't re-notify.
|
|
330
321
|
const marked = await markCapSoftWarned(ctx, {
|
|
@@ -349,7 +340,7 @@ export async function enforceCapAndMaybeNotify(
|
|
|
349
340
|
* Tag/Stunde auf (Hourly-Cron statt pro Request).
|
|
350
341
|
*/
|
|
351
342
|
export async function enforceRollingCapAndMaybeNotify(
|
|
352
|
-
ctx:
|
|
343
|
+
ctx: CapCounterContext,
|
|
353
344
|
options: {
|
|
354
345
|
readonly capName: string;
|
|
355
346
|
readonly windowDays: number;
|
|
@@ -366,16 +357,11 @@ export async function enforceRollingCapAndMaybeNotify(
|
|
|
366
357
|
});
|
|
367
358
|
|
|
368
359
|
if (result.state === "soft-hit") {
|
|
369
|
-
if (!ctx.user?.tenantId) {
|
|
370
|
-
throw new Error(
|
|
371
|
-
"cap-counter.enforceRollingCapAndMaybeNotify: ctx.user.tenantId missing — required for notification",
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
360
|
await options.notify({
|
|
375
361
|
capName: options.capName,
|
|
376
362
|
value: result.value,
|
|
377
363
|
limit: options.limit,
|
|
378
|
-
tenantId: ctx.user.tenantId,
|
|
364
|
+
tenantId: resolveCapCounterScope(ctx, false).user.tenantId,
|
|
379
365
|
});
|
|
380
366
|
}
|
|
381
367
|
|
|
@@ -119,7 +119,13 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
|
119
119
|
id: CAP_COUNTER_LIST_SCREEN_ID,
|
|
120
120
|
type: "entityList",
|
|
121
121
|
entity: "cap-counter",
|
|
122
|
-
columns: [
|
|
122
|
+
columns: [
|
|
123
|
+
"tenantId",
|
|
124
|
+
"capName",
|
|
125
|
+
"value",
|
|
126
|
+
"periodStart",
|
|
127
|
+
{ field: "lastSoftWarnedAt", hideOnNarrow: true },
|
|
128
|
+
],
|
|
123
129
|
defaultSort: { field: "capName", dir: "asc" },
|
|
124
130
|
// Search resolves against the caller's own tenant stream, so on this
|
|
125
131
|
// cross-tenant operator list it would only ever hit the SystemAdmin's tenant.
|
|
@@ -9,6 +9,11 @@ export {
|
|
|
9
9
|
type ReadRollingCapUsageOptions,
|
|
10
10
|
readRollingCapUsage,
|
|
11
11
|
} from "./book-cap-usage.js";
|
|
12
|
+
export {
|
|
13
|
+
type CalendarCapUsageReservation,
|
|
14
|
+
type CalendarCapUsageReservationSpec,
|
|
15
|
+
reserveCalendarCapUsage,
|
|
16
|
+
} from "./calendar-cap-usage-reservation.js";
|
|
12
17
|
export {
|
|
13
18
|
CAP_COUNTER_FEATURE,
|
|
14
19
|
CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
@@ -29,15 +29,11 @@ import type {
|
|
|
29
29
|
WriteEvent,
|
|
30
30
|
WriteHandlerDef,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
reserveCalendarCap,
|
|
35
|
-
reserveRollingCap,
|
|
36
|
-
} from "./cap-reservation.js";
|
|
32
|
+
import { reserveEnforcedCalendarCap } from "./calendar-cap-usage-reservation.js";
|
|
33
|
+
import { releaseExpiredCapReservations, reserveRollingCap } from "./cap-reservation.js";
|
|
37
34
|
import {
|
|
38
35
|
assertBelowHardCap,
|
|
39
36
|
type CapToleranceProfileName,
|
|
40
|
-
enforceCapAndMaybeNotify,
|
|
41
37
|
enforceRollingCapAndMaybeNotify,
|
|
42
38
|
type SoftHitNotifier,
|
|
43
39
|
} from "./enforce-cap.js";
|
|
@@ -93,25 +89,7 @@ export function withCapEnforcement(
|
|
|
93
89
|
handler.reserveBeforeTransaction,
|
|
94
90
|
async (event, ctx) => {
|
|
95
91
|
const cap = await capResolver(event, ctx);
|
|
96
|
-
|
|
97
|
-
await releaseExpiredCapReservations(ctx, cap.capName);
|
|
98
|
-
await enforceCapAndMaybeNotify(ctx, {
|
|
99
|
-
capName: cap.capName,
|
|
100
|
-
periodStartIso: cap.periodStartIso,
|
|
101
|
-
limit: cap.limit,
|
|
102
|
-
profile: cap.profile,
|
|
103
|
-
notify: cap.notify,
|
|
104
|
-
markSoftWarnedOutsideTransaction: true,
|
|
105
|
-
...(cap.amount !== undefined && { amount: cap.amount }),
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const amount = cap.amount ?? 1;
|
|
109
|
-
return reserveCalendarCap(ctx, {
|
|
110
|
-
capName: cap.capName,
|
|
111
|
-
amount,
|
|
112
|
-
periodStartIso: cap.periodStartIso,
|
|
113
|
-
guardCurrentValue: (currentValue) => assertBelowHardCap(currentValue + amount - 1, cap),
|
|
114
|
-
});
|
|
92
|
+
return reserveEnforcedCalendarCap(ctx, { ...cap, amount: cap.amount ?? 1 });
|
|
115
93
|
},
|
|
116
94
|
),
|
|
117
95
|
};
|
|
@@ -31,10 +31,12 @@ export function createTenantCapListScreen(
|
|
|
31
31
|
{ field: "name", label: "cap-overview.list.col.name" },
|
|
32
32
|
{ field: "tier", label: "cap-overview.list.col.tier" },
|
|
33
33
|
{ field: "billing", label: "cap-overview.list.col.billing" },
|
|
34
|
-
|
|
34
|
+
// Name is the title; tier, billing and the first cap fill the card's three meta rows.
|
|
35
|
+
...listedCaps.map((cap, index) => ({
|
|
35
36
|
field: capFieldName(cap.id),
|
|
36
37
|
label: cap.label,
|
|
37
38
|
renderer: { react: { __component: CAP_USAGE_CELL_COMPONENT } },
|
|
39
|
+
...(index > 0 && { hideOnNarrow: true }),
|
|
38
40
|
})),
|
|
39
41
|
],
|
|
40
42
|
searchable: true,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "forget-subject shreds note bodies attached to the subject and notes that mention it",
|
|
6
|
+
"detail": "`forget-subject` on a user or record subject now also erases the per-note keys of every `note-entry` that hangs on the subject as host entity (entityId, plus entityType for record subjects) or @-mentions it, unless the host entity's retention strategy is anonymize/blockDelete. The automated Art. 17 delete hook reaches notes on the user as host too, and the export lists them. Before, `forget-subject(contact)` left the note bodies readable. Consumer tests that expect readable note bodies after `forget-subject` on a contact (e.g. solon contact-notes-forget) now see the erased sentinel. Forgetting only the author still leaves the bodies readable."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -40,6 +40,7 @@ import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/se
|
|
|
40
40
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
41
41
|
import * as z from "zod";
|
|
42
42
|
import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
|
|
43
|
+
import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
|
|
43
44
|
import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
|
|
44
45
|
import { USER_STATUS } from "../../user/index.js";
|
|
45
46
|
import {
|
|
@@ -300,6 +301,7 @@ async function sweepDerivedSubjectData(
|
|
|
300
301
|
subject: SubjectId,
|
|
301
302
|
subjectKey: string,
|
|
302
303
|
crossTenantRunner: DbRunner,
|
|
304
|
+
audit: { readonly requestId: string; readonly erasedBy: string },
|
|
303
305
|
): Promise<string | undefined> {
|
|
304
306
|
try {
|
|
305
307
|
await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
|
|
@@ -318,6 +320,16 @@ async function sweepDerivedSubjectData(
|
|
|
318
320
|
subject,
|
|
319
321
|
);
|
|
320
322
|
}
|
|
323
|
+
|
|
324
|
+
// Note bodies are keyed per note, so the subject's own key never reaches them.
|
|
325
|
+
if (ctx.registry.features.has("notes-history")) {
|
|
326
|
+
await eraseNoteBodiesReachingSubject({
|
|
327
|
+
db: crossTenantSubjectRunner,
|
|
328
|
+
subject,
|
|
329
|
+
erasedBy: audit.erasedBy,
|
|
330
|
+
requestId: audit.requestId,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
321
333
|
});
|
|
322
334
|
return undefined;
|
|
323
335
|
} catch (err) {
|
|
@@ -471,8 +483,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
471
483
|
// but no event exists — a retry is a no-op erase plus the event. The
|
|
472
484
|
// reverse order could leave an audit trail claiming a shred that never
|
|
473
485
|
// happened.
|
|
486
|
+
const requestId = requestContext.get()?.requestId ?? "crypto-shredding:forget-subject";
|
|
474
487
|
await kms.eraseKey(subject, {
|
|
475
|
-
requestId
|
|
488
|
+
requestId,
|
|
476
489
|
userId: event.user.id,
|
|
477
490
|
eraseReason: event.payload.reason,
|
|
478
491
|
});
|
|
@@ -482,6 +495,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
482
495
|
subject,
|
|
483
496
|
subjectKey,
|
|
484
497
|
ctx.db.unsafeRaw(),
|
|
498
|
+
{ requestId, erasedBy: event.user.id },
|
|
485
499
|
);
|
|
486
500
|
|
|
487
501
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.357.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Delivery idempotencyKey is released when nothing was delivered",
|
|
6
|
+
"detail": "A retry of a failed notify() with the same idempotencyKey is no longer skipped as a duplicate. The key is deleted when no recipient got a `sent` or `queued` delivery, also when notify() throws; one successful recipient keeps it, even if notify() throws afterwards (a retry would re-send to them). For queued channels the send/render job deletes the key after its final failed attempt, not per retry. That reference is only attached when a `route` call has exactly one channel address; `to` calls (user lists) and multi-channel routes keep the key after queueing. The job payload gains an optional `idempotencyKey`, backward compatible. `createDeliveryTestContext` accepts `idempotencyRedis`."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -57,7 +57,7 @@ export type ChatWebhookRequest = ChatWebhookTarget & {
|
|
|
57
57
|
function policyFor(request: ChatWebhookRequest, target: URL): EgressPolicy {
|
|
58
58
|
return request.requireHttps
|
|
59
59
|
? { kind: "external" }
|
|
60
|
-
: { kind: "internal",
|
|
60
|
+
: { kind: "internal", allowOrigins: [target.origin] };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function hostMatches(hostname: string, allowedHost: string): boolean {
|
|
@@ -26,6 +26,11 @@ export {
|
|
|
26
26
|
type DeliveryStatusValue,
|
|
27
27
|
} from "./public-names.js";
|
|
28
28
|
|
|
29
|
+
// Single source for claim (delivery-service) and terminal-failure release (jobs).
|
|
30
|
+
export function deliveryIdempotencyRedisKey(tenantId: string, key: string): string {
|
|
31
|
+
return `delivery:idem:${tenantId}:${key}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
// notify() priority → BullMQ job priority. Lower number = processed first; all
|
|
30
35
|
// > 0 so prioritised delivery jobs never mix with BullMQ's "0 = unprioritised
|
|
31
36
|
// FIFO" bucket. critical jobs jump ahead of normal/low in the worker queue.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EscapeHatchAuditSink,
|
|
7
7
|
NotifyDelivery,
|
|
8
8
|
NotifyJobDispatcher,
|
|
9
|
+
NotifyOptions,
|
|
9
10
|
NotifyPriority,
|
|
10
11
|
Registry,
|
|
11
12
|
TenantId,
|
|
@@ -21,7 +22,12 @@ import { redactErrorText } from "../shared/index.js";
|
|
|
21
22
|
import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
22
23
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
23
24
|
import { buildChannelContext } from "./channel-context.js";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DELIVERY_CHANNEL_EXTENSION,
|
|
27
|
+
DeliveryJobs,
|
|
28
|
+
deliveryIdempotencyRedisKey,
|
|
29
|
+
deliveryPriorityRank,
|
|
30
|
+
} from "./constants.js";
|
|
25
31
|
import {
|
|
26
32
|
type AddressOptOutKey,
|
|
27
33
|
addressOptOutKey,
|
|
@@ -171,11 +177,43 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
171
177
|
"Delivery idempotencyKey requires options.idempotencyRedis (or rateLimit.redis) to be configured",
|
|
172
178
|
);
|
|
173
179
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
180
|
+
const res = await idemRedis.set(
|
|
181
|
+
deliveryIdempotencyRedisKey(tenantId, key),
|
|
182
|
+
"1",
|
|
183
|
+
"EX",
|
|
184
|
+
ttlSec,
|
|
185
|
+
"NX",
|
|
186
|
+
);
|
|
176
187
|
return res === "OK";
|
|
177
188
|
}
|
|
178
189
|
|
|
190
|
+
function isDeliveredOrQueued(delivery: NotifyDelivery): boolean {
|
|
191
|
+
return delivery.status === "sent" || delivery.status === "queued";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
|
|
195
|
+
// Partial success keeps it: a retry would re-send to those who got it.
|
|
196
|
+
async function releaseIdempotencyIfNothingDelivered(
|
|
197
|
+
tenantId: TenantId,
|
|
198
|
+
key: string | undefined,
|
|
199
|
+
run: NotifyRun,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
// skip: no key claimed, or a recipient already got the notification
|
|
202
|
+
if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
|
|
203
|
+
await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Only a single-channel route lets the queued job free the key on terminal failure
|
|
207
|
+
// without risking a duplicate send on another channel.
|
|
208
|
+
function jobIdempotencyKeyFor(
|
|
209
|
+
idempotencyKey: string | undefined,
|
|
210
|
+
route: NotifyOptions["route"],
|
|
211
|
+
): string | undefined {
|
|
212
|
+
if (!idempotencyKey || !route) return undefined;
|
|
213
|
+
const routedChannelCount = channels.filter((channel) => route[channel.name]).length;
|
|
214
|
+
return routedChannelCount === 1 ? idempotencyKey : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
async function resolveUserIdsForTenant(tenantId: TenantId): Promise<readonly string[]> {
|
|
180
218
|
if (!tenantUserIdsQuery) {
|
|
181
219
|
throw new Error("Tenant broadcast requires tenantUserIdsQuery in DeliveryServiceOptions");
|
|
@@ -233,6 +271,9 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
233
271
|
readonly deliveries: NotifyDelivery[];
|
|
234
272
|
readonly jobDispatcher: NotifyJobDispatcher | undefined;
|
|
235
273
|
readonly locale: string | undefined;
|
|
274
|
+
// Set only when exactly one channel carries the call: the queued job may then free
|
|
275
|
+
// the key on terminal failure without risking a duplicate send on another channel.
|
|
276
|
+
readonly jobIdempotencyKey: string | undefined;
|
|
236
277
|
};
|
|
237
278
|
|
|
238
279
|
function recordDelivery(
|
|
@@ -343,6 +384,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
343
384
|
deliveryAttemptId,
|
|
344
385
|
priority,
|
|
345
386
|
message,
|
|
387
|
+
...(run.jobIdempotencyKey !== undefined && { idempotencyKey: run.jobIdempotencyKey }),
|
|
346
388
|
},
|
|
347
389
|
{ priority: deliveryPriorityRank[priority] },
|
|
348
390
|
);
|
|
@@ -745,14 +787,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
745
787
|
}
|
|
746
788
|
}
|
|
747
789
|
|
|
790
|
+
async function dispatchNotification(
|
|
791
|
+
notificationType: string,
|
|
792
|
+
options: NotifyOptions,
|
|
793
|
+
tenantId: TenantId,
|
|
794
|
+
priority: NotifyPriority,
|
|
795
|
+
run: NotifyRun,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
const { to, route, data } = options;
|
|
798
|
+
if (route) {
|
|
799
|
+
await deliverDirect(
|
|
800
|
+
route,
|
|
801
|
+
notificationType,
|
|
802
|
+
data,
|
|
803
|
+
tenantId,
|
|
804
|
+
priority,
|
|
805
|
+
options.recipientId ?? null,
|
|
806
|
+
run,
|
|
807
|
+
);
|
|
808
|
+
// skip: explicit route is delivered directly, no recipient resolution needed
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (to !== undefined) {
|
|
813
|
+
let userIds: readonly string[];
|
|
814
|
+
|
|
815
|
+
if (typeof to === "string") {
|
|
816
|
+
userIds = [to];
|
|
817
|
+
} else if ("tenant" in to) {
|
|
818
|
+
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
819
|
+
} else {
|
|
820
|
+
userIds = to;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const prefetchedAddresses =
|
|
824
|
+
priority === "critical"
|
|
825
|
+
? new Map<string, string | null>()
|
|
826
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
827
|
+
const suppressed = await loadSuppressedAddresses(
|
|
828
|
+
tenantId,
|
|
829
|
+
notificationType,
|
|
830
|
+
priority,
|
|
831
|
+
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
for (const userId of userIds) {
|
|
835
|
+
await deliverToUser(
|
|
836
|
+
userId,
|
|
837
|
+
notificationType,
|
|
838
|
+
data,
|
|
839
|
+
tenantId,
|
|
840
|
+
priority,
|
|
841
|
+
run,
|
|
842
|
+
prefetchedAddresses,
|
|
843
|
+
suppressed,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
748
849
|
return {
|
|
749
850
|
async notify(notificationType, options, _user, tenantId, jobDispatcher) {
|
|
750
|
-
const {
|
|
851
|
+
const { route, idempotencyKey } = options;
|
|
751
852
|
const priority: NotifyPriority = options.priority ?? "normal";
|
|
752
853
|
const run: NotifyRun = {
|
|
753
854
|
deliveries: [],
|
|
754
855
|
jobDispatcher: options.immediate ? undefined : (jobDispatcher ?? jobRunner),
|
|
755
856
|
locale: options.locale,
|
|
857
|
+
jobIdempotencyKey: jobIdempotencyKeyFor(idempotencyKey, route),
|
|
756
858
|
};
|
|
757
859
|
|
|
758
860
|
if (idempotencyKey) {
|
|
@@ -772,54 +874,20 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
772
874
|
}
|
|
773
875
|
}
|
|
774
876
|
|
|
775
|
-
|
|
776
|
-
await
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return { deliveries: run.deliveries };
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
if (to !== undefined) {
|
|
789
|
-
let userIds: readonly string[];
|
|
790
|
-
|
|
791
|
-
if (typeof to === "string") {
|
|
792
|
-
userIds = [to];
|
|
793
|
-
} else if ("tenant" in to) {
|
|
794
|
-
userIds = await resolveUserIdsForTenant(to.tenant);
|
|
795
|
-
} else {
|
|
796
|
-
userIds = to;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const prefetchedAddresses =
|
|
800
|
-
priority === "critical"
|
|
801
|
-
? new Map<string, string | null>()
|
|
802
|
-
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
803
|
-
const suppressed = await loadSuppressedAddresses(
|
|
804
|
-
tenantId,
|
|
805
|
-
notificationType,
|
|
806
|
-
priority,
|
|
807
|
-
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
877
|
+
try {
|
|
878
|
+
await dispatchNotification(notificationType, options, tenantId, priority, run);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// A failing release must not replace the dispatch error the caller retries on.
|
|
881
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
|
|
882
|
+
(releaseErr: unknown) => {
|
|
883
|
+
logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
|
|
884
|
+
notificationType,
|
|
885
|
+
});
|
|
886
|
+
},
|
|
808
887
|
);
|
|
809
|
-
|
|
810
|
-
for (const userId of userIds) {
|
|
811
|
-
await deliverToUser(
|
|
812
|
-
userId,
|
|
813
|
-
notificationType,
|
|
814
|
-
data,
|
|
815
|
-
tenantId,
|
|
816
|
-
priority,
|
|
817
|
-
run,
|
|
818
|
-
prefetchedAddresses,
|
|
819
|
-
suppressed,
|
|
820
|
-
);
|
|
821
|
-
}
|
|
888
|
+
throw err;
|
|
822
889
|
}
|
|
890
|
+
await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
|
|
823
891
|
return { deliveries: run.deliveries };
|
|
824
892
|
},
|
|
825
893
|
};
|
|
@@ -247,15 +247,21 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
247
247
|
label: i18nKey("delivery.log.col.channel"),
|
|
248
248
|
renderer: { react: { __component: DELIVERY_CHANNEL_CELL_COMPONENT } },
|
|
249
249
|
},
|
|
250
|
-
{
|
|
250
|
+
{
|
|
251
|
+
field: "recipientLabel",
|
|
252
|
+
label: i18nKey("delivery.log.col.recipient"),
|
|
253
|
+
hideOnNarrow: true,
|
|
254
|
+
},
|
|
251
255
|
{
|
|
252
256
|
field: "status",
|
|
253
257
|
label: i18nKey("delivery.log.col.status"),
|
|
258
|
+
hideOnNarrow: true,
|
|
254
259
|
renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
|
|
255
260
|
},
|
|
256
261
|
{
|
|
257
262
|
field: "error",
|
|
258
263
|
label: i18nKey("delivery.log.col.error"),
|
|
264
|
+
hideOnNarrow: true,
|
|
259
265
|
renderer: { react: { __component: DELIVERY_ERROR_CELL_COMPONENT } },
|
|
260
266
|
},
|
|
261
267
|
],
|
|
@@ -18,7 +18,7 @@ import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
|
18
18
|
import * as z from "zod";
|
|
19
19
|
import { appendAttemptEvent } from "./attempt-log.js";
|
|
20
20
|
import { buildChannelContext } from "./channel-context.js";
|
|
21
|
-
import { DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
21
|
+
import { DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank } from "./constants.js";
|
|
22
22
|
import { collectChannels, redactedMessageOf } from "./delivery-service.js";
|
|
23
23
|
import type {
|
|
24
24
|
ChannelMessage,
|
|
@@ -44,6 +44,7 @@ const renderJobPayloadSchema = z.object({
|
|
|
44
44
|
deliveryAttemptId: z.string(),
|
|
45
45
|
priority: z.enum(["critical", "normal", "low"]),
|
|
46
46
|
message: channelMessageSchema,
|
|
47
|
+
idempotencyKey: z.string().optional(),
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
@@ -110,6 +111,30 @@ function entryFor(
|
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// Only the final retry frees the key: earlier failures are retried by the runner and
|
|
115
|
+
// must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
|
|
116
|
+
// wires the same instance into both.
|
|
117
|
+
async function releaseIdempotencyOnTerminalFailure(
|
|
118
|
+
ctx: JobContext,
|
|
119
|
+
p: RenderJobPayload,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
// skip: nothing to release before the last attempt or without an idempotency key
|
|
122
|
+
if (!ctx.finalAttempt || !p.idempotencyKey) return;
|
|
123
|
+
if (!ctx.redis) {
|
|
124
|
+
ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
|
|
125
|
+
notificationType: p.notificationType,
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await ctx.redis.del(deliveryIdempotencyRedisKey(p.tenantId, p.idempotencyKey));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
ctx.log.warn(`delivery: idempotency key release failed: ${redactedMessageOf(err)}`, {
|
|
133
|
+
notificationType: p.notificationType,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
// The job runner stores err.message in the run row, and BullMQ keeps failedReason and
|
|
114
139
|
// the stack trace in Redis in plain text, so the rethrown error carries only the code.
|
|
115
140
|
// The redacted message goes to the log.
|
|
@@ -132,6 +157,7 @@ async function failAttempt(
|
|
|
132
157
|
p.deliveryAttemptId,
|
|
133
158
|
entryFor(p, "failed", code, p.address),
|
|
134
159
|
);
|
|
160
|
+
await releaseIdempotencyOnTerminalFailure(ctx, p);
|
|
135
161
|
throw new Error(code);
|
|
136
162
|
}
|
|
137
163
|
|