@valentinkolb/cloud 0.5.2 → 0.5.4
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
|
@@ -11,10 +11,14 @@ import { z } from "zod";
|
|
|
11
11
|
import { listApps } from "../_internal/registry";
|
|
12
12
|
import { auth, v, type AuthContext } from "../server";
|
|
13
13
|
import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
|
|
14
|
+
import { sendEmail } from "../services/notifications/email";
|
|
14
15
|
import * as settings from "../services/settings";
|
|
15
16
|
import { SETTINGS_MAP } from "../services/settings/defaults";
|
|
16
17
|
|
|
17
18
|
const BulkUpdateSchema = z.record(z.string(), z.unknown());
|
|
19
|
+
const TestEmailSchema = z.object({
|
|
20
|
+
recipient: z.email(),
|
|
21
|
+
});
|
|
18
22
|
|
|
19
23
|
type FieldErrors = Record<string, string>;
|
|
20
24
|
|
|
@@ -28,6 +32,24 @@ const app = new Hono<AuthContext>()
|
|
|
28
32
|
.delete("/legacy", auth.requireRole("admin"), async (c) => {
|
|
29
33
|
return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
|
|
30
34
|
})
|
|
35
|
+
.post("/test-email", auth.requireRole("admin"), v("json", TestEmailSchema), async (c) => {
|
|
36
|
+
const { recipient } = c.req.valid("json");
|
|
37
|
+
const sentAt = new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await sendEmail(recipient, "Cloud test email", {
|
|
41
|
+
rawHtml: `
|
|
42
|
+
<p>This is a test email from Cloud.</p>
|
|
43
|
+
<p>If you received this message, SMTP delivery is configured correctly.</p>
|
|
44
|
+
<p style="margin-top:24px;color:#71717a;font-size:12px;">Sent at ${sentAt}</p>
|
|
45
|
+
`,
|
|
46
|
+
});
|
|
47
|
+
return c.json({ ok: true });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : "Failed to send test email";
|
|
50
|
+
return c.json({ message }, 500);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
31
53
|
.put(
|
|
32
54
|
"/",
|
|
33
55
|
auth.requireRole("admin"),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
|
|
2
2
|
import { sql } from "bun";
|
|
3
|
+
import { toPgUuidArray } from "../../services/postgres";
|
|
3
4
|
|
|
4
5
|
// ==========================
|
|
5
6
|
// Permission Levels
|
|
@@ -85,14 +86,6 @@ type DbAccessUser = {
|
|
|
85
86
|
// Helper Functions
|
|
86
87
|
// ==========================
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
* Converts UUID strings into a PostgreSQL uuid[] literal for relation queries.
|
|
90
|
-
*/
|
|
91
|
-
const toPgUuidArray = (values: string[] | null | undefined): string => {
|
|
92
|
-
if (!Array.isArray(values) || values.length === 0) return "{}";
|
|
93
|
-
return `{${values.join(",")}}`;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
89
|
const uniqueIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))];
|
|
97
90
|
|
|
98
91
|
const escapeLikePattern = (value: string): string => value.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { toPgTextArray as serializePgTextArray } from "../../../services/postgres";
|
|
2
|
+
|
|
1
3
|
export class IpaError extends Error {
|
|
2
4
|
constructor(
|
|
3
5
|
message: string,
|
|
@@ -54,7 +56,4 @@ export const mapIpaErrorCode = (code: number): 400 | 401 | 403 => {
|
|
|
54
56
|
return 400;
|
|
55
57
|
};
|
|
56
58
|
|
|
57
|
-
export const toPgTextArray =
|
|
58
|
-
if (!Array.isArray(values) || values.length === 0) return "{}";
|
|
59
|
-
return `{${values.map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(",")}}`;
|
|
60
|
-
};
|
|
59
|
+
export const toPgTextArray = serializePgTextArray;
|
|
@@ -9,6 +9,11 @@ const log = logger("notifications");
|
|
|
9
9
|
export type NotificationType = "email";
|
|
10
10
|
export type NotificationStatus = "sent" | "pending" | "error";
|
|
11
11
|
export type NotificationStatusSummary = Record<NotificationStatus, number>;
|
|
12
|
+
export type NotificationSearchSummary = NotificationStatusSummary & {
|
|
13
|
+
total: number;
|
|
14
|
+
system: number;
|
|
15
|
+
latestCreatedAt: Date | null;
|
|
16
|
+
};
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Computes notification delivery status from sent/error timestamps.
|
|
@@ -63,6 +68,15 @@ const emptyStatusSummary = (): NotificationStatusSummary => ({
|
|
|
63
68
|
error: 0,
|
|
64
69
|
});
|
|
65
70
|
|
|
71
|
+
const emptySearchSummary = (): NotificationSearchSummary => ({
|
|
72
|
+
total: 0,
|
|
73
|
+
sent: 0,
|
|
74
|
+
pending: 0,
|
|
75
|
+
error: 0,
|
|
76
|
+
system: 0,
|
|
77
|
+
latestCreatedAt: null,
|
|
78
|
+
});
|
|
79
|
+
|
|
66
80
|
type DbNotificationRow = {
|
|
67
81
|
id: string;
|
|
68
82
|
type: NotificationType;
|
|
@@ -316,6 +330,79 @@ export const getStatusSummary = async (options?: {
|
|
|
316
330
|
return summary;
|
|
317
331
|
};
|
|
318
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Count statuses across all entries matching a search term in the current access scope.
|
|
335
|
+
*/
|
|
336
|
+
export const getSearchSummary = async (options: {
|
|
337
|
+
search: string;
|
|
338
|
+
sentBy?: string;
|
|
339
|
+
isAdmin?: boolean;
|
|
340
|
+
}): Promise<NotificationSearchSummary> => {
|
|
341
|
+
const { sentBy, isAdmin } = options;
|
|
342
|
+
const search = options.search.trim();
|
|
343
|
+
if (!search) return emptySearchSummary();
|
|
344
|
+
|
|
345
|
+
const searchPattern = `%${escapeLikePattern(search)}%`;
|
|
346
|
+
let rows: Array<{
|
|
347
|
+
total: number | string;
|
|
348
|
+
sent: number | string;
|
|
349
|
+
pending: number | string;
|
|
350
|
+
error: number | string;
|
|
351
|
+
system: number | string;
|
|
352
|
+
latest_created_at: Date | null;
|
|
353
|
+
}> = [];
|
|
354
|
+
|
|
355
|
+
if (isAdmin) {
|
|
356
|
+
rows = await sql`
|
|
357
|
+
SELECT
|
|
358
|
+
COUNT(*)::int AS total,
|
|
359
|
+
COUNT(*) FILTER (WHERE sent_at IS NOT NULL)::int AS sent,
|
|
360
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NULL)::int AS pending,
|
|
361
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NOT NULL)::int AS error,
|
|
362
|
+
COUNT(*) FILTER (WHERE sent_by IS NULL)::int AS system,
|
|
363
|
+
MAX(created_at) AS latest_created_at
|
|
364
|
+
FROM notifications.messages
|
|
365
|
+
WHERE subject ILIKE ${searchPattern} ESCAPE '\'
|
|
366
|
+
OR content ILIKE ${searchPattern} ESCAPE '\'
|
|
367
|
+
OR recipient ILIKE ${searchPattern} ESCAPE '\'
|
|
368
|
+
`;
|
|
369
|
+
} else if (sentBy) {
|
|
370
|
+
rows = await sql`
|
|
371
|
+
SELECT
|
|
372
|
+
COUNT(*)::int AS total,
|
|
373
|
+
COUNT(*) FILTER (WHERE sent_at IS NOT NULL)::int AS sent,
|
|
374
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NULL)::int AS pending,
|
|
375
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NOT NULL)::int AS error,
|
|
376
|
+
COUNT(*) FILTER (WHERE sent_by IS NULL)::int AS system,
|
|
377
|
+
MAX(created_at) AS latest_created_at
|
|
378
|
+
FROM notifications.messages
|
|
379
|
+
WHERE sent_by = ${sentBy}
|
|
380
|
+
AND (
|
|
381
|
+
subject ILIKE ${searchPattern} ESCAPE '\'
|
|
382
|
+
OR content ILIKE ${searchPattern} ESCAPE '\'
|
|
383
|
+
OR recipient ILIKE ${searchPattern} ESCAPE '\'
|
|
384
|
+
)
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const row = rows[0];
|
|
389
|
+
if (!row) return emptySearchSummary();
|
|
390
|
+
|
|
391
|
+
const toNumber = (value: number | string) => {
|
|
392
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
|
|
393
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
total: toNumber(row.total),
|
|
398
|
+
sent: toNumber(row.sent),
|
|
399
|
+
pending: toNumber(row.pending),
|
|
400
|
+
error: toNumber(row.error),
|
|
401
|
+
system: toNumber(row.system),
|
|
402
|
+
latestCreatedAt: row.latest_created_at,
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
|
|
319
406
|
/**
|
|
320
407
|
* Get a single notification by ID.
|
|
321
408
|
*/
|
|
@@ -481,4 +568,5 @@ export const notifications = {
|
|
|
481
568
|
getPendingSystemCount,
|
|
482
569
|
sendAllPendingSystem,
|
|
483
570
|
getStatusSummary,
|
|
571
|
+
getSearchSummary,
|
|
484
572
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { toPgTextArray, toPgUuidArray } from "./postgres";
|
|
3
|
+
|
|
4
|
+
describe("Postgres array helpers", () => {
|
|
5
|
+
test("serializes UUID arrays and treats non-arrays as empty arrays", () => {
|
|
6
|
+
expect(toPgUuidArray(["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"])).toBe(
|
|
7
|
+
"{11111111-1111-4111-8111-111111111111,22222222-2222-4222-8222-222222222222}",
|
|
8
|
+
);
|
|
9
|
+
expect(toPgUuidArray([])).toBe("{}");
|
|
10
|
+
expect(toPgUuidArray("{}" as unknown as string[])).toBe("{}");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("serializes text arrays with escaping and treats non-arrays as empty arrays", () => {
|
|
14
|
+
expect(toPgTextArray(["alpha", "has space", 'has "quote"', "has\\slash"])).toBe('{"alpha","has space","has \\"quote\\"","has\\\\slash"}');
|
|
15
|
+
expect(toPgTextArray([])).toBe("{}");
|
|
16
|
+
expect(toPgTextArray("{}" as unknown as string[])).toBe("{}");
|
|
17
|
+
});
|
|
18
|
+
});
|