@valentinkolb/cloud 0.1.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.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,413 @@
1
+ import { sql } from "bun";
2
+ import { sendEmail } from "./email";
3
+ import type { PaginationParams } from "../../contracts/shared";
4
+ import { escapeLikePattern } from "../postgres";
5
+ import { logger } from "../logging";
6
+
7
+ const log = logger("notifications");
8
+
9
+ export type NotificationType = "email";
10
+ export type NotificationStatus = "sent" | "pending" | "error";
11
+
12
+ /**
13
+ * Computes notification delivery status from sent/error timestamps.
14
+ */
15
+ const determineStatus = (sentAt: Date | null, error: string | null): NotificationStatus => {
16
+ if (sentAt) return "sent";
17
+ if (error) return "error";
18
+ return "pending";
19
+ };
20
+
21
+ export type SendNotificationParams = {
22
+ type: NotificationType;
23
+ recipient: string;
24
+ subject: string;
25
+ content?: string;
26
+ rawHtml?: string;
27
+ autoSend?: boolean; // default true - when false, only store in DB without sending
28
+ sentBy?: string; // user ID of sender
29
+ };
30
+
31
+ export type SendNotificationResult = {
32
+ id: string;
33
+ status: NotificationStatus;
34
+ error?: string;
35
+ };
36
+
37
+ export type SendToUserParams = {
38
+ userId: string;
39
+ subject: string;
40
+ content?: string;
41
+ rawHtml?: string;
42
+ sentBy?: string; // user ID of sender
43
+ };
44
+
45
+ export type NotificationMessage = {
46
+ id: string;
47
+ type: NotificationType;
48
+ recipient: string;
49
+ subject: string;
50
+ content: string;
51
+ sentAt: Date | null;
52
+ error: string | null;
53
+ createdAt: Date;
54
+ sentBy: string | null;
55
+ sentByName: string | null;
56
+ status: NotificationStatus;
57
+ };
58
+
59
+ type DbNotificationRow = {
60
+ id: string;
61
+ type: NotificationType;
62
+ recipient: string;
63
+ subject: string;
64
+ content: string;
65
+ sent_at: Date | null;
66
+ error: string | null;
67
+ created_at: Date;
68
+ sent_by: string | null;
69
+ sent_by_name: string | null;
70
+ };
71
+
72
+ /**
73
+ * Send a notification. Persists to DB, attempts delivery (if autoSend=true), updates sent_at/error.
74
+ */
75
+ export const send = async (params: SendNotificationParams): Promise<SendNotificationResult> => {
76
+ const { type, recipient, subject, content, rawHtml, autoSend = true, sentBy } = params;
77
+
78
+ // Persist to DB
79
+ const dbContent = rawHtml ?? content ?? "";
80
+ const rows = await sql`
81
+ INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
82
+ VALUES (${type}, ${recipient}, ${subject}, ${dbContent}, ${sentBy ?? null})
83
+ RETURNING id
84
+ `;
85
+ const id = rows[0]!.id as string;
86
+
87
+ // Skip delivery if autoSend is false
88
+ if (!autoSend) {
89
+ log.info("Stored notification", { type, recipient });
90
+ return { id, status: "pending" };
91
+ }
92
+
93
+ // Attempt delivery
94
+ try {
95
+ if (type === "email") {
96
+ await sendEmail(recipient, subject, { content, rawHtml });
97
+ }
98
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
99
+ return { id, status: "sent" };
100
+ } catch (e) {
101
+ const error = e instanceof Error ? e.message : String(e);
102
+ log.error("Failed to send", { type, recipient, error });
103
+ await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
104
+ return { id, status: "error", error };
105
+ }
106
+ };
107
+
108
+ /**
109
+ * Send a notification to a user by their database ID.
110
+ * Looks up the user's preferred notification method (currently email only).
111
+ */
112
+ export const sendToUser = async (params: SendToUserParams): Promise<{ ok: true; id: string } | { ok: false; error: string }> => {
113
+ const { userId, subject, content, rawHtml, sentBy } = params;
114
+
115
+ // Get user's email from database
116
+ const rows = await sql`SELECT mail FROM auth.users WHERE id = ${userId}`;
117
+ if (rows.length === 0) {
118
+ return { ok: false, error: "User not found" };
119
+ }
120
+
121
+ const email = rows[0]!.mail as string | null;
122
+ if (!email) {
123
+ return { ok: false, error: "User has no email address" };
124
+ }
125
+
126
+ // For now, always use email. Later this can be extended to support other notification types
127
+ // based on user preferences stored in the database.
128
+ const result = await send({
129
+ type: "email",
130
+ recipient: email,
131
+ subject,
132
+ content,
133
+ rawHtml,
134
+ sentBy,
135
+ });
136
+
137
+ return { ok: true, id: result.id };
138
+ };
139
+
140
+ /**
141
+ * List notifications with pagination and optional search.
142
+ * Admins see all, regular users see only their own sent notifications.
143
+ */
144
+ export const list = async (
145
+ pagination: PaginationParams,
146
+ options?: { sentBy?: string; isAdmin?: boolean; search?: string },
147
+ ): Promise<{ notifications: NotificationMessage[]; total: number }> => {
148
+ const { offset, perPage } = pagination;
149
+ const { sentBy, isAdmin, search } = options ?? {};
150
+
151
+ // Build query based on permissions
152
+ let countRows: Array<{ count: number | string }> = [];
153
+ let dataRows: DbNotificationRow[] = [];
154
+
155
+ const searchPattern = search ? `%${escapeLikePattern(search)}%` : null;
156
+
157
+ if (isAdmin) {
158
+ // Admins see all notifications
159
+ if (searchPattern) {
160
+ countRows = await sql`
161
+ SELECT COUNT(*)::int as count FROM notifications.messages
162
+ WHERE subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\'
163
+ `;
164
+ dataRows = await sql`
165
+ SELECT
166
+ m.id, m.type, m.recipient, m.subject, m.content,
167
+ m.sent_at, m.error, m.created_at, m.sent_by,
168
+ u.display_name as sent_by_name
169
+ FROM notifications.messages m
170
+ LEFT JOIN auth.users u ON m.sent_by = u.id
171
+ WHERE m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\'
172
+ ORDER BY m.created_at DESC
173
+ LIMIT ${perPage} OFFSET ${offset}
174
+ `;
175
+ } else {
176
+ countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages`;
177
+ dataRows = await sql`
178
+ SELECT
179
+ m.id, m.type, m.recipient, m.subject, m.content,
180
+ m.sent_at, m.error, m.created_at, m.sent_by,
181
+ u.display_name as sent_by_name
182
+ FROM notifications.messages m
183
+ LEFT JOIN auth.users u ON m.sent_by = u.id
184
+ ORDER BY m.created_at DESC
185
+ LIMIT ${perPage} OFFSET ${offset}
186
+ `;
187
+ }
188
+ } else if (sentBy) {
189
+ // Regular users see only their own sent notifications
190
+ if (searchPattern) {
191
+ countRows = await sql`
192
+ SELECT COUNT(*)::int as count FROM notifications.messages
193
+ WHERE sent_by = ${sentBy} AND (subject ILIKE ${searchPattern} ESCAPE '\' OR content ILIKE ${searchPattern} ESCAPE '\' OR recipient ILIKE ${searchPattern} ESCAPE '\')
194
+ `;
195
+ dataRows = await sql`
196
+ SELECT
197
+ m.id, m.type, m.recipient, m.subject, m.content,
198
+ m.sent_at, m.error, m.created_at, m.sent_by,
199
+ u.display_name as sent_by_name
200
+ FROM notifications.messages m
201
+ LEFT JOIN auth.users u ON m.sent_by = u.id
202
+ WHERE m.sent_by = ${sentBy} AND (m.subject ILIKE ${searchPattern} ESCAPE '\' OR m.content ILIKE ${searchPattern} ESCAPE '\' OR m.recipient ILIKE ${searchPattern} ESCAPE '\')
203
+ ORDER BY m.created_at DESC
204
+ LIMIT ${perPage} OFFSET ${offset}
205
+ `;
206
+ } else {
207
+ countRows = await sql`SELECT COUNT(*)::int as count FROM notifications.messages WHERE sent_by = ${sentBy}`;
208
+ dataRows = await sql`
209
+ SELECT
210
+ m.id, m.type, m.recipient, m.subject, m.content,
211
+ m.sent_at, m.error, m.created_at, m.sent_by,
212
+ u.display_name as sent_by_name
213
+ FROM notifications.messages m
214
+ LEFT JOIN auth.users u ON m.sent_by = u.id
215
+ WHERE m.sent_by = ${sentBy}
216
+ ORDER BY m.created_at DESC
217
+ LIMIT ${perPage} OFFSET ${offset}
218
+ `;
219
+ }
220
+ } else {
221
+ return { notifications: [], total: 0 };
222
+ }
223
+
224
+ const rawTotal = countRows[0]?.count ?? 0;
225
+ const total = typeof rawTotal === "string" ? Number.parseInt(rawTotal, 10) : rawTotal;
226
+
227
+ const notifications: NotificationMessage[] = dataRows.map((row: DbNotificationRow) => {
228
+ const sentAt = row.sent_at as Date | null;
229
+ const error = row.error as string | null;
230
+
231
+ return {
232
+ id: row.id,
233
+ type: row.type,
234
+ recipient: row.recipient,
235
+ subject: row.subject,
236
+ content: row.content,
237
+ sentAt,
238
+ error,
239
+ createdAt: row.created_at,
240
+ sentBy: row.sent_by,
241
+ sentByName: row.sent_by_name,
242
+ status: determineStatus(sentAt, error),
243
+ };
244
+ });
245
+
246
+ return { notifications, total };
247
+ };
248
+
249
+ /**
250
+ * Get a single notification by ID.
251
+ */
252
+ export const getById = async (id: string): Promise<NotificationMessage | null> => {
253
+ const rows = await sql`
254
+ SELECT
255
+ m.id, m.type, m.recipient, m.subject, m.content,
256
+ m.sent_at, m.error, m.created_at, m.sent_by,
257
+ u.display_name as sent_by_name
258
+ FROM notifications.messages m
259
+ LEFT JOIN auth.users u ON m.sent_by = u.id
260
+ WHERE m.id = ${id}
261
+ `;
262
+
263
+ if (rows.length === 0) return null;
264
+
265
+ const row = rows[0]!;
266
+ const sentAt = row.sent_at as Date | null;
267
+ const error = row.error as string | null;
268
+
269
+ return {
270
+ id: row.id as string,
271
+ type: row.type as NotificationType,
272
+ recipient: row.recipient as string,
273
+ subject: row.subject as string,
274
+ content: row.content as string,
275
+ sentAt,
276
+ error,
277
+ createdAt: row.created_at as Date,
278
+ sentBy: row.sent_by as string | null,
279
+ sentByName: row.sent_by_name as string | null,
280
+ status: determineStatus(sentAt, error),
281
+ };
282
+ };
283
+
284
+ /**
285
+ * Resend a notification (retry delivery).
286
+ */
287
+ export const resend = async (id: string): Promise<{ ok: true } | { ok: false; error: string }> => {
288
+ const notification = await getById(id);
289
+ if (!notification) {
290
+ return { ok: false, error: "Notification not found" };
291
+ }
292
+
293
+ try {
294
+ if (notification.type === "email") {
295
+ await sendEmail(notification.recipient, notification.subject, {
296
+ rawHtml: notification.content,
297
+ });
298
+ }
299
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
300
+ return { ok: true };
301
+ } catch (e) {
302
+ const error = e instanceof Error ? e.message : String(e);
303
+ await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
304
+ return { ok: false, error };
305
+ }
306
+ };
307
+
308
+ /**
309
+ * Update a notification.
310
+ * Non-admins can only edit pending/error notifications.
311
+ * Admins can edit any notification (including sent ones).
312
+ */
313
+ export const update = async (
314
+ id: string,
315
+ data: { subject?: string; content?: string; recipient?: string },
316
+ options?: { isAdmin?: boolean },
317
+ ): Promise<{ ok: true } | { ok: false; error: string }> => {
318
+ const notification = await getById(id);
319
+ if (!notification) {
320
+ return { ok: false, error: "Notification not found" };
321
+ }
322
+
323
+ // Non-admins cannot edit sent notifications
324
+ if (!options?.isAdmin && notification.status === "sent") {
325
+ return { ok: false, error: "Cannot edit a sent notification" };
326
+ }
327
+
328
+ if (data.subject === undefined && data.content === undefined && data.recipient === undefined) {
329
+ return { ok: true };
330
+ }
331
+
332
+ // Clear error when editing (only for non-sent notifications)
333
+ const clearError = notification.status !== "sent";
334
+
335
+ await sql`
336
+ UPDATE notifications.messages
337
+ SET
338
+ subject = COALESCE(${data.subject ?? null}, subject),
339
+ content = COALESCE(${data.content ?? null}, content),
340
+ recipient = COALESCE(${data.recipient ?? null}, recipient),
341
+ error = CASE WHEN ${clearError} THEN NULL ELSE error END
342
+ WHERE id = ${id}
343
+ `;
344
+
345
+ return { ok: true };
346
+ };
347
+
348
+ /**
349
+ * Get count of pending system notifications (sent_by IS NULL).
350
+ */
351
+ export const getPendingSystemCount = async (): Promise<number> => {
352
+ const rows = await sql`
353
+ SELECT COUNT(*)::int as count FROM notifications.messages
354
+ WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
355
+ `;
356
+ return rows[0]?.count ?? 0;
357
+ };
358
+
359
+ /**
360
+ * Send all pending system notifications (sent_by IS NULL).
361
+ * Returns the count of successfully sent and failed notifications.
362
+ */
363
+ export const sendAllPendingSystem = async (): Promise<{
364
+ sent: number;
365
+ failed: number;
366
+ errors: { id: string; recipient: string; error: string }[];
367
+ }> => {
368
+ // Get all pending system notifications
369
+ const rows = await sql`
370
+ SELECT id, type, recipient, subject, content
371
+ FROM notifications.messages
372
+ WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
373
+ ORDER BY created_at ASC
374
+ `;
375
+
376
+ let sent = 0;
377
+ let failed = 0;
378
+ const errors: { id: string; recipient: string; error: string }[] = [];
379
+
380
+ for (const row of rows) {
381
+ const id = row.id as string;
382
+ const type = row.type as NotificationType;
383
+ const recipient = row.recipient as string;
384
+ const subject = row.subject as string;
385
+ const content = row.content as string;
386
+
387
+ try {
388
+ if (type === "email") {
389
+ await sendEmail(recipient, subject, { rawHtml: content });
390
+ }
391
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
392
+ sent++;
393
+ } catch (e) {
394
+ const error = e instanceof Error ? e.message : String(e);
395
+ await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
396
+ failed++;
397
+ errors.push({ id, recipient, error });
398
+ }
399
+ }
400
+
401
+ return { sent, failed, errors };
402
+ };
403
+
404
+ export const notifications = {
405
+ send,
406
+ sendToUser,
407
+ list,
408
+ getById,
409
+ resend,
410
+ update,
411
+ getPendingSystemCount,
412
+ sendAllPendingSystem,
413
+ };
@@ -0,0 +1,51 @@
1
+ /** Convert a JS string array to a Postgres TEXT[] literal (Bun sql can't serialize empty arrays). */
2
+ export const toPgTextArray = (values: string[] | null | undefined): string => {
3
+ if (!Array.isArray(values) || values.length === 0) return "{}";
4
+ return `{${values.map((value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(",")}}`;
5
+ };
6
+
7
+ /** Convert UUID strings into a Postgres UUID[] literal for `ANY(...)`/`ALL(...)` filters. */
8
+ export const toPgUuidArray = (values: string[] | null | undefined): string => {
9
+ if (!Array.isArray(values) || values.length === 0) return "{}";
10
+ return `{${values.join(",")}}`;
11
+ };
12
+
13
+ /** Escape a user string for safe use inside a LIKE/ILIKE pattern with `ESCAPE '\'`. */
14
+ export const escapeLikePattern = (value: string): string => value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
15
+
16
+ /** Normalize a Postgres JSON/JSONB value that may come back as a parsed value or a JSON string. */
17
+ export const parsePgJsonValue = (value: unknown): unknown => {
18
+ if (value == null || typeof value !== "string") return value;
19
+
20
+ const trimmed = value.trim();
21
+ if (!trimmed) return null;
22
+
23
+ try {
24
+ return JSON.parse(trimmed);
25
+ } catch {
26
+ return value;
27
+ }
28
+ };
29
+
30
+ /** Normalize a Postgres JSON/JSONB object value to a plain record. */
31
+ export const parsePgJsonRecord = (value: unknown): Record<string, unknown> | null => {
32
+ const parsed = parsePgJsonValue(value);
33
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
34
+ return parsed as Record<string, unknown>;
35
+ };
36
+
37
+ /**
38
+ * Classify a thrown Postgres error. Bun's sql driver surfaces the canonical
39
+ * SQLSTATE on `.code`. Use this at service boundaries to turn
40
+ * unique-constraint violations into typed 409 results instead of bubbling up
41
+ * raw DB errors to API clients.
42
+ */
43
+ export type PgError = { code?: string; constraint_name?: string; detail?: string; message?: string };
44
+
45
+ export const isUniqueViolation = (error: unknown, constraintName?: string): boolean => {
46
+ if (!error || typeof error !== "object") return false;
47
+ const e = error as PgError;
48
+ if (e.code !== "23505") return false;
49
+ if (!constraintName) return true;
50
+ return e.constraint_name === constraintName;
51
+ };
@@ -0,0 +1,27 @@
1
+ import * as ipaAuth from "../ipa/auth";
2
+ import * as ipaUsers from "../ipa/users";
3
+ import * as ipaGroups from "../ipa/groups";
4
+ import * as ipaSync from "../ipa/sync";
5
+ import { local } from "./local";
6
+
7
+ export const ipa = {
8
+ auth: ipaAuth,
9
+ users: {
10
+ ...ipaUsers,
11
+ create: ipaUsers.addIpa,
12
+ update: ipaUsers.updateProfile,
13
+ remove: ipaUsers.deleteUser,
14
+ },
15
+ groups: {
16
+ ...ipaGroups,
17
+ remove: ipaGroups.del,
18
+ },
19
+ sync: {
20
+ ...ipaSync,
21
+ run: ipaSync.syncFromIpa,
22
+ user: ipaSync.syncUser,
23
+ },
24
+ } as const;
25
+
26
+ export { local };
27
+ export const providers = { ipa, local } as const;
@@ -0,0 +1,13 @@
1
+ import { redis } from "bun";
2
+
3
+ export const createMagicLinkToken = async (params: { email: string; ttlSeconds?: number }): Promise<string> => {
4
+ const token = crypto.randomUUID();
5
+ await redis.set(`email-login:${token}`, JSON.stringify({ email: params.email }), "EX", params.ttlSeconds ?? 300);
6
+ return token;
7
+ };
8
+
9
+ export const consumeMagicLinkToken = async (token: string): Promise<{ email: string } | null> => {
10
+ const raw = await redis.getdel(`email-login:${token}`);
11
+ if (!raw) return null;
12
+ return JSON.parse(raw) as { email: string };
13
+ };
@@ -0,0 +1,4 @@
1
+ import * as auth from "./auth";
2
+ import * as users from "./users";
3
+
4
+ export const local = { auth, users } as const;