@k2b/cloud 0.28.0 → 0.29.1
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 +9 -6
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +259 -197
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +20 -24
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +14 -2
- package/src/ai/chat/turn-view.tsx +132 -70
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +43 -5
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +76 -33
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +3 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/network-address.ts +1 -0
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +165 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { decryptSecret } from "../secrets";
|
|
4
4
|
import { getNotificationChannel } from "./channels";
|
|
5
|
+
import { quietStateForEvent } from "./quiet";
|
|
5
6
|
|
|
6
7
|
const MAX_DELIVERY_ATTEMPTS = 5;
|
|
7
8
|
const BASE_RETRY_MS = 2_000;
|
|
@@ -19,12 +20,40 @@ type DeliveryRow = {
|
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export type DeliveryAttemptResult =
|
|
22
|
-
| { status: "skipped" | "delivered" | "failed" }
|
|
23
|
+
| { status: "skipped" | "delivered" | "suppressed" | "failed" }
|
|
23
24
|
| { status: "pending"; retryAfterMs: number }
|
|
24
25
|
| { status: "retry"; retryAfterMs: number; error: string };
|
|
25
26
|
|
|
26
27
|
const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Do not disturb and quiet hours hold back recommended browser notifications when they would go out. The
|
|
31
|
+
* notification is dropped, not postponed, so nothing arrives in a burst when the quiet time ends, and it is not
|
|
32
|
+
* rerouted: the event's waiting fallbacks end with it. Required deliveries are part of a protocol and still go.
|
|
33
|
+
*/
|
|
34
|
+
const holdBackForQuietTime = async (delivery: DeliveryRow): Promise<boolean> => {
|
|
35
|
+
if (delivery.channel !== "browser" || delivery.required) return false;
|
|
36
|
+
const quiet = await quietStateForEvent(delivery.event_id);
|
|
37
|
+
if (!quiet?.active) return false;
|
|
38
|
+
const code = quiet.reason === "doNotDisturb" ? "do_not_disturb" : "quiet_hours";
|
|
39
|
+
const message = quiet.reason === "doNotDisturb" ? "Held back by do not disturb." : "Held back during quiet hours.";
|
|
40
|
+
// One transaction: a crash in between must not leave fallbacks waiting, with their payload, behind a dropped push.
|
|
41
|
+
await sql.begin(async (tx) => {
|
|
42
|
+
await tx`
|
|
43
|
+
UPDATE notifications.deliveries
|
|
44
|
+
SET status = 'suppressed', attempt_count = attempt_count - 1, next_attempt_at = NULL,
|
|
45
|
+
error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
46
|
+
WHERE id = ${delivery.id}::uuid AND status = 'sending'
|
|
47
|
+
`;
|
|
48
|
+
await tx`
|
|
49
|
+
UPDATE notifications.deliveries
|
|
50
|
+
SET status = 'suppressed', error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
51
|
+
WHERE event_id = ${delivery.event_id}::uuid AND required = false AND status = 'deferred'
|
|
52
|
+
`;
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
56
|
+
|
|
28
57
|
const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
29
58
|
const delivered = await sql<{ exists: boolean }[]>`
|
|
30
59
|
SELECT EXISTS (
|
|
@@ -85,6 +114,7 @@ export const processNotificationDelivery = async (
|
|
|
85
114
|
if (!delivery) return { status: "skipped" };
|
|
86
115
|
|
|
87
116
|
try {
|
|
117
|
+
if (await holdBackForQuietTime(delivery)) return { status: "suppressed" };
|
|
88
118
|
const driver = getNotificationChannel(delivery.channel);
|
|
89
119
|
if (!driver)
|
|
90
120
|
throw Object.assign(new Error(`Notification channel "${delivery.channel}" is unavailable`), { code: "channel_unavailable" });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
2
|
import type { output, ZodType } from "zod";
|
|
3
|
+
import { isNotificationGroup, notificationGroupTag } from "../../contracts/notification-group";
|
|
3
4
|
import type {
|
|
4
5
|
BoundNotificationDefinition,
|
|
5
6
|
EmailNotificationPresentation,
|
|
@@ -15,6 +16,7 @@ import { encryptSecret } from "../secrets";
|
|
|
15
16
|
import { ensureNotificationDefinition } from "./catalog";
|
|
16
17
|
import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
|
|
17
18
|
import { processNotificationDelivery } from "./dispatcher";
|
|
19
|
+
import { normalizeNotificationPreview } from "./preview";
|
|
18
20
|
import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
|
|
19
21
|
|
|
20
22
|
export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
|
|
@@ -69,14 +71,38 @@ const preparationFailure = (input: { channel: string; required: boolean; routePr
|
|
|
69
71
|
errorMessage: "Notification delivery could not be prepared.",
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
const validatePresentation = (presentation: NotificationPresentation): NotificationPresentation => {
|
|
74
|
+
const validatePresentation = (presentation: NotificationPresentation, appId: string): NotificationPresentation => {
|
|
73
75
|
const title = presentation.title.trim();
|
|
74
76
|
const body = presentation.body?.trim();
|
|
77
|
+
if (presentation.preview !== undefined && typeof presentation.preview !== "string") {
|
|
78
|
+
throw new Error("Notification preview must be a string");
|
|
79
|
+
}
|
|
80
|
+
const preview = presentation.preview === undefined ? undefined : normalizeNotificationPreview(presentation.preview);
|
|
75
81
|
if (!title) throw new Error("Notification title is required");
|
|
76
82
|
if (title.length > 200) throw new Error("Notification title must not exceed 200 characters");
|
|
77
83
|
if (body && body.length > 4_000) throw new Error("Notification body must not exceed 4000 characters");
|
|
78
84
|
const targetHref = presentation.targetHref ? validateNotificationTargetHref(presentation.targetHref) : undefined;
|
|
79
|
-
|
|
85
|
+
if (presentation.group !== undefined && !isNotificationGroup(presentation.group)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Notification group must contain 1 to 128 characters using only letters, digits, dots, underscores, colons, or hyphens",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (presentation.group !== undefined && notificationGroupTag(appId, presentation.group) === null) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"Notification group requires an app ID that starts with a lowercase letter and contains only lowercase letters, digits, and hyphens",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (presentation.badge !== undefined && (!Number.isSafeInteger(presentation.badge) || presentation.badge < 0)) {
|
|
96
|
+
throw new Error("Notification badge must be a non-negative safe integer");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
title,
|
|
100
|
+
...(body ? { body } : {}),
|
|
101
|
+
...(preview ? { preview } : {}),
|
|
102
|
+
...(targetHref ? { targetHref } : {}),
|
|
103
|
+
...(presentation.group !== undefined ? { group: presentation.group } : {}),
|
|
104
|
+
...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
|
|
105
|
+
};
|
|
80
106
|
};
|
|
81
107
|
|
|
82
108
|
const resolveRecipient = async (
|
|
@@ -172,6 +198,8 @@ const prepareChannel = async (input: {
|
|
|
172
198
|
|
|
173
199
|
try {
|
|
174
200
|
const emailPresentation = input.channel === "email" ? await input.emailPresentation?.() : undefined;
|
|
201
|
+
const { preview: _preview, ...otherPresentation } = input.presentation;
|
|
202
|
+
const presentation = input.channel === "browser" ? input.presentation : otherPresentation;
|
|
175
203
|
return await Promise.all(
|
|
176
204
|
destinations.map(async (destination) => ({
|
|
177
205
|
channel: input.channel,
|
|
@@ -179,7 +207,7 @@ const prepareChannel = async (input: {
|
|
|
179
207
|
destinationKey: destination.key,
|
|
180
208
|
destinationLabel: destination.label,
|
|
181
209
|
payloadEncrypted: await encryptSecret(
|
|
182
|
-
driver.createPayload({ presentation
|
|
210
|
+
driver.createPayload({ presentation, email: emailPresentation, destination, event: input.event }),
|
|
183
211
|
),
|
|
184
212
|
required: input.required,
|
|
185
213
|
routePriority: input.routePriority,
|
|
@@ -241,7 +269,7 @@ export const sendTypedNotification = async <
|
|
|
241
269
|
|
|
242
270
|
const data: output<S> = definition.data.parse(input.data);
|
|
243
271
|
const renderContext = { locale: normalizeLocale(input.locale) };
|
|
244
|
-
const presentation = validatePresentation(await definition.render(data, renderContext));
|
|
272
|
+
const presentation = validatePresentation(await definition.render(data, renderContext), definition.appId);
|
|
245
273
|
const resolved = await resolveRecipient(input.recipient);
|
|
246
274
|
const candidateEventId = crypto.randomUUID();
|
|
247
275
|
await ensureNotificationDefinition(definition);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const NOTIFICATION_PREVIEW_LIMIT = 200;
|
|
2
|
+
const PREVIEW_INPUT_LIMIT = 1_000;
|
|
3
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
|
|
5
|
+
export const normalizeNotificationPreview = (value: string): string | undefined => {
|
|
6
|
+
let prefix = value.slice(0, PREVIEW_INPUT_LIMIT);
|
|
7
|
+
const clipped = value.length > prefix.length;
|
|
8
|
+
if (clipped) {
|
|
9
|
+
// A boundary at or before the bound depends on at most one following code point (two UTF-16 units).
|
|
10
|
+
const last = segmenter.segment(value.slice(0, PREVIEW_INPUT_LIMIT + 2)).containing(PREVIEW_INPUT_LIMIT - 1);
|
|
11
|
+
if (last && last.index + last.segment.length > PREVIEW_INPUT_LIMIT) prefix = prefix.slice(0, last.index);
|
|
12
|
+
}
|
|
13
|
+
const text = prefix.replace(/[\s\p{Cc}]+/gu, " ").trim();
|
|
14
|
+
if (!text) return undefined;
|
|
15
|
+
if (!clipped && text.length <= NOTIFICATION_PREVIEW_LIMIT) return text;
|
|
16
|
+
|
|
17
|
+
let end = 0;
|
|
18
|
+
for (const { segment, index } of segmenter.segment(text)) {
|
|
19
|
+
if (index + segment.length > NOTIFICATION_PREVIEW_LIMIT - 1) break;
|
|
20
|
+
end = index + segment.length;
|
|
21
|
+
}
|
|
22
|
+
return `${text.slice(0, end).trimEnd()}…`;
|
|
23
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { dates } from "@k2b/stdlib";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import type { MutationResult } from "../../contracts/shared";
|
|
4
|
+
import {
|
|
5
|
+
NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS,
|
|
6
|
+
type NotificationQuietHours,
|
|
7
|
+
NotificationQuietHoursSchema,
|
|
8
|
+
type NotificationQuietSettings,
|
|
9
|
+
type NotificationQuietState,
|
|
10
|
+
type UpdateNotificationQuietSettings,
|
|
11
|
+
} from "../../contracts/user-notifications";
|
|
12
|
+
import { normalizeTimeZone } from "../../shared/time";
|
|
13
|
+
|
|
14
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
15
|
+
/** How far ahead `until` looks. A schedule that stays quiet for longer has no end worth naming. */
|
|
16
|
+
const LOOKAHEAD_DAYS = 8;
|
|
17
|
+
|
|
18
|
+
type QuietInput = { doNotDisturbUntil: Date | null; quietHours: NotificationQuietHours };
|
|
19
|
+
type Interval = { start: number; end: number };
|
|
20
|
+
|
|
21
|
+
const pad2 = (value: number): string => String(value).padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The quiet-hours intervals that touch `[from - 1 day, from + days]`, as instants. Each period belongs to the
|
|
25
|
+
* local day it starts on; wall-clock times are converted per date, so a period keeps its local times across
|
|
26
|
+
* daylight-saving changes. A time inside a skipped hour moves forward like the clock does. In a repeated hour a
|
|
27
|
+
* start takes the first occurrence and an end the second, so the period covers every instant the clock shows a
|
|
28
|
+
* time inside it.
|
|
29
|
+
*/
|
|
30
|
+
const quietHourIntervals = (hours: NotificationQuietHours, from: number, days: number): Interval[] => {
|
|
31
|
+
if (hours.periods.length === 0) return [];
|
|
32
|
+
const timeZone = normalizeTimeZone(hours.timeZone);
|
|
33
|
+
const [localDate = ""] = dates.instantToZonedInput(new Date(from), timeZone).split("T");
|
|
34
|
+
const [year, month, day] = localDate.split("-").map(Number) as [number, number, number];
|
|
35
|
+
const instant = (offset: number, time: string, disambiguation: "compatible" | "later"): number => {
|
|
36
|
+
const date = new Date(Date.UTC(year, month - 1, day + offset));
|
|
37
|
+
const wallClock = `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}T${time}`;
|
|
38
|
+
return Date.parse(dates.zonedDateTimeToInstant(wallClock, timeZone, { disambiguation }));
|
|
39
|
+
};
|
|
40
|
+
const intervals: Interval[] = [];
|
|
41
|
+
for (let offset = -1; offset <= days; offset += 1) {
|
|
42
|
+
const isoWeekday = new Date(Date.UTC(year, month - 1, day + offset)).getUTCDay() || 7;
|
|
43
|
+
for (const period of hours.periods) {
|
|
44
|
+
if (!period.days.includes(isoWeekday)) continue;
|
|
45
|
+
const start = instant(offset, period.start, "compatible");
|
|
46
|
+
const end = instant(period.end > period.start ? offset : offset + 1, period.end, "later");
|
|
47
|
+
if (end > start) intervals.push({ start, end });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return intervals;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Follow quiet hours from `from` across overlapping and adjoining periods. Returns `from` when it is not quiet
|
|
55
|
+
* and null when the quiet time does not end within the lookahead, for example with every day fully quiet.
|
|
56
|
+
*/
|
|
57
|
+
const quietHoursEnd = (hours: NotificationQuietHours, from: number): number | null => {
|
|
58
|
+
const intervals = quietHourIntervals(hours, from, LOOKAHEAD_DAYS);
|
|
59
|
+
const horizon = from + LOOKAHEAD_DAYS * DAY_MS;
|
|
60
|
+
let until = from;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const ends = intervals.filter((interval) => interval.start <= until && until < interval.end).map((interval) => interval.end);
|
|
63
|
+
if (ends.length === 0) return until;
|
|
64
|
+
until = Math.max(...ends);
|
|
65
|
+
if (until > horizon) return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether browser notifications are held back at `now`, why, and until when. Do not disturb names the reason
|
|
71
|
+
* while it lasts; `until` also covers quiet hours that continue when it ends.
|
|
72
|
+
*/
|
|
73
|
+
export const notificationQuietState = (input: QuietInput, now: Date = new Date()): NotificationQuietState => {
|
|
74
|
+
const at = now.getTime();
|
|
75
|
+
const iso = (instant: number | null) => (instant === null ? null : new Date(instant).toISOString());
|
|
76
|
+
const doNotDisturbUntil = input.doNotDisturbUntil?.getTime() ?? null;
|
|
77
|
+
if (doNotDisturbUntil !== null && doNotDisturbUntil > at) {
|
|
78
|
+
return { active: true, reason: "doNotDisturb", until: iso(quietHoursEnd(input.quietHours, doNotDisturbUntil)), nextStart: null };
|
|
79
|
+
}
|
|
80
|
+
const until = quietHoursEnd(input.quietHours, at);
|
|
81
|
+
if (until !== at) return { active: true, reason: "quietHours", until: iso(until), nextStart: null };
|
|
82
|
+
const starts = quietHourIntervals(input.quietHours, at, LOOKAHEAD_DAYS)
|
|
83
|
+
.map((interval) => interval.start)
|
|
84
|
+
.filter((start) => start > at && start <= at + LOOKAHEAD_DAYS * DAY_MS);
|
|
85
|
+
return { active: false, reason: null, until: null, nextStart: iso(starts.length > 0 ? Math.min(...starts) : null) };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type QuietRow = { do_not_disturb_until: Date | null; time_zone: string; periods: unknown };
|
|
89
|
+
|
|
90
|
+
/** Parse a stored row defensively: a schedule that no longer validates silences nothing. */
|
|
91
|
+
export const quietInputFromRow = (row: QuietRow | undefined, fallbackTimeZone = "UTC"): QuietInput => {
|
|
92
|
+
const parsed = row ? NotificationQuietHoursSchema.safeParse({ timeZone: row.time_zone, periods: row.periods }) : null;
|
|
93
|
+
return {
|
|
94
|
+
doNotDisturbUntil: row?.do_not_disturb_until ?? null,
|
|
95
|
+
quietHours: parsed?.success ? parsed.data : { timeZone: normalizeTimeZone(row?.time_zone ?? fallbackTimeZone), periods: [] },
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const toSettings = (input: QuietInput, now = new Date()): NotificationQuietSettings => ({
|
|
100
|
+
doNotDisturbUntil:
|
|
101
|
+
input.doNotDisturbUntil && input.doNotDisturbUntil.getTime() > now.getTime() ? input.doNotDisturbUntil.toISOString() : null,
|
|
102
|
+
quietHours: input.quietHours,
|
|
103
|
+
state: notificationQuietState(input, now),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** The person's quiet settings and current state. Without saved quiet hours, `fallbackTimeZone` is offered. */
|
|
107
|
+
const getQuietSettings = async (userId: string, fallbackTimeZone = "UTC"): Promise<NotificationQuietSettings> => {
|
|
108
|
+
const [row] = await sql<QuietRow[]>`
|
|
109
|
+
SELECT do_not_disturb_until, time_zone, periods FROM notifications.quiet_times WHERE user_id = ${userId}::uuid
|
|
110
|
+
`;
|
|
111
|
+
return toSettings(quietInputFromRow(row, fallbackTimeZone));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const updateQuietSettings = async (config: {
|
|
115
|
+
userId: string;
|
|
116
|
+
update: UpdateNotificationQuietSettings;
|
|
117
|
+
fallbackTimeZone?: string;
|
|
118
|
+
}): Promise<MutationResult<NotificationQuietSettings>> => {
|
|
119
|
+
const { doNotDisturbUntil, quietHours } = config.update;
|
|
120
|
+
const until = doNotDisturbUntil ? new Date(doNotDisturbUntil) : null;
|
|
121
|
+
if (until && until.getTime() <= Date.now()) {
|
|
122
|
+
return { ok: false, error: "Do not disturb must end in the future", status: 400 };
|
|
123
|
+
}
|
|
124
|
+
if (until && until.getTime() > Date.now() + NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS * DAY_MS) {
|
|
125
|
+
return { ok: false, error: "Do not disturb must end within a year", status: 400 };
|
|
126
|
+
}
|
|
127
|
+
const timeZone = normalizeTimeZone(quietHours?.timeZone ?? config.fallbackTimeZone);
|
|
128
|
+
const periods = quietHours ? JSON.stringify(quietHours.periods) : "[]";
|
|
129
|
+
await sql`
|
|
130
|
+
INSERT INTO notifications.quiet_times (user_id, do_not_disturb_until, time_zone, periods, updated_at)
|
|
131
|
+
VALUES (${config.userId}::uuid, ${until}::timestamptz, ${timeZone}, (${periods}::text)::jsonb, now())
|
|
132
|
+
ON CONFLICT (user_id) DO UPDATE SET
|
|
133
|
+
do_not_disturb_until = CASE WHEN ${doNotDisturbUntil !== undefined} THEN EXCLUDED.do_not_disturb_until
|
|
134
|
+
ELSE notifications.quiet_times.do_not_disturb_until END,
|
|
135
|
+
time_zone = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.time_zone ELSE notifications.quiet_times.time_zone END,
|
|
136
|
+
periods = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.periods ELSE notifications.quiet_times.periods END,
|
|
137
|
+
updated_at = now()
|
|
138
|
+
`;
|
|
139
|
+
return { ok: true, data: await getQuietSettings(config.userId, config.fallbackTimeZone) };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The quiet state of an event's recipient, or null when the recipient is not a Cloud user. */
|
|
143
|
+
export const quietStateForEvent = async (eventId: string, now = new Date()): Promise<NotificationQuietState | null> => {
|
|
144
|
+
const [row] = await sql<QuietRow[]>`
|
|
145
|
+
SELECT q.do_not_disturb_until, q.time_zone, q.periods
|
|
146
|
+
FROM notifications.events e
|
|
147
|
+
JOIN notifications.quiet_times q ON q.user_id = e.recipient_user_id
|
|
148
|
+
WHERE e.id = ${eventId}::uuid
|
|
149
|
+
`;
|
|
150
|
+
return row ? notificationQuietState(quietInputFromRow(row), now) : null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const userQuietTimes = { get: getQuietSettings, update: updateQuietSettings } as const;
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import { normalizeLocale } from "../../shared/locale";
|
|
15
15
|
import { toPgTextArray } from "../postgres";
|
|
16
16
|
import { listNotificationChannels } from "./channels";
|
|
17
|
+
import { userQuietTimes } from "./quiet";
|
|
17
18
|
|
|
18
19
|
type PreferenceRow = {
|
|
19
20
|
id: string;
|
|
@@ -164,6 +165,10 @@ const publicDeliveryError = (code: string | null, status: NotificationDeliverySt
|
|
|
164
165
|
return { code, message: "This delivery channel is currently unavailable." };
|
|
165
166
|
case "no_endpoint":
|
|
166
167
|
return { code, message: "No registered destination is available for this channel." };
|
|
168
|
+
case "do_not_disturb":
|
|
169
|
+
return { code, message: "Held back because do not disturb was on." };
|
|
170
|
+
case "quiet_hours":
|
|
171
|
+
return { code, message: "Held back during your quiet hours." };
|
|
167
172
|
case "fallback_not_needed":
|
|
168
173
|
return { code, message: "Another preferred channel delivered this notification." };
|
|
169
174
|
case "lease_recovered":
|
|
@@ -245,4 +250,5 @@ const listHistory = async (config: {
|
|
|
245
250
|
export const userNotifications = {
|
|
246
251
|
preferences: { list: listPreferences, set: setPreference, reset: resetPreference },
|
|
247
252
|
history: { list: listHistory },
|
|
253
|
+
quiet: userQuietTimes,
|
|
248
254
|
} as const;
|
package/src/services/outbox.ts
CHANGED
|
@@ -12,10 +12,11 @@ import { logger } from "./logging";
|
|
|
12
12
|
* It works in batches: claims of one outbox (table and `where`) take turns, a
|
|
13
13
|
* claim takes rows in `sequence` order with several rows per ordering key, and
|
|
14
14
|
* the dispatcher publishes keys concurrently and the rows of one key in order,
|
|
15
|
-
* then completes the batch in one statement. A
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* then completes the batch in one statement. A batch starts no publish after
|
|
16
|
+
* its claim has ended, because the next claim takes its rows. A row that never
|
|
17
|
+
* failed is due at once. Claims stay proportional to the batch with an index on
|
|
18
|
+
* the `where` columns and `sequence`, and one on the `where` columns and
|
|
19
|
+
* `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
|
|
19
20
|
*
|
|
20
21
|
* Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
|
|
21
22
|
* `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
|
|
@@ -52,7 +53,9 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
|
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
export type PgOutbox<Row extends OutboxRow> = {
|
|
56
|
+
/** @internal Test seam; applications use `reconcile`, `notify`, `start`, and `stop`. */
|
|
55
57
|
claim(limit?: number): Promise<Row[]>;
|
|
58
|
+
/** @internal Test seam; it publishes one row outside the order of its key. */
|
|
56
59
|
dispatch(row: Row, publish?: (row: Row) => Promise<unknown>): Promise<void>;
|
|
57
60
|
/** Drain every claimable row; concurrent calls join the active pass. */
|
|
58
61
|
reconcile(): Promise<number>;
|
|
@@ -78,7 +81,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
78
81
|
const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
|
|
79
82
|
const matches = (alias: "current" | "busy") =>
|
|
80
83
|
filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
|
|
81
|
-
|
|
84
|
+
// Sorted, so the order of the `where` entries cannot give one outbox two locks. Descending keeps the
|
|
85
|
+
// lock that live outboxes (`kind`, `app_id`) always took, so replicas of two releases share it.
|
|
86
|
+
const where = Object.entries(config.where ?? {}).sort(([left], [right]) => (left < right ? 1 : -1));
|
|
87
|
+
const claimLock = `${config.table}:${JSON.stringify(Object.fromEntries(where))}`;
|
|
82
88
|
|
|
83
89
|
/**
|
|
84
90
|
* Claims rows in sequence order, several per key, and never a row of a key
|
|
@@ -151,19 +157,26 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
151
157
|
}
|
|
152
158
|
};
|
|
153
159
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key.
|
|
162
|
+
* A run starts no publish once the claim has ended (`claimEnds`, monotonic): another replica may own the rest by then.
|
|
163
|
+
*/
|
|
164
|
+
const dispatchBatch = async (rows: Row[], claimEnds: number) => {
|
|
156
165
|
const delivered: string[] = [];
|
|
157
166
|
const runs = Map.groupBy(rows, (row) => row[runKey]);
|
|
158
167
|
const results = await Promise.allSettled(
|
|
159
168
|
[...runs.values()].map(async (run) => {
|
|
160
169
|
for (const [index, row] of run.entries()) {
|
|
170
|
+
if (performance.now() >= claimEnds) return;
|
|
161
171
|
try {
|
|
162
172
|
await config.publish(row);
|
|
163
173
|
} catch (error) {
|
|
164
174
|
await retryLater(row, error);
|
|
165
175
|
const rest = run.slice(index + 1).map((later) => later.id);
|
|
166
|
-
|
|
176
|
+
// Past the claim, the rest is free already, and releasing it could end another replica's claim.
|
|
177
|
+
if (rest.length > 0 && performance.now() < claimEnds) {
|
|
178
|
+
await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
|
|
179
|
+
}
|
|
167
180
|
return;
|
|
168
181
|
}
|
|
169
182
|
delivered.push(row.id);
|
|
@@ -185,8 +198,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
185
198
|
let rows: Row[];
|
|
186
199
|
do {
|
|
187
200
|
reconcileRequested = false;
|
|
201
|
+
// Taken before the claim, so it ends no later than the `claimed_until` the claim sets.
|
|
202
|
+
const claimEnds = performance.now() + claimMs;
|
|
188
203
|
rows = await claim();
|
|
189
|
-
await dispatchBatch(rows);
|
|
204
|
+
await dispatchBatch(rows, claimEnds);
|
|
190
205
|
processed += rows.length;
|
|
191
206
|
} while (reconcileRequested || rows.length > 0);
|
|
192
207
|
return processed;
|
|
@@ -19,11 +19,15 @@ import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./message
|
|
|
19
19
|
import { sendMail } from "./send";
|
|
20
20
|
import { OutgoingMailError, outgoingMailStore } from "./store";
|
|
21
21
|
|
|
22
|
-
const identity = (): Result<string, MailServiceError> => {
|
|
22
|
+
const identity = (read = false): Result<string, MailServiceError> => {
|
|
23
23
|
const appId = getProcessApplicationId();
|
|
24
24
|
if (!appId) return fail({ code: "mail_unavailable", message: "Start the application before using outgoing mail.", status: 500 });
|
|
25
|
-
if (!getProcessPlatformPermissions().includes("mail:send"))
|
|
26
|
-
return fail({
|
|
25
|
+
if (!getProcessPlatformPermissions().includes("mail:send") && !(read && getProcessPlatformPermissions().includes("mail:read")))
|
|
26
|
+
return fail({
|
|
27
|
+
code: "mail_not_declared",
|
|
28
|
+
message: read ? 'Declare "mail:send" or "mail:read" in platformPermissions.' : 'Declare platformPermissions: ["mail:send"].',
|
|
29
|
+
status: 403,
|
|
30
|
+
});
|
|
27
31
|
return ok(appId);
|
|
28
32
|
};
|
|
29
33
|
const failure = (error: unknown): MailServiceError => {
|
|
@@ -39,6 +43,16 @@ const failure = (error: unknown): MailServiceError => {
|
|
|
39
43
|
};
|
|
40
44
|
};
|
|
41
45
|
export const mail = {
|
|
46
|
+
async readableApps(): Promise<Result<string[], MailServiceError>> {
|
|
47
|
+
const caller = identity(true);
|
|
48
|
+
if (!caller.ok) return caller;
|
|
49
|
+
try {
|
|
50
|
+
const granted = getProcessPlatformPermissions().includes("mail:read") ? await outgoingMailStore.logAppsFor(caller.data) : [];
|
|
51
|
+
return ok([caller.data, ...granted.filter((appId) => appId !== caller.data && appId !== "core").sort()]);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return fail(failure(error));
|
|
54
|
+
}
|
|
55
|
+
},
|
|
42
56
|
async enqueue(messages: MailMessage[]): Promise<Result<{ batchId: string; ids: string[] }, MailServiceError>> {
|
|
43
57
|
const caller = identity();
|
|
44
58
|
const parsed = caller.ok ? MailBatchSchema.safeParse(messages) : undefined;
|
|
@@ -97,14 +111,28 @@ export const mail = {
|
|
|
97
111
|
}
|
|
98
112
|
},
|
|
99
113
|
async list(filter: MailFilter = {}, page: MailPageParams = {}): Promise<Result<MailPage, MailServiceError>> {
|
|
100
|
-
const caller = identity();
|
|
114
|
+
const caller = identity(true);
|
|
101
115
|
if (!caller.ok) return caller;
|
|
102
116
|
const parsedFilter = MailFilterSchema.safeParse(filter);
|
|
103
117
|
const parsedPage = MailPageParamsSchema.safeParse(page);
|
|
104
118
|
if (!parsedFilter.success || !parsedPage.success)
|
|
105
119
|
return fail({ code: "bad_input", message: "Invalid outgoing mail filter or page.", status: 400 });
|
|
106
120
|
try {
|
|
107
|
-
|
|
121
|
+
const { apps = [caller.data], recipient, ...filter } = parsedFilter.data;
|
|
122
|
+
const otherApps = apps.filter((appId) => appId !== caller.data);
|
|
123
|
+
if (otherApps.length) {
|
|
124
|
+
if (!getProcessPlatformPermissions().includes("mail:read"))
|
|
125
|
+
return fail({
|
|
126
|
+
code: "mail_not_declared",
|
|
127
|
+
message: 'Declare "mail:read" in platformPermissions to read other apps’ mail logs.',
|
|
128
|
+
status: 403,
|
|
129
|
+
});
|
|
130
|
+
const granted = await outgoingMailStore.logAppsFor(caller.data);
|
|
131
|
+
const denied = otherApps.find((appId) => !granted.includes(appId));
|
|
132
|
+
if (denied)
|
|
133
|
+
return fail({ code: "app_not_allowed", message: `This application may not read the mail log of "${denied}".`, status: 403 });
|
|
134
|
+
}
|
|
135
|
+
return ok(await outgoingMailMessages.list({ ...filter, apps, recipientExact: recipient }, parsedPage.data, false, caller.data));
|
|
108
136
|
} catch (error) {
|
|
109
137
|
return fail(failure(error));
|
|
110
138
|
}
|
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import {
|
|
5
5
|
type AdminMailFilter,
|
|
6
6
|
type AdminMailRecord,
|
|
7
|
+
type MailFilter,
|
|
7
8
|
type MailMessage,
|
|
8
9
|
type MailPage,
|
|
9
10
|
type MailPageParams,
|
|
@@ -86,16 +87,17 @@ type LogRow = Pick<
|
|
|
86
87
|
| "sent_at"
|
|
87
88
|
| "content_purged_at"
|
|
88
89
|
>;
|
|
89
|
-
const logColumns = (db: SQL,
|
|
90
|
-
${
|
|
90
|
+
const logColumns = (db: SQL, ownApp?: string) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
|
|
91
|
+
${ownApp === undefined ? db`NULL::text AS text_body` : db`CASE WHEN app_id = ${ownApp} THEN text_body END AS text_body`}, attachments, status, error_code, error_message, smtp_response,
|
|
91
92
|
failures, attempt_count, actor_id, actor_name, created_at, created_at::text AS cursor_created_at, sent_at, content_purged_at`;
|
|
92
93
|
const metadata = async (id: string): Promise<AdminMailRecord | undefined> => {
|
|
93
|
-
const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql
|
|
94
|
+
const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
|
|
94
95
|
return row ? adminMessageRecord(row) : undefined;
|
|
95
96
|
};
|
|
96
97
|
const date = (value: Date | string) => new Date(value).toISOString();
|
|
97
98
|
export const messageRecord = (row: LogRow): MailRecord => ({
|
|
98
99
|
id: row.id,
|
|
100
|
+
appId: row.app_id,
|
|
99
101
|
profile: row.profile_key,
|
|
100
102
|
to: row.to_addresses,
|
|
101
103
|
subject: row.subject,
|
|
@@ -329,28 +331,67 @@ export const decodeMailCursor = (cursor?: string) => {
|
|
|
329
331
|
throw new OutgoingMailError("bad_input", "Invalid outgoing mail cursor.");
|
|
330
332
|
}
|
|
331
333
|
};
|
|
334
|
+
type LogFilter = AdminMailFilter & Pick<MailFilter, "apps"> & { recipientExact?: string };
|
|
335
|
+
/**
|
|
336
|
+
* The longest one search of the send log may run, the same budget as a Mail search; its statements share
|
|
337
|
+
* it. A broad search on a large log, or one that runs before Core has built the search indexes, could
|
|
338
|
+
* otherwise read every record on every page.
|
|
339
|
+
*/
|
|
340
|
+
const SEARCH_BUDGET_MS = 5_000;
|
|
341
|
+
const searchTooBroad = () =>
|
|
342
|
+
new OutgoingMailError("bad_input", "This search took too long. Narrow it with a longer term, fewer apps, or a shorter time range.");
|
|
343
|
+
const isStatementTimeout = (error: unknown) => {
|
|
344
|
+
const candidate = error as { code?: unknown; errno?: unknown } | null;
|
|
345
|
+
return candidate?.errno === "57014" || candidate?.code === "57014";
|
|
346
|
+
};
|
|
347
|
+
const withinSearchBudget = async <T>(read: (tx: SQL, limit: () => Promise<void>) => Promise<T>): Promise<T> => {
|
|
348
|
+
const deadline = Date.now() + SEARCH_BUDGET_MS;
|
|
349
|
+
try {
|
|
350
|
+
return await sql.begin((tx) =>
|
|
351
|
+
read(tx, async () => {
|
|
352
|
+
const remainingMs = Math.floor(deadline - Date.now());
|
|
353
|
+
if (remainingMs < 1) throw searchTooBroad();
|
|
354
|
+
await tx`SELECT set_config('statement_timeout', ${`${remainingMs}ms`}, true)`;
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw isStatementTimeout(error) ? searchTooBroad() : error;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
332
361
|
const list = async (
|
|
333
|
-
filter:
|
|
362
|
+
filter: LogFilter,
|
|
334
363
|
page: MailPageParams = {},
|
|
335
364
|
metadata = false,
|
|
365
|
+
ownApp?: string,
|
|
336
366
|
): Promise<MailPage<MailRecord | AdminMailRecord>> => {
|
|
337
367
|
const cursor = decodeMailCursor(page.cursor);
|
|
338
368
|
const perPage = page.perPage ?? 50;
|
|
339
369
|
const pageNumber = cursor?.page ?? page.page ?? 1;
|
|
340
|
-
const where =
|
|
341
|
-
${filter.
|
|
342
|
-
${filter.
|
|
343
|
-
${filter.
|
|
344
|
-
${filter.
|
|
345
|
-
${filter.
|
|
346
|
-
${filter.
|
|
347
|
-
${filter.
|
|
348
|
-
${filter.
|
|
349
|
-
${filter.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
${
|
|
353
|
-
|
|
370
|
+
const where = (db: SQL) => db`WHERE TRUE
|
|
371
|
+
${filter.apps === undefined ? db`` : db`AND app_id = ANY(${toPgTextArray(filter.apps)}::text[])`}
|
|
372
|
+
${filter.app === undefined ? db`` : db`AND app_id = ${filter.app}`}
|
|
373
|
+
${filter.profile === undefined ? db`` : db`AND profile_key = ${filter.profile}`}
|
|
374
|
+
${filter.ids === undefined ? db`` : db`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
|
|
375
|
+
${filter.batchId === undefined ? db`` : db`AND batch_id = ${filter.batchId}::uuid`}
|
|
376
|
+
${filter.status === undefined ? db`` : db`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
|
|
377
|
+
${filter.since === undefined ? db`` : db`AND created_at >= ${filter.since}::timestamptz`}
|
|
378
|
+
${filter.until === undefined ? db`` : db`AND created_at < ${filter.until}::timestamptz`}
|
|
379
|
+
${filter.q === undefined ? db`` : db`AND outgoing_mail.search_text(subject, to_addresses) ILIKE ${`%${escapeLikePattern(filter.q.toLowerCase())}%`}`}
|
|
380
|
+
${filter.recipientExact === undefined ? db`` : db`AND outgoing_mail.lower_addresses(to_addresses) @> ARRAY[pg_catalog.lower(${filter.recipientExact})]`}
|
|
381
|
+
${filter.ref === undefined ? db`` : db`AND ref_scope = ${filter.ref.scope}`}
|
|
382
|
+
${filter.ref?.id === undefined ? db`` : db`AND ref_id = ${filter.ref.id}`}
|
|
383
|
+
${filter.recipient === undefined ? db`` : db`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
|
|
384
|
+
const read = async (db: SQL, limit: () => Promise<void>) => {
|
|
385
|
+
await limit();
|
|
386
|
+
const [count] = await db<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where(db)}`;
|
|
387
|
+
await limit();
|
|
388
|
+
const rows = await db<LogRow[]>`SELECT ${logColumns(db, metadata ? undefined : ownApp)} FROM outgoing_mail.messages ${where(db)}
|
|
389
|
+
${cursor ? db`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : db``}
|
|
390
|
+
ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
|
|
391
|
+
return { total: count?.total ?? 0, rows };
|
|
392
|
+
};
|
|
393
|
+
const { total, rows } =
|
|
394
|
+
filter.q === undefined && filter.recipientExact === undefined ? await read(sql, async () => {}) : await withinSearchBudget(read);
|
|
354
395
|
const hasNext = rows.length > perPage;
|
|
355
396
|
const items = rows.slice(0, perPage);
|
|
356
397
|
const last = items.at(-1);
|
|
@@ -358,7 +399,7 @@ const list = async (
|
|
|
358
399
|
items: items.map(metadata ? adminMessageRecord : messageRecord),
|
|
359
400
|
page: pageNumber,
|
|
360
401
|
perPage,
|
|
361
|
-
total
|
|
402
|
+
total,
|
|
362
403
|
hasNext,
|
|
363
404
|
...(hasNext && last
|
|
364
405
|
? {
|