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.
- package/README.md +5 -17
- package/dashboard/README.md +29 -0
- package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
- package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
- package/dashboard/dist/favicon.svg +4 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/index.d.mts +3435 -548
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-DDbquL89.mjs → main-0FHNtN5g.mjs} +2 -2
- package/dist/{main-DDbquL89.mjs.map → main-0FHNtN5g.mjs.map} +1 -1
- package/dist/{main-DdLzptZE.mjs → main-7KMlWnDx.mjs} +2 -2
- package/dist/{main-DdLzptZE.mjs.map → main-7KMlWnDx.mjs.map} +1 -1
- package/dist/{main-DsDkyBZi.mjs → main-B0BcY_JJ.mjs} +2 -2
- package/dist/{main-DsDkyBZi.mjs.map → main-B0BcY_JJ.mjs.map} +1 -1
- package/dist/{main-Db2f2-sW.mjs → main-BAO4kKce.mjs} +437 -63
- package/dist/main-BAO4kKce.mjs.map +1 -0
- package/dist/{main-DfrrNKp9.mjs → main-BjXgGJBd.mjs} +2 -2
- package/dist/{main-DfrrNKp9.mjs.map → main-BjXgGJBd.mjs.map} +1 -1
- package/dist/{main-BnIX5nfp.mjs → main-BtXITy0k.mjs} +2 -2
- package/dist/{main-BnIX5nfp.mjs.map → main-BtXITy0k.mjs.map} +1 -1
- package/dist/{main-CWGEKpLU.mjs → main-BxqAXTr6.mjs} +2 -2
- package/dist/{main-CWGEKpLU.mjs.map → main-BxqAXTr6.mjs.map} +1 -1
- package/dist/{main-Cwu4iQXc.mjs → main-zCpvA9Rx.mjs} +2 -2
- package/dist/{main-Cwu4iQXc.mjs.map → main-zCpvA9Rx.mjs.map} +1 -1
- package/dist/{src-CpUnBl_M.mjs → src-DkvHYM3y.mjs} +127 -41
- package/dist/src-DkvHYM3y.mjs.map +1 -0
- package/drizzle/0005_admin_users.sql +11 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -3
- package/scripts/create-admin.mjs +67 -0
- package/src/client.ts +111 -40
- package/src/config/index.ts +3 -0
- package/src/contracts/sdk.ts +143 -3
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +224 -0
- package/src/services/api/handlers.ts +223 -42
- package/src/services/api/main.ts +72 -19
- package/src/services/auth/index.ts +103 -0
- package/dist/main-Db2f2-sW.mjs.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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,
|