@ensera/plugin-backend 1.1.0 → 1.2.1
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/dist/index.d.ts +103 -1
- package/dist/index.js +327 -80
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import { Request, Response, NextFunction, Express } from 'express';
|
|
2
2
|
|
|
3
|
+
type CoarseCapabilities = {
|
|
4
|
+
/** Can invite / remove workspace members. OWNER/ADMIN only. */
|
|
5
|
+
canInviteMembers: boolean;
|
|
6
|
+
/** Can create, rename, delete, or reorder spaces. OWNER/ADMIN only. */
|
|
7
|
+
canManageSpaces: boolean;
|
|
8
|
+
/** Can install or uninstall workspace-level features. OWNER/ADMIN only. */
|
|
9
|
+
canInstallWorkspaceFeatures: boolean;
|
|
10
|
+
/** Can create feature instances inside spaces. OWNER/ADMIN only. */
|
|
11
|
+
canCreateFeatureInstances: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Can create new content items (tasks, cards, rows, etc.) inside a feature.
|
|
14
|
+
* False for MEMBER role — they may edit assigned content but cannot create.
|
|
15
|
+
* True for OWNER/ADMIN and for non-MEMBER actors with EDIT+ permission.
|
|
16
|
+
*/
|
|
17
|
+
canCreateContent: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Can delete any content item regardless of ownership.
|
|
20
|
+
* True for OWNER/ADMIN or actors with FULL feature permission.
|
|
21
|
+
*/
|
|
22
|
+
canDeleteAnyContent: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Can delete content items the actor personally created.
|
|
25
|
+
* False for MEMBER role. True for non-MEMBER actors with EDIT+ permission.
|
|
26
|
+
*/
|
|
27
|
+
canDeleteOwnContent: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Can assign content items (tasks, cards) to other workspace members.
|
|
30
|
+
* OWNER/ADMIN only.
|
|
31
|
+
*/
|
|
32
|
+
canAssignContent: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Can manage structural elements (columns, sections, board layout).
|
|
35
|
+
* OWNER/ADMIN or actors with FULL feature permission.
|
|
36
|
+
*/
|
|
37
|
+
canManageContentStructure: boolean;
|
|
38
|
+
};
|
|
39
|
+
type CoarseAuthorization = {
|
|
40
|
+
workspaceRole: "OWNER" | "ADMIN" | "MEMBER" | "GUEST" | null;
|
|
41
|
+
/** Effective space-level permission (FULL|EDIT|COMMENT|VIEW|NO_ACCESS). */
|
|
42
|
+
spacePermission: string;
|
|
43
|
+
/** Effective feature-level permission (FULL|EDIT|COMMENT|VIEW|NO_ACCESS). */
|
|
44
|
+
featurePermission: string;
|
|
45
|
+
accessSource: "workspace" | "space" | "feature" | "none";
|
|
46
|
+
capabilities: CoarseCapabilities;
|
|
47
|
+
policyVersion: "v2";
|
|
48
|
+
};
|
|
3
49
|
/**
|
|
4
50
|
* Plugin scope extracted from JWT token
|
|
5
51
|
*/
|
|
@@ -11,6 +57,11 @@ type PluginScope = {
|
|
|
11
57
|
featureSlug: string;
|
|
12
58
|
tabViewId?: string;
|
|
13
59
|
taskScopeId?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Coarse authorization resolved by core at token-mint time.
|
|
62
|
+
* Use this for permission checks in route handlers and command handlers.
|
|
63
|
+
*/
|
|
64
|
+
authorization?: CoarseAuthorization;
|
|
14
65
|
roles?: string[];
|
|
15
66
|
perms?: string[];
|
|
16
67
|
tokenId?: string;
|
|
@@ -86,9 +137,12 @@ interface NotificationAction {
|
|
|
86
137
|
*/
|
|
87
138
|
interface BackendNotification {
|
|
88
139
|
userId: string;
|
|
140
|
+
actorId?: string | null;
|
|
89
141
|
type: string;
|
|
90
142
|
title: string;
|
|
91
143
|
message: string;
|
|
144
|
+
entityType?: string;
|
|
145
|
+
entityId?: string;
|
|
92
146
|
metadata?: JsonValue;
|
|
93
147
|
priority?: "low" | "normal" | "high" | "urgent";
|
|
94
148
|
link?: string;
|
|
@@ -114,6 +168,12 @@ interface NotificationPublishResponse {
|
|
|
114
168
|
sent?: boolean;
|
|
115
169
|
emailSent?: boolean;
|
|
116
170
|
pushSent?: boolean;
|
|
171
|
+
scheduled?: boolean;
|
|
172
|
+
scheduledFor?: string;
|
|
173
|
+
warnings?: string[];
|
|
174
|
+
errorCode?: string;
|
|
175
|
+
hint?: string;
|
|
176
|
+
nextSteps?: string[];
|
|
117
177
|
error?: string;
|
|
118
178
|
reason?: string;
|
|
119
179
|
}
|
|
@@ -135,6 +195,9 @@ interface NotificationBulkResponse {
|
|
|
135
195
|
interface NotificationCancelResponse {
|
|
136
196
|
success: boolean;
|
|
137
197
|
cancelled: number;
|
|
198
|
+
errorCode?: string;
|
|
199
|
+
hint?: string;
|
|
200
|
+
nextSteps?: string[];
|
|
138
201
|
error?: string;
|
|
139
202
|
}
|
|
140
203
|
/**
|
|
@@ -163,6 +226,8 @@ interface NotificationPublisherConfig {
|
|
|
163
226
|
coreApiUrl: string;
|
|
164
227
|
/** Service-to-service authentication token */
|
|
165
228
|
serviceToken: string;
|
|
229
|
+
/** Developer Notification Token for Core-side notification authorization */
|
|
230
|
+
notificationToken?: string;
|
|
166
231
|
/** Timeout in ms (default 5000) */
|
|
167
232
|
timeoutMs?: number;
|
|
168
233
|
}
|
|
@@ -381,6 +446,29 @@ type CoreRequestContext = {
|
|
|
381
446
|
userId: string;
|
|
382
447
|
taskScopeId?: string;
|
|
383
448
|
requestId: string;
|
|
449
|
+
/**
|
|
450
|
+
* Structured coarse authorization resolved by core for this feature instance.
|
|
451
|
+
* Prefer this over the flat legacy fields below for new code.
|
|
452
|
+
*/
|
|
453
|
+
authorization?: CoarseAuthorization;
|
|
454
|
+
/**
|
|
455
|
+
* Effective feature-level permission string.
|
|
456
|
+
* Kept for backward compatibility; use authorization.featurePermission instead.
|
|
457
|
+
* Values: "FULL" | "EDIT" | "COMMENT" | "VIEW" | "NO_ACCESS"
|
|
458
|
+
*/
|
|
459
|
+
effectiveRole?: string;
|
|
460
|
+
/**
|
|
461
|
+
* Optional feature ownership/publisher attribution resolved by Core.
|
|
462
|
+
* Use this for logs, support diagnostics, or future ownership-aware behavior.
|
|
463
|
+
*/
|
|
464
|
+
developer?: {
|
|
465
|
+
ownerUserId?: string | null;
|
|
466
|
+
ownerDeveloperProfileId?: string | null;
|
|
467
|
+
ownerDisplayName?: string | null;
|
|
468
|
+
ownerDeveloperType?: string | null;
|
|
469
|
+
releaseId?: string | null;
|
|
470
|
+
storageMode?: string | null;
|
|
471
|
+
};
|
|
384
472
|
};
|
|
385
473
|
type CommandHandler = (input: Record<string, unknown>, context: CoreRequestContext) => Promise<unknown>;
|
|
386
474
|
type CommandRegistry = {
|
|
@@ -391,4 +479,18 @@ type CommandRegistry = {
|
|
|
391
479
|
declare function createCommandRegistry(): CommandRegistry;
|
|
392
480
|
declare function createCoreRouter(app: Express, registry: CommandRegistry): void;
|
|
393
481
|
|
|
394
|
-
|
|
482
|
+
type CreateDbOptions = {
|
|
483
|
+
prismaDir?: string;
|
|
484
|
+
coreApiUrl?: string;
|
|
485
|
+
serviceToken?: string;
|
|
486
|
+
managedDbSessionToken?: string;
|
|
487
|
+
runMigrations?: boolean;
|
|
488
|
+
};
|
|
489
|
+
type DbProvisionResult = {
|
|
490
|
+
connectionString: string;
|
|
491
|
+
schemaName: string;
|
|
492
|
+
featureSlug: string;
|
|
493
|
+
};
|
|
494
|
+
declare function provisionDb(options?: CreateDbOptions): Promise<DbProvisionResult>;
|
|
495
|
+
|
|
496
|
+
export { type BackendNotification, type CoarseAuthorization, type CoarseCapabilities, type CommandHandler, type CommandRegistry, type CoreRequestContext, type CreateDbOptions, type DbProvisionResult, type JsonObject, type JsonValue, type NotificationAction, type NotificationApiPayload, type NotificationBulkApiPayload, type NotificationBulkResponse, type NotificationCancelResponse, type NotificationCapabilities, type NotificationOptions, type NotificationPublishResponse, type NotificationPublisher, type NotificationPublisherConfig, type NotificationTypeConfig, type PluginAuthOptions, type PluginContext, PluginError, type PluginErrorResponse, type PluginScope, type RequestWithContext, type ScheduleOptions, assertPluginScope, buildDateTime, calculateScheduleTime, createCommandRegistry, createCoreRouter, createNotificationPublisher, forbidClientInstanceId, formatTime, getPluginScope, isPastDate, pluginAuth, pluginErrorHandler, provisionDb, requireFeature, requirePermission, requireTabView, requireTaskScope, resolveEffectiveInstanceId, toDateString, withCoreServiceToken, withInstanceOnly, withInstanceScope, withUserAndInstance };
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,14 @@ function pickClaim(obj, key) {
|
|
|
32
32
|
const v = obj?.[key];
|
|
33
33
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
34
34
|
}
|
|
35
|
+
function isCoarseAuthorization(value) {
|
|
36
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const candidate = value;
|
|
40
|
+
const capabilities = candidate.capabilities;
|
|
41
|
+
return (candidate.workspaceRole === null || typeof candidate.workspaceRole === "string") && typeof candidate.spacePermission === "string" && typeof candidate.featurePermission === "string" && typeof candidate.accessSource === "string" && !!capabilities && typeof capabilities === "object" && !Array.isArray(capabilities) && typeof capabilities.canInviteMembers === "boolean" && typeof capabilities.canManageSpaces === "boolean" && typeof capabilities.canInstallWorkspaceFeatures === "boolean" && typeof capabilities.canCreateFeatureInstances === "boolean" && candidate.policyVersion === "v2";
|
|
42
|
+
}
|
|
35
43
|
function normalizeScope(payload) {
|
|
36
44
|
const userId = pickClaim(payload, "sub");
|
|
37
45
|
const workspaceId = pickClaim(payload, "workspaceId");
|
|
@@ -55,6 +63,7 @@ function normalizeScope(payload) {
|
|
|
55
63
|
featureSlug,
|
|
56
64
|
tabViewId,
|
|
57
65
|
taskScopeId,
|
|
66
|
+
authorization: isCoarseAuthorization(payload.authorization) ? payload.authorization : void 0,
|
|
58
67
|
roles: Array.isArray(payload.roles) ? payload.roles : void 0,
|
|
59
68
|
perms: Array.isArray(payload.perms) ? payload.perms : void 0,
|
|
60
69
|
tokenId: pickClaim(payload, "jti"),
|
|
@@ -298,18 +307,101 @@ function forbidClientInstanceId(options = {}) {
|
|
|
298
307
|
}
|
|
299
308
|
|
|
300
309
|
// src/notify.ts
|
|
310
|
+
function isLoopbackHost(hostname) {
|
|
311
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "::ffff:127.0.0.1" || hostname === "0.0.0.0" || hostname === "host.docker.internal";
|
|
312
|
+
}
|
|
313
|
+
function normalizeScheduledOptions(options) {
|
|
314
|
+
if (!options) {
|
|
315
|
+
return void 0;
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
...options,
|
|
319
|
+
scheduledAt: options.scheduledAt instanceof Date ? options.scheduledAt.toISOString() : options.scheduledAt
|
|
320
|
+
};
|
|
321
|
+
}
|
|
301
322
|
function createNotificationPublisher(config) {
|
|
302
323
|
const { featureSlug, coreApiUrl, serviceToken, timeoutMs = 5e3 } = config;
|
|
303
324
|
if (!featureSlug?.trim()) {
|
|
304
|
-
throw new Error(
|
|
325
|
+
throw new Error(
|
|
326
|
+
"NotificationPublisher: featureSlug is required for Core-attributed notification sends."
|
|
327
|
+
);
|
|
305
328
|
}
|
|
306
329
|
if (!coreApiUrl?.trim()) {
|
|
307
|
-
throw new Error(
|
|
330
|
+
throw new Error(
|
|
331
|
+
"NotificationPublisher: coreApiUrl is required. Set CORE_API_URL before sending notifications."
|
|
332
|
+
);
|
|
308
333
|
}
|
|
309
334
|
if (!serviceToken?.trim()) {
|
|
310
|
-
throw new Error(
|
|
335
|
+
throw new Error(
|
|
336
|
+
"NotificationPublisher: serviceToken is required. Set CORE_SERVICE_TOKEN or SERVICE_TOKEN from the installation Developer Settings page."
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
function getNotificationToken() {
|
|
340
|
+
return config.notificationToken?.trim() || process.env.ENSERA_NOTIFICATION_TOKEN?.trim() || process.env.NOTIFICATION_TOKEN?.trim() || process.env.ENSERA_DEVELOPER_NOTIFICATION_TOKEN?.trim() || void 0;
|
|
341
|
+
}
|
|
342
|
+
function getCoreApiUrls() {
|
|
343
|
+
const normalized = coreApiUrl.replace(/\/+$/, "");
|
|
344
|
+
const candidates = [normalized];
|
|
345
|
+
try {
|
|
346
|
+
const fallbackUrl = new URL(normalized);
|
|
347
|
+
if (fallbackUrl.hostname === "host.docker.internal") {
|
|
348
|
+
fallbackUrl.hostname = "localhost";
|
|
349
|
+
candidates.push(fallbackUrl.toString().replace(/\/+$/, ""));
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
}
|
|
353
|
+
return [...new Set(candidates)];
|
|
354
|
+
}
|
|
355
|
+
function allowsNotificationCompatibilityBridge() {
|
|
356
|
+
if ((process.env.ENSERA_ALLOW_LEGACY_NOTIFICATION_ACCESS ?? "").trim() === "1") {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (process.env.NODE_ENV === "production") {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
return isLoopbackHost(new URL(coreApiUrl).hostname);
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function buildMissingNotificationTokenError() {
|
|
369
|
+
return `[NOTIFICATION_TOKEN_REQUIRED] Backend notifications for '${featureSlug}' require a Notification Token. Open Developer Settings > Tokens, reveal or rotate the Notification Token, set ENSERA_NOTIFICATION_TOKEN or NOTIFICATION_TOKEN in the backend environment, and retry.`;
|
|
370
|
+
}
|
|
371
|
+
function getNotificationPreflightError() {
|
|
372
|
+
if (getNotificationToken() || allowsNotificationCompatibilityBridge()) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return buildMissingNotificationTokenError();
|
|
376
|
+
}
|
|
377
|
+
function formatCoreError2(status, data) {
|
|
378
|
+
const code = typeof data?.errorCode === "string" && data.errorCode.trim() ? `[${data.errorCode.trim()}] ` : "";
|
|
379
|
+
const message = typeof data?.error === "string" && data.error.trim() ? data.error.trim() : `HTTP ${status}`;
|
|
380
|
+
const hints = [
|
|
381
|
+
typeof data?.hint === "string" ? data.hint.trim() : void 0,
|
|
382
|
+
...Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
|
|
383
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
384
|
+
) : []
|
|
385
|
+
];
|
|
386
|
+
return hints.length > 0 ? `${code}${message} ${hints.join(" ")}` : `${code}${message}`;
|
|
387
|
+
}
|
|
388
|
+
function buildHeaders(notificationToken) {
|
|
389
|
+
return {
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
Authorization: `Bearer ${serviceToken}`,
|
|
392
|
+
"X-Feature-Slug": featureSlug,
|
|
393
|
+
...notificationToken ? {
|
|
394
|
+
"X-Ensera-Notification-Token": notificationToken
|
|
395
|
+
} : {}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
async function readJson(response) {
|
|
399
|
+
try {
|
|
400
|
+
return await response.json();
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
311
404
|
}
|
|
312
|
-
const baseUrl = coreApiUrl.replace(/\/+$/, "");
|
|
313
405
|
async function fetchWithTimeout(url, options) {
|
|
314
406
|
const controller = new AbortController();
|
|
315
407
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -328,52 +420,84 @@ function createNotificationPublisher(config) {
|
|
|
328
420
|
throw error;
|
|
329
421
|
}
|
|
330
422
|
}
|
|
423
|
+
async function requestCore(path2, init) {
|
|
424
|
+
let lastError = null;
|
|
425
|
+
const notificationToken = getNotificationToken();
|
|
426
|
+
for (const baseUrl of getCoreApiUrls()) {
|
|
427
|
+
try {
|
|
428
|
+
return await fetchWithTimeout(`${baseUrl}${path2}`, {
|
|
429
|
+
...init,
|
|
430
|
+
headers: {
|
|
431
|
+
...buildHeaders(notificationToken),
|
|
432
|
+
...init.headers ?? {}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
lastError = error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw lastError instanceof Error ? lastError : new Error("Failed to reach Core notification API");
|
|
440
|
+
}
|
|
441
|
+
function toPublishFailure(status, data) {
|
|
442
|
+
return {
|
|
443
|
+
success: false,
|
|
444
|
+
error: formatCoreError2(status, data),
|
|
445
|
+
errorCode: typeof data?.errorCode === "string" ? data.errorCode : void 0,
|
|
446
|
+
hint: typeof data?.hint === "string" ? data.hint : void 0,
|
|
447
|
+
nextSteps: Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
|
|
448
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
449
|
+
) : void 0
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function toCancelFailure(status, data) {
|
|
453
|
+
return {
|
|
454
|
+
success: false,
|
|
455
|
+
cancelled: 0,
|
|
456
|
+
error: formatCoreError2(status, data),
|
|
457
|
+
errorCode: typeof data?.errorCode === "string" ? data.errorCode : void 0,
|
|
458
|
+
hint: typeof data?.hint === "string" ? data.hint : void 0,
|
|
459
|
+
nextSteps: Array.isArray(data?.nextSteps) ? data.nextSteps.filter(
|
|
460
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
461
|
+
) : void 0
|
|
462
|
+
};
|
|
463
|
+
}
|
|
331
464
|
async function publish(notification, options) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
465
|
+
const preflightError = getNotificationPreflightError();
|
|
466
|
+
if (preflightError) {
|
|
467
|
+
return {
|
|
468
|
+
success: false,
|
|
469
|
+
error: preflightError,
|
|
470
|
+
errorCode: "NOTIFICATION_TOKEN_REQUIRED"
|
|
471
|
+
};
|
|
472
|
+
}
|
|
337
473
|
const payload = {
|
|
338
474
|
...notification,
|
|
339
475
|
featureSlug,
|
|
340
|
-
options:
|
|
476
|
+
options: normalizeScheduledOptions(options)
|
|
341
477
|
};
|
|
342
|
-
console.log("[NotificationPublisher] Publishing notification:", {
|
|
343
|
-
featureSlug,
|
|
344
|
-
userId: notification.userId,
|
|
345
|
-
type: notification.type,
|
|
346
|
-
scheduledAt: processedOptions?.scheduledAt,
|
|
347
|
-
hasScheduledAt: !!processedOptions?.scheduledAt
|
|
348
|
-
});
|
|
349
478
|
try {
|
|
350
|
-
const response = await
|
|
479
|
+
const response = await requestCore("/api/notifications/publish", {
|
|
351
480
|
method: "POST",
|
|
352
|
-
headers: {
|
|
353
|
-
"Content-Type": "application/json",
|
|
354
|
-
Authorization: `Bearer ${serviceToken}`,
|
|
355
|
-
"X-Feature-Slug": featureSlug
|
|
356
|
-
},
|
|
357
481
|
body: JSON.stringify(payload)
|
|
358
482
|
});
|
|
359
|
-
const data = await response
|
|
360
|
-
console.log("[NotificationPublisher] Response:", {
|
|
361
|
-
ok: response.ok,
|
|
362
|
-
status: response.status,
|
|
363
|
-
success: data.success,
|
|
364
|
-
scheduled: data.scheduled,
|
|
365
|
-
scheduledFor: data.scheduledFor,
|
|
366
|
-
error: data.error
|
|
367
|
-
});
|
|
483
|
+
const data = await readJson(response);
|
|
368
484
|
if (!response.ok) {
|
|
369
|
-
return
|
|
370
|
-
success: false,
|
|
371
|
-
error: data.error || `HTTP ${response.status}`
|
|
372
|
-
};
|
|
485
|
+
return toPublishFailure(response.status, data);
|
|
373
486
|
}
|
|
374
|
-
return
|
|
487
|
+
return {
|
|
488
|
+
success: true,
|
|
489
|
+
id: typeof data?.id === "string" ? data.id : void 0,
|
|
490
|
+
sent: typeof data?.sent === "boolean" ? data.sent : void 0,
|
|
491
|
+
emailSent: typeof data?.emailSent === "boolean" ? data.emailSent : void 0,
|
|
492
|
+
pushSent: typeof data?.pushSent === "boolean" ? data.pushSent : void 0,
|
|
493
|
+
scheduled: typeof data?.scheduled === "boolean" ? data.scheduled : void 0,
|
|
494
|
+
scheduledFor: typeof data?.scheduledFor === "string" ? data.scheduledFor : void 0,
|
|
495
|
+
reason: typeof data?.reason === "string" ? data.reason : void 0,
|
|
496
|
+
warnings: Array.isArray(data?.warnings) ? data.warnings.filter(
|
|
497
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
498
|
+
) : void 0
|
|
499
|
+
};
|
|
375
500
|
} catch (error) {
|
|
376
|
-
console.error("[NotificationPublisher] Error:", error);
|
|
377
501
|
return {
|
|
378
502
|
success: false,
|
|
379
503
|
error: error.message || "Failed to publish notification"
|
|
@@ -381,23 +505,31 @@ function createNotificationPublisher(config) {
|
|
|
381
505
|
}
|
|
382
506
|
}
|
|
383
507
|
async function publishBulk(notifications, options) {
|
|
384
|
-
const
|
|
508
|
+
const preflightError = getNotificationPreflightError();
|
|
509
|
+
if (preflightError) {
|
|
510
|
+
return {
|
|
511
|
+
success: false,
|
|
512
|
+
count: 0,
|
|
513
|
+
errors: [
|
|
514
|
+
{
|
|
515
|
+
index: 0,
|
|
516
|
+
userId: "",
|
|
517
|
+
error: preflightError
|
|
518
|
+
}
|
|
519
|
+
]
|
|
520
|
+
};
|
|
521
|
+
}
|
|
385
522
|
const payload = {
|
|
386
523
|
notifications,
|
|
387
524
|
featureSlug,
|
|
388
|
-
options
|
|
525
|
+
options: normalizeScheduledOptions(options)
|
|
389
526
|
};
|
|
390
527
|
try {
|
|
391
|
-
const response = await
|
|
528
|
+
const response = await requestCore("/api/notifications/bulk", {
|
|
392
529
|
method: "POST",
|
|
393
|
-
headers: {
|
|
394
|
-
"Content-Type": "application/json",
|
|
395
|
-
Authorization: `Bearer ${serviceToken}`,
|
|
396
|
-
"X-Feature-Slug": featureSlug
|
|
397
|
-
},
|
|
398
530
|
body: JSON.stringify(payload)
|
|
399
531
|
});
|
|
400
|
-
const data = await response
|
|
532
|
+
const data = await readJson(response);
|
|
401
533
|
if (!response.ok) {
|
|
402
534
|
return {
|
|
403
535
|
success: false,
|
|
@@ -406,12 +538,16 @@ function createNotificationPublisher(config) {
|
|
|
406
538
|
{
|
|
407
539
|
index: 0,
|
|
408
540
|
userId: "",
|
|
409
|
-
error:
|
|
541
|
+
error: formatCoreError2(response.status, data)
|
|
410
542
|
}
|
|
411
543
|
]
|
|
412
544
|
};
|
|
413
545
|
}
|
|
414
|
-
return
|
|
546
|
+
return {
|
|
547
|
+
success: Boolean(data?.success),
|
|
548
|
+
count: typeof data?.count === "number" ? data.count : 0,
|
|
549
|
+
errors: Array.isArray(data?.errors) ? data.errors : void 0
|
|
550
|
+
};
|
|
415
551
|
} catch (error) {
|
|
416
552
|
return {
|
|
417
553
|
success: false,
|
|
@@ -434,46 +570,32 @@ function createNotificationPublisher(config) {
|
|
|
434
570
|
});
|
|
435
571
|
}
|
|
436
572
|
async function cancel(metadata) {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
573
|
+
const preflightError = getNotificationPreflightError();
|
|
574
|
+
if (preflightError) {
|
|
575
|
+
return {
|
|
576
|
+
success: false,
|
|
577
|
+
cancelled: 0,
|
|
578
|
+
error: preflightError,
|
|
579
|
+
errorCode: "NOTIFICATION_TOKEN_REQUIRED"
|
|
580
|
+
};
|
|
581
|
+
}
|
|
446
582
|
try {
|
|
447
|
-
const response = await
|
|
583
|
+
const response = await requestCore("/api/notifications/cancel-scheduled", {
|
|
448
584
|
method: "POST",
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
},
|
|
454
|
-
body: JSON.stringify(payload)
|
|
455
|
-
});
|
|
456
|
-
const data = await response.json();
|
|
457
|
-
console.log("[NotificationPublisher] Cancel response:", {
|
|
458
|
-
ok: response.ok,
|
|
459
|
-
status: response.status,
|
|
460
|
-
success: data.success,
|
|
461
|
-
cancelled: data.cancelled,
|
|
462
|
-
error: data.error
|
|
585
|
+
body: JSON.stringify({
|
|
586
|
+
featureSlug,
|
|
587
|
+
metadata
|
|
588
|
+
})
|
|
463
589
|
});
|
|
590
|
+
const data = await readJson(response);
|
|
464
591
|
if (!response.ok) {
|
|
465
|
-
return
|
|
466
|
-
success: false,
|
|
467
|
-
cancelled: 0,
|
|
468
|
-
error: data.error || `HTTP ${response.status}`
|
|
469
|
-
};
|
|
592
|
+
return toCancelFailure(response.status, data);
|
|
470
593
|
}
|
|
471
594
|
return {
|
|
472
595
|
success: true,
|
|
473
|
-
cancelled: data.cancelled
|
|
596
|
+
cancelled: typeof data?.cancelled === "number" ? data.cancelled : 0
|
|
474
597
|
};
|
|
475
598
|
} catch (error) {
|
|
476
|
-
console.error("[NotificationPublisher] Cancel error:", error);
|
|
477
599
|
return {
|
|
478
600
|
success: false,
|
|
479
601
|
cancelled: 0,
|
|
@@ -751,6 +873,130 @@ function createCoreRouter(app, registry) {
|
|
|
751
873
|
})
|
|
752
874
|
);
|
|
753
875
|
}
|
|
876
|
+
|
|
877
|
+
// src/createDb.ts
|
|
878
|
+
import { execSync } from "child_process";
|
|
879
|
+
import fs from "fs";
|
|
880
|
+
import path from "path";
|
|
881
|
+
function isRecord(value) {
|
|
882
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
883
|
+
}
|
|
884
|
+
function getString(value) {
|
|
885
|
+
if (typeof value !== "string") return void 0;
|
|
886
|
+
const trimmed = value.trim();
|
|
887
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
888
|
+
}
|
|
889
|
+
function formatCoreError(status, payload) {
|
|
890
|
+
if (!isRecord(payload)) {
|
|
891
|
+
return `Core returned ${status}. Unknown error`;
|
|
892
|
+
}
|
|
893
|
+
const parts = [
|
|
894
|
+
getString(payload.errorCode) ? `[${getString(payload.errorCode)}]` : void 0,
|
|
895
|
+
getString(payload.error) ?? `Core returned ${status}. Unknown error`
|
|
896
|
+
].filter(
|
|
897
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
898
|
+
);
|
|
899
|
+
const hints = [
|
|
900
|
+
getString(payload.hint),
|
|
901
|
+
...Array.isArray(payload.nextSteps) ? payload.nextSteps.filter(
|
|
902
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
903
|
+
) : []
|
|
904
|
+
];
|
|
905
|
+
return hints.length > 0 ? `${parts.join(" ")} ${hints.join(" ")}` : parts.join(" ");
|
|
906
|
+
}
|
|
907
|
+
function parseProvisionResult(value) {
|
|
908
|
+
if (!isRecord(value)) {
|
|
909
|
+
throw new Error("provisionDb: Core returned an invalid response body");
|
|
910
|
+
}
|
|
911
|
+
const connectionString = getString(value.connectionString);
|
|
912
|
+
const schemaName = getString(value.schemaName);
|
|
913
|
+
const featureSlug = getString(value.featureSlug);
|
|
914
|
+
if (!connectionString) {
|
|
915
|
+
throw new Error("provisionDb: Core did not return a connectionString");
|
|
916
|
+
}
|
|
917
|
+
if (!schemaName) {
|
|
918
|
+
throw new Error("provisionDb: Core did not return a schemaName");
|
|
919
|
+
}
|
|
920
|
+
if (!featureSlug) {
|
|
921
|
+
throw new Error("provisionDb: Core did not return a featureSlug");
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
connectionString,
|
|
925
|
+
schemaName,
|
|
926
|
+
featureSlug
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
async function provisionDb(options = {}) {
|
|
930
|
+
const coreApiUrl = (options.coreApiUrl ?? process.env.CORE_API_URL)?.replace(/\/+$/, "");
|
|
931
|
+
const serviceToken = options.serviceToken ?? process.env.CORE_SERVICE_TOKEN;
|
|
932
|
+
const resolvedServiceToken = serviceToken ?? process.env.SERVICE_TOKEN;
|
|
933
|
+
const managedDbSessionToken = options.managedDbSessionToken ?? process.env.ENSERA_MANAGED_DB_SESSION_TOKEN ?? process.env.MANAGED_DB_SESSION_TOKEN ?? process.env.ENSERA_DEVELOPER_DB_TOKEN;
|
|
934
|
+
if (!coreApiUrl) {
|
|
935
|
+
throw new Error(
|
|
936
|
+
"provisionDb: CORE_API_URL is not set. Add it to your .env file."
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
if (!resolvedServiceToken) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
"provisionDb: CORE_SERVICE_TOKEN or SERVICE_TOKEN is not set. Add it to your .env file."
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const response = await fetch(`${coreApiUrl}/api/internal/db/provision`, {
|
|
945
|
+
method: "POST",
|
|
946
|
+
headers: {
|
|
947
|
+
"Content-Type": "application/json",
|
|
948
|
+
Authorization: `Bearer ${resolvedServiceToken}`,
|
|
949
|
+
...managedDbSessionToken ? {
|
|
950
|
+
"x-ensera-managed-db-session-token": managedDbSessionToken
|
|
951
|
+
} : {}
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
let payload = null;
|
|
955
|
+
try {
|
|
956
|
+
payload = await response.json();
|
|
957
|
+
} catch {
|
|
958
|
+
payload = null;
|
|
959
|
+
}
|
|
960
|
+
if (!response.ok) {
|
|
961
|
+
throw new Error(
|
|
962
|
+
`provisionDb: ${formatCoreError(response.status, payload)}`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
const result = parseProvisionResult(payload);
|
|
966
|
+
const shouldMigrate = options.runMigrations !== false;
|
|
967
|
+
if (shouldMigrate) {
|
|
968
|
+
const prismaDir = options.prismaDir ?? path.join(process.cwd(), "prisma");
|
|
969
|
+
const migrationsDir = path.join(prismaDir, "migrations");
|
|
970
|
+
if (fs.existsSync(prismaDir) && fs.existsSync(migrationsDir)) {
|
|
971
|
+
console.log(
|
|
972
|
+
`[ensera-db] Running migrations for schema: ${result.schemaName}`
|
|
973
|
+
);
|
|
974
|
+
try {
|
|
975
|
+
execSync("npx prisma migrate deploy", {
|
|
976
|
+
cwd: path.dirname(prismaDir),
|
|
977
|
+
stdio: "inherit",
|
|
978
|
+
env: {
|
|
979
|
+
...process.env,
|
|
980
|
+
DATABASE_URL: result.connectionString
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
console.log("[ensera-db] Migrations complete");
|
|
984
|
+
} catch (error) {
|
|
985
|
+
const message = error instanceof Error ? error.message : "Unknown migration error";
|
|
986
|
+
throw new Error(`[ensera-db] Migration failed: ${message}`);
|
|
987
|
+
}
|
|
988
|
+
} else if (fs.existsSync(prismaDir)) {
|
|
989
|
+
console.warn(
|
|
990
|
+
`[ensera-db] No prisma migrations found at ${migrationsDir}. Skipping migrations.`
|
|
991
|
+
);
|
|
992
|
+
} else {
|
|
993
|
+
console.warn(
|
|
994
|
+
`[ensera-db] No prisma directory found at ${prismaDir}. Skipping migrations.`
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
754
1000
|
export {
|
|
755
1001
|
PluginError,
|
|
756
1002
|
assertPluginScope,
|
|
@@ -765,6 +1011,7 @@ export {
|
|
|
765
1011
|
isPastDate,
|
|
766
1012
|
pluginAuth,
|
|
767
1013
|
pluginErrorHandler,
|
|
1014
|
+
provisionDb,
|
|
768
1015
|
requireFeature,
|
|
769
1016
|
requirePermission,
|
|
770
1017
|
requireTabView,
|