notifkit 0.1.6 → 0.1.8

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 (42) hide show
  1. package/README.md +5 -17
  2. package/dashboard/README.md +29 -0
  3. package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
  4. package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
  5. package/dashboard/dist/favicon.svg +4 -0
  6. package/dashboard/dist/index.html +14 -0
  7. package/dist/index.d.mts +3435 -548
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +2 -2
  10. package/dist/{main-DDbquL89.mjs → main-0FHNtN5g.mjs} +2 -2
  11. package/dist/{main-DDbquL89.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
  12. package/dist/{main-DdLzptZE.mjs → main-7KMlWnDx.mjs} +2 -2
  13. package/dist/{main-DdLzptZE.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
  14. package/dist/{main-DsDkyBZi.mjs → main-B0BcY_JJ.mjs} +2 -2
  15. package/dist/{main-DsDkyBZi.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
  16. package/dist/{main-Db2f2-sW.mjs → main-BAO4kKce.mjs} +437 -63
  17. package/dist/main-BAO4kKce.mjs.map +1 -0
  18. package/dist/{main-DfrrNKp9.mjs → main-BjXgGJBd.mjs} +2 -2
  19. package/dist/{main-DfrrNKp9.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
  20. package/dist/{main-BnIX5nfp.mjs → main-BtXITy0k.mjs} +2 -2
  21. package/dist/{main-BnIX5nfp.mjs.map → main-BtXITy0k.mjs.map} +1 -1
  22. package/dist/{main-CWGEKpLU.mjs → main-BxqAXTr6.mjs} +2 -2
  23. package/dist/{main-CWGEKpLU.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
  24. package/dist/{main-Cwu4iQXc.mjs → main-zCpvA9Rx.mjs} +2 -2
  25. package/dist/{main-Cwu4iQXc.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
  26. package/dist/{src-CpUnBl_M.mjs → src-DkvHYM3y.mjs} +127 -41
  27. package/dist/src-DkvHYM3y.mjs.map +1 -0
  28. package/drizzle/0005_admin_users.sql +11 -0
  29. package/drizzle/meta/_journal.json +7 -0
  30. package/package.json +8 -3
  31. package/scripts/create-admin.mjs +67 -0
  32. package/src/client.ts +111 -40
  33. package/src/config/index.ts +3 -0
  34. package/src/contracts/sdk.ts +143 -3
  35. package/src/db/schema.ts +13 -0
  36. package/src/repositories/index.ts +82 -1
  37. package/src/services/api/admin-static.ts +224 -0
  38. package/src/services/api/handlers.ts +223 -42
  39. package/src/services/api/main.ts +72 -19
  40. package/src/services/auth/index.ts +103 -0
  41. package/dist/main-Db2f2-sW.mjs.map +0 -1
  42. package/dist/src-CpUnBl_M.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { eq, and, sql as drizzleSql, inArray, desc } from "drizzle-orm";
1
+ import { eq, and, or, sql as drizzleSql, inArray, desc } from "drizzle-orm";
2
2
  import type { Db } from "@/index.js";
3
3
  import type { Preferences, ContactChannel } from "@/contracts/index.js";
4
4
  import {
@@ -18,6 +18,7 @@ import {
18
18
  projectApiKeys,
19
19
  suppressions,
20
20
  messageLogs,
21
+ adminUsers,
21
22
  } from "@/db/schema.js";
22
23
 
23
24
  // ─── Domain types ───────────────────────────────────────────────────────────
@@ -1244,3 +1245,83 @@ export class SegmentRepository {
1244
1245
  return (rows as any[]).map((r) => r.segment);
1245
1246
  }
1246
1247
  }
1248
+
1249
+ // ─── AdminUserRepository ───────────────────────────────────────────────────
1250
+
1251
+ export interface AdminUserRecord {
1252
+ id: string;
1253
+ email: string;
1254
+ username: string | null;
1255
+ passwordHash: string;
1256
+ role: string;
1257
+ createdAt: Date;
1258
+ updatedAt: Date;
1259
+ }
1260
+
1261
+ export class AdminUserRepository {
1262
+ constructor(private readonly db: Db) {}
1263
+
1264
+ async findByEmailOrUsername(identifier: string): Promise<AdminUserRecord | null> {
1265
+ const normalized = identifier.trim().toLowerCase();
1266
+ const rows = await this.db
1267
+ .select()
1268
+ .from(adminUsers)
1269
+ .where(or(eq(adminUsers.email, normalized), eq(adminUsers.username, identifier.trim())))
1270
+ .limit(1);
1271
+ return (rows[0] as AdminUserRecord) ?? null;
1272
+ }
1273
+
1274
+ async findById(id: string): Promise<AdminUserRecord | null> {
1275
+ const rows = await this.db.select().from(adminUsers).where(eq(adminUsers.id, id)).limit(1);
1276
+ return (rows[0] as AdminUserRecord) ?? null;
1277
+ }
1278
+
1279
+ async create(data: {
1280
+ email: string;
1281
+ username?: string | null;
1282
+ passwordHash: string;
1283
+ role?: string;
1284
+ }): Promise<AdminUserRecord> {
1285
+ const rows = await this.db
1286
+ .insert(adminUsers)
1287
+ .values({
1288
+ email: data.email.trim().toLowerCase(),
1289
+ username: data.username ? data.username.trim() : null,
1290
+ passwordHash: data.passwordHash,
1291
+ role: data.role || "admin",
1292
+ })
1293
+ .returning();
1294
+ return rows[0] as AdminUserRecord;
1295
+ }
1296
+
1297
+ async updatePassword(id: string, passwordHash: string): Promise<boolean> {
1298
+ const rows = await this.db
1299
+ .update(adminUsers)
1300
+ .set({ passwordHash, updatedAt: new Date() })
1301
+ .where(eq(adminUsers.id, id))
1302
+ .returning();
1303
+ return rows.length > 0;
1304
+ }
1305
+
1306
+ async list(): Promise<Omit<AdminUserRecord, "passwordHash">[]> {
1307
+ const rows = await this.db
1308
+ .select({
1309
+ id: adminUsers.id,
1310
+ email: adminUsers.email,
1311
+ username: adminUsers.username,
1312
+ role: adminUsers.role,
1313
+ createdAt: adminUsers.createdAt,
1314
+ updatedAt: adminUsers.updatedAt,
1315
+ })
1316
+ .from(adminUsers)
1317
+ .orderBy(adminUsers.createdAt);
1318
+ return rows as Omit<AdminUserRecord, "passwordHash">[];
1319
+ }
1320
+
1321
+ async count(): Promise<number> {
1322
+ const rows = await this.db
1323
+ .select({ count: drizzleSql<number>`count(*)::int` })
1324
+ .from(adminUsers);
1325
+ return rows[0]?.count ?? 0;
1326
+ }
1327
+ }
@@ -0,0 +1,224 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { resolve, extname, join } from "node:path";
3
+ import { existsSync, statSync, createReadStream } from "node:fs";
4
+ import { request as httpRequest } from "node:http";
5
+
6
+ const MIME_TYPES: Record<string, string> = {
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "application/javascript; charset=utf-8",
9
+ ".mjs": "application/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".json": "application/json; charset=utf-8",
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".gif": "image/gif",
16
+ ".svg": "image/svg+xml",
17
+ ".ico": "image/x-icon",
18
+ ".woff": "font/woff",
19
+ ".woff2": "font/woff2",
20
+ ".ttf": "font/ttf",
21
+ ".webp": "image/webp",
22
+ ".txt": "text/plain; charset=utf-8",
23
+ ".map": "application/json; charset=utf-8",
24
+ };
25
+
26
+ // Possible output directories where built dashboard static files might reside
27
+ const POSSIBLE_DASHBOARD_DIRS = [
28
+ resolve(process.cwd(), "dashboard", "dist"),
29
+ resolve(process.cwd(), "dashboard", "out"),
30
+ resolve(process.cwd(), "dist", "admin"),
31
+ resolve(process.cwd(), "dist", "dashboard"),
32
+ ];
33
+
34
+ function getDashboardDir(): string | null {
35
+ for (const dir of POSSIBLE_DASHBOARD_DIRS) {
36
+ if (existsSync(dir) && existsSync(join(dir, "index.html"))) {
37
+ return dir;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Proxies request to frontend dev server if running.
45
+ */
46
+ function proxyToDevServer(
47
+ req: IncomingMessage,
48
+ res: ServerResponse,
49
+ targetHost: string,
50
+ targetPort: number,
51
+ ): Promise<boolean> {
52
+ return new Promise((resolvePromise) => {
53
+ const proxyReq = httpRequest(
54
+ {
55
+ host: targetHost,
56
+ port: targetPort,
57
+ path: req.url,
58
+ method: req.method,
59
+ headers: req.headers,
60
+ },
61
+ (proxyRes) => {
62
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
63
+ proxyRes.pipe(res);
64
+ resolvePromise(true);
65
+ },
66
+ );
67
+
68
+ proxyReq.on("error", () => {
69
+ resolvePromise(false);
70
+ });
71
+
72
+ if (req.readable) {
73
+ req.pipe(proxyReq);
74
+ } else {
75
+ proxyReq.end();
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Handles incoming HTTP requests for `/admin` and `/admin/*`.
82
+ * Serves static exported assets from Vite SPA build with index.html fallback,
83
+ * or proxies to Vite dev server if running in development mode.
84
+ */
85
+ export async function handleAdminRequest(
86
+ req: IncomingMessage,
87
+ res: ServerResponse,
88
+ url: URL,
89
+ ): Promise<boolean> {
90
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) {
91
+ return false;
92
+ }
93
+
94
+ // Redirect /admin to /admin/
95
+ if (url.pathname === "/admin") {
96
+ res.writeHead(301, { Location: "/admin/" + (url.search || "") });
97
+ res.end();
98
+ return true;
99
+ }
100
+
101
+ // In development, attempt to proxy to local Vite dev server on port 5173 (or VITE_DEV_URL) if active
102
+ if (process.env.NODE_ENV !== "production") {
103
+ const devProxyUrl =
104
+ process.env.VITE_DEV_URL || process.env.ADMIN_DEV_URL || process.env.NEXT_DEV_URL;
105
+ const targetPort = devProxyUrl ? parseInt(new URL(devProxyUrl).port, 10) : 5173;
106
+ const targetHost = devProxyUrl ? new URL(devProxyUrl).hostname : "127.0.0.1";
107
+
108
+ const proxied = await proxyToDevServer(req, res, targetHost, targetPort);
109
+ if (proxied) {
110
+ return true;
111
+ }
112
+ }
113
+
114
+ const dashboardDir = getDashboardDir();
115
+ if (!dashboardDir) {
116
+ // If dashboard build not found, return friendly message
117
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
118
+ res.end(`
119
+ <!DOCTYPE html>
120
+ <html lang="en">
121
+ <head>
122
+ <meta charset="utf-8">
123
+ <title>Notifkit Admin Dashboard</title>
124
+ <style>
125
+ body { font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
126
+ .card { background: #18181b; border: 1px solid #27272a; padding: 2rem; border-radius: 0.75rem; max-width: 500px; text-align: center; }
127
+ h1 { margin-top: 0; color: #fafafa; }
128
+ code { background: #27272a; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.9em; }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div class="card">
133
+ <h1>Notifkit Admin Dashboard</h1>
134
+ <p>The dashboard static bundle has not been built yet.</p>
135
+ <p>Please build it by running:</p>
136
+ <p><code>npm run build:dashboard</code></p>
137
+ <p>or run the dev server with <code>npm --prefix dashboard run dev</code>.</p>
138
+ </div>
139
+ </body>
140
+ </html>
141
+ `);
142
+ return true;
143
+ }
144
+
145
+ // Strip /admin prefix to find relative path inside dashboardDir
146
+ let subPath = url.pathname.slice("/admin".length);
147
+ if (subPath.startsWith("/")) {
148
+ subPath = subPath.slice(1);
149
+ }
150
+ if (!subPath) {
151
+ subPath = "index.html";
152
+ }
153
+
154
+ const normalizedSubPath = subPath.replace(/\/$/, "");
155
+ let filePath = resolve(dashboardDir, subPath);
156
+
157
+ // Security check: ensure filePath is inside dashboardDir
158
+ if (!filePath.startsWith(dashboardDir)) {
159
+ res.writeHead(403, { "Content-Type": "text/plain" });
160
+ res.end("Forbidden");
161
+ return true;
162
+ }
163
+
164
+ // Check direct file
165
+ let stat: ReturnType<typeof statSync> | null = null;
166
+ if (existsSync(filePath)) {
167
+ stat = statSync(filePath);
168
+ if (stat.isDirectory()) {
169
+ filePath = join(filePath, "index.html");
170
+ stat = existsSync(filePath) ? statSync(filePath) : null;
171
+ }
172
+ }
173
+
174
+ // Check path.html if direct file not found
175
+ if (!stat) {
176
+ const htmlPath = resolve(dashboardDir, `${normalizedSubPath}.html`);
177
+ if (existsSync(htmlPath)) {
178
+ filePath = htmlPath;
179
+ stat = statSync(filePath);
180
+ }
181
+ }
182
+
183
+ // Check path/index.html
184
+ if (!stat) {
185
+ const dirIndexPath = resolve(dashboardDir, normalizedSubPath, "index.html");
186
+ if (existsSync(dirIndexPath)) {
187
+ filePath = dirIndexPath;
188
+ stat = statSync(filePath);
189
+ }
190
+ }
191
+
192
+ // Fallback to index.html for SPA client-side routing
193
+ if (!stat) {
194
+ filePath = resolve(dashboardDir, "index.html");
195
+ if (existsSync(filePath)) {
196
+ stat = statSync(filePath);
197
+ }
198
+ }
199
+
200
+ if (!stat) {
201
+ res.writeHead(404, { "Content-Type": "text/plain" });
202
+ res.end("Not Found");
203
+ return true;
204
+ }
205
+
206
+ const ext = extname(filePath).toLowerCase();
207
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
208
+
209
+ // Cache static assets (_next/static) aggressively (immutable, 1 year), HTML files no-cache
210
+ const isImmutable = filePath.includes("_next") || ext === ".js" || ext === ".css";
211
+ const cacheControl = isImmutable
212
+ ? "public, max-age=31536000, immutable"
213
+ : "public, max-age=0, must-revalidate";
214
+
215
+ res.writeHead(200, {
216
+ "Content-Type": contentType,
217
+ "Content-Length": stat.size,
218
+ "Cache-Control": cacheControl,
219
+ });
220
+
221
+ const stream = createReadStream(filePath);
222
+ stream.pipe(res);
223
+ return true;
224
+ }
@@ -10,15 +10,24 @@ import type {
10
10
  ProjectRepository,
11
11
  WorkflowRepository,
12
12
  SegmentRepository,
13
+ AdminUserRepository,
13
14
  } from "@/repositories/index.js";
15
+ import {
16
+ verifyPassword,
17
+ createAdminSession,
18
+ revokeAdminSession,
19
+ getAdminSession,
20
+ } from "@/services/auth/index.js";
14
21
  import type { Preferences } from "@/contracts/index.js";
15
22
  import {
16
23
  AddUserSchema,
17
24
  UpdateUserSchema,
18
- AddContactSchema,
25
+ BatchAddContactsSchema,
26
+ type UserContactInput,
19
27
  SyncTemplatesSchema,
20
28
  NotifyRequestSchema,
21
29
  ContactChannelSchema,
30
+ type ContactChannel,
22
31
  PreferencesSchema,
23
32
  type NotificationRequestedPayload,
24
33
  type NotificationTarget,
@@ -61,6 +70,7 @@ export interface Deps {
61
70
  projectRepo: ProjectRepository;
62
71
  workflowRepo: WorkflowRepository;
63
72
  segmentRepo: SegmentRepository;
73
+ adminUserRepo?: AdminUserRepository;
64
74
  db: Db;
65
75
  }
66
76
 
@@ -69,6 +79,7 @@ interface InlineUserLike {
69
79
  id: string;
70
80
  language?: string;
71
81
  timezone?: string;
82
+ contacts?: UserContactInput[];
72
83
  email?: string[];
73
84
  phone?: string[];
74
85
  pushToken?: string[];
@@ -76,25 +87,66 @@ interface InlineUserLike {
76
87
  preferences?: Preferences;
77
88
  }
78
89
 
79
- /** Map an inline user's contact arrays into (channel, target) pairs. */
80
- function contactsOf(user: InlineUserLike): { channel: "email" | "sms" | "push"; target: string }[] {
81
- return [
82
- ...(user.email ?? []).map((target) => ({ channel: "email" as const, target })),
83
- ...(user.phone ?? []).map((target) => ({ channel: "sms" as const, target })),
84
- ...(user.pushToken ?? []).map((target) => ({ channel: "push" as const, target })),
85
- ];
90
+ interface ResolvedContact {
91
+ channel: ContactChannel;
92
+ target: string;
93
+ label?: string;
94
+ isPrimary?: boolean;
95
+ enabled?: boolean;
96
+ preferences?: Preferences;
97
+ }
98
+
99
+ /** Map an inline or user input's contacts and legacy arrays into resolved contact records. */
100
+ function contactsOf(user: InlineUserLike): ResolvedContact[] {
101
+ const list: ResolvedContact[] = [];
102
+
103
+ if (user.contacts && Array.isArray(user.contacts)) {
104
+ for (const c of user.contacts) {
105
+ list.push({
106
+ channel: c.channel,
107
+ target: c.target,
108
+ label: c.label,
109
+ isPrimary: c.isPrimary,
110
+ enabled: c.enabled,
111
+ preferences: c.preferences,
112
+ });
113
+ }
114
+ }
115
+
116
+ if (user.email) {
117
+ for (const target of user.email) {
118
+ list.push({ channel: "email", target });
119
+ }
120
+ }
121
+ if (user.phone) {
122
+ for (const target of user.phone) {
123
+ list.push({ channel: "sms", target });
124
+ }
125
+ }
126
+ if (user.pushToken) {
127
+ for (const target of user.pushToken) {
128
+ list.push({ channel: "push", target });
129
+ }
130
+ }
131
+
132
+ return list;
86
133
  }
87
134
 
88
135
  /** Persist user records + their contacts in bulk. Shared by addUser and inline notify. */
89
136
  async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: string): Promise<void> {
90
- const usersList = users.map((u) => ({
91
- userId: u.id,
92
- language: u.language ?? "en",
93
- timezone: u.timezone ?? "UTC",
94
- email: u.email?.[0] ?? null,
95
- segments: u.segments ?? [],
96
- preferences: u.preferences ?? {},
97
- }));
137
+ const usersList = users.map((u) => {
138
+ const primaryEmail =
139
+ u.email?.[0] ?? u.contacts?.find((c) => c.channel === "email")?.target ?? null;
140
+
141
+ return {
142
+ userId: u.id,
143
+ language: u.language ?? "en",
144
+ timezone: u.timezone ?? "UTC",
145
+ email: primaryEmail,
146
+ segments: u.segments ?? [],
147
+ preferences: u.preferences ?? {},
148
+ };
149
+ });
98
150
 
99
151
  const contactsList: any[] = [];
100
152
  for (const u of users) {
@@ -103,7 +155,7 @@ async function persistUsers(deps: Deps, users: InlineUserLike[], projectId: stri
103
155
  userId: u.id,
104
156
  channel: c.channel,
105
157
  target: c.target,
106
- preferences: {},
158
+ preferences: c.preferences ?? {},
107
159
  });
108
160
  }
109
161
  }
@@ -190,7 +242,7 @@ export function createHandlers(deps: Deps) {
190
242
  const updated = await deps.userRepo.updatePartial(ctx.projectId!, userId, {
191
243
  language: patch.language,
192
244
  timezone: patch.timezone,
193
- email: patch.email?.[0],
245
+ email: patch.email?.[0] ?? patch.contacts?.find((c) => c.channel === "email")?.target,
194
246
  segments: patch.segments,
195
247
  preferences: patch.preferences,
196
248
  });
@@ -198,7 +250,11 @@ export function createHandlers(deps: Deps) {
198
250
 
199
251
  // New contact values in the patch are added (existing ones are kept).
200
252
  for (const c of contactsOf({ id: userId, ...patch })) {
201
- await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target);
253
+ if (c.preferences) {
254
+ await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target, c.preferences);
255
+ } else {
256
+ await deps.contactRepo.upsert(ctx.projectId!, userId, c.channel, c.target);
257
+ }
202
258
  }
203
259
  logger.info({ userId }, "user updated");
204
260
  sendJson(res, 200, { id: userId });
@@ -217,28 +273,44 @@ export function createHandlers(deps: Deps) {
217
273
  sendNoContent(res);
218
274
  }
219
275
 
220
- // ── POST /v1/users/:id/contacts — addUserContact ──────────────────────────
276
+ // ── POST /v1/users/:id/contacts — addUserContact / batch contacts ─────────
221
277
  async function addContact(
222
278
  req: IncomingMessage,
223
279
  res: ServerResponse,
224
280
  ctx: RouteContext,
225
281
  ): Promise<void> {
226
282
  const userId = ctx.params.id!;
227
- const parsed = AddContactSchema.safeParse(await readJsonBody(req));
283
+ const body = await readJsonBody(req);
284
+ const parsed = BatchAddContactsSchema.safeParse(body);
228
285
  if (!parsed.success) return sendValidationError(res, parsed.error);
229
286
 
230
287
  const user = await deps.userRepo.findById(ctx.projectId!, userId);
231
288
  if (!user) return sendJson(res, 404, { error: "user_not_found", id: userId });
232
289
 
233
- await deps.contactRepo.upsert(
234
- ctx.projectId!,
235
- userId,
236
- parsed.data.channel,
237
- parsed.data.target,
238
- parsed.data.preferences ?? {},
239
- );
240
- logger.info({ userId, channel: parsed.data.channel }, "contact added");
241
- sendJson(res, 201, { userId, channel: parsed.data.channel, target: parsed.data.target });
290
+ if (Array.isArray(parsed.data)) {
291
+ const contactsList = parsed.data.map((c) => ({
292
+ userId,
293
+ channel: c.channel,
294
+ target: c.target,
295
+ preferences: c.preferences ?? {},
296
+ }));
297
+ await deps.contactRepo.upsertMany(ctx.projectId!, contactsList);
298
+ logger.info({ userId, count: contactsList.length }, "contacts batch added");
299
+ sendJson(res, 201, {
300
+ userId,
301
+ contacts: contactsList.map((c) => ({ channel: c.channel, target: c.target })),
302
+ });
303
+ } else {
304
+ await deps.contactRepo.upsert(
305
+ ctx.projectId!,
306
+ userId,
307
+ parsed.data.channel,
308
+ parsed.data.target,
309
+ parsed.data.preferences ?? {},
310
+ );
311
+ logger.info({ userId, channel: parsed.data.channel }, "contact added");
312
+ sendJson(res, 201, { userId, channel: parsed.data.channel, target: parsed.data.target });
313
+ }
242
314
  }
243
315
 
244
316
  // ── DELETE /v1/users/:id/contacts/:channel/:target — deleteUserContact ────
@@ -1043,19 +1115,35 @@ export function createHandlers(deps: Deps) {
1043
1115
  }
1044
1116
  }
1045
1117
 
1046
- // Scoped to the caller's project: an unscoped count reports every tenant's
1047
- // traffic to whoever asks.
1048
- const totalTasksRes = await deps.db
1049
- .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1050
- .from(messageLogs)
1051
- .where(eq(messageLogs.projectId, ctx.projectId!));
1052
- const deliveredTasksRes = await deps.db
1053
- .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1054
- .from(messageLogs)
1055
- .where(and(eq(messageLogs.projectId, ctx.projectId!), eq(messageLogs.status, "delivered")));
1118
+ // Scoped to the caller's project if provided, otherwise aggregate across all projects
1119
+ let total = 0;
1120
+ let delivered = 0;
1121
+
1122
+ if (ctx.projectId) {
1123
+ const totalTasksRes = await deps.db
1124
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1125
+ .from(messageLogs)
1126
+ .where(eq(messageLogs.projectId, ctx.projectId));
1127
+ const deliveredTasksRes = await deps.db
1128
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1129
+ .from(messageLogs)
1130
+ .where(and(eq(messageLogs.projectId, ctx.projectId), eq(messageLogs.status, "delivered")));
1131
+
1132
+ total = Number(totalTasksRes[0]?.count ?? 0);
1133
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1134
+ } else {
1135
+ const totalTasksRes = await deps.db
1136
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1137
+ .from(messageLogs);
1138
+ const deliveredTasksRes = await deps.db
1139
+ .select({ count: sql<number>`count(distinct ${messageLogs.taskId})` })
1140
+ .from(messageLogs)
1141
+ .where(eq(messageLogs.status, "delivered"));
1142
+
1143
+ total = Number(totalTasksRes[0]?.count ?? 0);
1144
+ delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1145
+ }
1056
1146
 
1057
- const total = Number(totalTasksRes[0]?.count ?? 0);
1058
- const delivered = Number(deliveredTasksRes[0]?.count ?? 0);
1059
1147
  const failed = streamDepths.DEAD_LETTER || 0;
1060
1148
  const successRate = total > 0 ? Number(((delivered / total) * 100).toFixed(2)) : 100;
1061
1149
 
@@ -1683,7 +1771,100 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px}</style>
1683
1771
  sendNoContent(res);
1684
1772
  }
1685
1773
 
1774
+ // ── POST /v1/auth/login — login ───────────────────────────────────────────
1775
+ async function login(req: IncomingMessage, res: ServerResponse): Promise<void> {
1776
+ if (!deps.adminUserRepo) {
1777
+ sendJson(res, 500, {
1778
+ error: "internal_error",
1779
+ message: "Admin user repository not initialized",
1780
+ });
1781
+ return;
1782
+ }
1783
+
1784
+ const body = await readJsonBody(req);
1785
+ const parsed = z
1786
+ .object({
1787
+ identifier: z.string().min(1, "Identifier (email or username) is required"),
1788
+ password: z.string().min(1, "Password is required"),
1789
+ })
1790
+ .safeParse(body);
1791
+
1792
+ if (!parsed.success) {
1793
+ sendValidationError(res, parsed.error);
1794
+ return;
1795
+ }
1796
+
1797
+ const { identifier, password } = parsed.data;
1798
+ const user = await deps.adminUserRepo.findByEmailOrUsername(identifier);
1799
+ if (!user) {
1800
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
1801
+ return;
1802
+ }
1803
+
1804
+ const isValid = await verifyPassword(password, user.passwordHash);
1805
+ if (!isValid) {
1806
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" });
1807
+ return;
1808
+ }
1809
+
1810
+ const session = await createAdminSession(deps.redis.native, user);
1811
+ sendJson(res, 200, {
1812
+ token: session.token,
1813
+ user: {
1814
+ id: user.id,
1815
+ email: user.email,
1816
+ username: user.username,
1817
+ role: user.role,
1818
+ },
1819
+ expiresAt: session.expiresAt,
1820
+ });
1821
+ }
1822
+
1823
+ // ── POST /v1/auth/logout — logout ─────────────────────────────────────────
1824
+ async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
1825
+ const authHeader = req.headers["authorization"];
1826
+ let token: string | undefined;
1827
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1828
+ token = authHeader.slice(7).trim();
1829
+ }
1830
+ if (token) {
1831
+ await revokeAdminSession(deps.redis.native, token);
1832
+ }
1833
+ sendJson(res, 200, { message: "Logged out successfully" });
1834
+ }
1835
+
1836
+ // ── GET /v1/auth/me — getMe ───────────────────────────────────────────────
1837
+ async function getMe(req: IncomingMessage, res: ServerResponse): Promise<void> {
1838
+ const authHeader = req.headers["authorization"];
1839
+ let token: string | undefined;
1840
+ if (typeof authHeader === "string" && authHeader.toLowerCase().startsWith("bearer ")) {
1841
+ token = authHeader.slice(7).trim();
1842
+ }
1843
+ if (!token) {
1844
+ sendJson(res, 401, { error: "unauthorized", message: "Missing token" });
1845
+ return;
1846
+ }
1847
+
1848
+ const session = await getAdminSession(deps.redis.native, token);
1849
+ if (!session) {
1850
+ sendJson(res, 401, { error: "unauthorized", message: "Invalid or expired session" });
1851
+ return;
1852
+ }
1853
+
1854
+ sendJson(res, 200, {
1855
+ user: {
1856
+ id: session.adminId,
1857
+ email: session.email,
1858
+ username: session.username,
1859
+ role: session.role,
1860
+ },
1861
+ });
1862
+ }
1863
+
1686
1864
  return {
1865
+ login,
1866
+ logout,
1867
+ getMe,
1687
1868
  syncTemplates,
1688
1869
  addUser,
1689
1870
  updateUser,