@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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
|
+
};
|