@valentinkolb/cloud 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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"),
@@ -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
  };