feat: add platform notification core
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
import express from "express";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const config = readConfig();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const streamClients = new Set();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.get("/healthz", asyncRoute(async (_req, res) => {
|
||||
await pool.query("select 1");
|
||||
res.json({
|
||||
ok: true,
|
||||
service: "nodedc-notification-core",
|
||||
database: "ready",
|
||||
internalApiConfigured: Boolean(config.internalAccessToken),
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/internal/notifications/events", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const command = sanitizeEventCommand(req.body);
|
||||
const result = await createNotificationEvent(command);
|
||||
res.status(result.created ? 201 : 200).json(result);
|
||||
}));
|
||||
|
||||
app.get("/api/notifications", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const subject = getRequestSubject(req);
|
||||
const surface = sanitizeSurface(req.query.surface || "hub");
|
||||
const limit = sanitizeLimit(req.query.limit);
|
||||
const unreadOnly = req.query.unreadOnly === "1" || req.query.unreadOnly === "true";
|
||||
const deliveries = await listDeliveries({ subject, surface, limit, unreadOnly });
|
||||
res.json({ ok: true, surface, deliveries });
|
||||
}));
|
||||
|
||||
app.post("/api/notifications/:deliveryId/read", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const subject = getRequestSubject(req);
|
||||
const deliveryId = sanitizeUuid(req.params.deliveryId, "deliveryId");
|
||||
const delivery = await markDeliveryRead({ subject, deliveryId });
|
||||
if (!delivery) {
|
||||
res.status(404).json({ ok: false, error: "notification_delivery_not_found" });
|
||||
return;
|
||||
}
|
||||
broadcastDeliveryUpdate("notification.delivery.read", delivery);
|
||||
res.json({ ok: true, delivery });
|
||||
}));
|
||||
|
||||
app.post("/api/notifications/read-all", requireInternalApi, asyncRoute(async (req, res) => {
|
||||
const subject = getRequestSubject(req);
|
||||
const surface = sanitizeSurface(req.query.surface || req.body?.surface || "hub");
|
||||
const result = await markAllDeliveriesRead({ subject, surface });
|
||||
broadcastSubjectUpdate("notification.deliveries.read-all", { subject, surface, count: result.count });
|
||||
res.json({ ok: true, surface, count: result.count });
|
||||
}));
|
||||
|
||||
app.get("/api/notifications/stream", requireInternalApi, (req, res) => {
|
||||
const subject = getRequestSubject(req);
|
||||
const surface = sanitizeSurface(req.query.surface || "hub");
|
||||
const client = { id: randomUUID(), res, subject, surface };
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
writeSse(res, "notification.ready", { ok: true, surface });
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
res.write(": keep-alive\n\n");
|
||||
}, 30000);
|
||||
|
||||
streamClients.add(client);
|
||||
req.on("close", () => {
|
||||
clearInterval(keepAlive);
|
||||
streamClients.delete(client);
|
||||
});
|
||||
});
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
const status = Number(error?.status || 500);
|
||||
const message = error instanceof Error ? error.message : "internal_error";
|
||||
res.status(status >= 400 && status < 600 ? status : 500).json({ ok: false, error: message });
|
||||
});
|
||||
|
||||
await migrate();
|
||||
|
||||
httpServer.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`NODE.DC Notification Core listening on http://0.0.0.0:${config.port}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function createNotificationEvent(command) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
|
||||
let event = null;
|
||||
if (command.idempotencyKey) {
|
||||
const existing = await client.query(
|
||||
"select * from notification_events where idempotency_key = $1",
|
||||
[command.idempotencyKey]
|
||||
);
|
||||
if (existing.rowCount > 0) {
|
||||
event = existing.rows[0];
|
||||
const deliveries = await loadDeliveriesForEvent(client, event.id);
|
||||
await client.query("commit");
|
||||
return { ok: true, created: false, idempotent: true, event: toEvent(event), deliveries };
|
||||
}
|
||||
}
|
||||
|
||||
const eventId = randomUUID();
|
||||
const insertedEvent = await client.query(
|
||||
`insert into notification_events (
|
||||
id,
|
||||
type,
|
||||
source_service,
|
||||
actor_user_id,
|
||||
actor_email,
|
||||
subject_type,
|
||||
subject_id,
|
||||
payload,
|
||||
idempotency_key
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
||||
returning *`,
|
||||
[
|
||||
eventId,
|
||||
command.type,
|
||||
command.sourceService,
|
||||
command.actor.userId,
|
||||
command.actor.email,
|
||||
command.subject.type,
|
||||
command.subject.id,
|
||||
JSON.stringify(command.payload),
|
||||
command.idempotencyKey,
|
||||
]
|
||||
);
|
||||
event = insertedEvent.rows[0];
|
||||
|
||||
const deliveries = [];
|
||||
for (const delivery of command.deliveries) {
|
||||
const insertedDelivery = await client.query(
|
||||
`insert into notification_deliveries (
|
||||
id,
|
||||
event_id,
|
||||
recipient_user_id,
|
||||
recipient_email,
|
||||
surface,
|
||||
title,
|
||||
body,
|
||||
meta,
|
||||
status,
|
||||
actionable,
|
||||
action_type,
|
||||
action_url,
|
||||
entity_type,
|
||||
entity_id
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14)
|
||||
returning *`,
|
||||
[
|
||||
randomUUID(),
|
||||
event.id,
|
||||
delivery.recipientUserId,
|
||||
delivery.recipientEmail,
|
||||
delivery.surface,
|
||||
delivery.title,
|
||||
delivery.body,
|
||||
JSON.stringify(delivery.meta),
|
||||
delivery.status,
|
||||
delivery.actionable,
|
||||
delivery.actionType,
|
||||
delivery.actionUrl,
|
||||
delivery.entityType,
|
||||
delivery.entityId,
|
||||
]
|
||||
);
|
||||
deliveries.push(toDelivery({ ...insertedDelivery.rows[0], event_type: event.type, source_service: event.source_service, event_payload: event.payload }));
|
||||
}
|
||||
|
||||
await client.query("commit");
|
||||
for (const delivery of deliveries) {
|
||||
broadcastDeliveryUpdate("notification.delivery.created", delivery);
|
||||
}
|
||||
return { ok: true, created: true, event: toEvent(event), deliveries };
|
||||
} catch (error) {
|
||||
await client.query("rollback");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function listDeliveries({ subject, surface, limit, unreadOnly }) {
|
||||
const values = [surface, subject.userId, subject.email, limit];
|
||||
const unreadSql = unreadOnly ? "and d.status = 'unread'" : "";
|
||||
const result = await pool.query(
|
||||
`select
|
||||
d.*,
|
||||
e.type as event_type,
|
||||
e.source_service,
|
||||
e.payload as event_payload
|
||||
from notification_deliveries d
|
||||
join notification_events e on e.id = d.event_id
|
||||
where d.surface = $1
|
||||
and (
|
||||
($2::text is not null and d.recipient_user_id = $2)
|
||||
or ($3::text is not null and lower(d.recipient_email) = lower($3))
|
||||
)
|
||||
${unreadSql}
|
||||
order by d.created_at desc
|
||||
limit $4`,
|
||||
values
|
||||
);
|
||||
return result.rows.map(toDelivery);
|
||||
}
|
||||
|
||||
async function markDeliveryRead({ subject, deliveryId }) {
|
||||
const result = await pool.query(
|
||||
`update notification_deliveries
|
||||
set status = 'read', read_at = coalesce(read_at, now()), updated_at = now()
|
||||
where id = $1
|
||||
and status = 'unread'
|
||||
and (
|
||||
($2::text is not null and recipient_user_id = $2)
|
||||
or ($3::text is not null and lower(recipient_email) = lower($3))
|
||||
)
|
||||
returning *`,
|
||||
[deliveryId, subject.userId, subject.email]
|
||||
);
|
||||
if (result.rowCount > 0) {
|
||||
const delivery = await loadDelivery(result.rows[0].id);
|
||||
return delivery;
|
||||
}
|
||||
|
||||
const existing = await loadDeliveryForSubject({ subject, deliveryId });
|
||||
return existing?.status === "read" ? existing : null;
|
||||
}
|
||||
|
||||
async function markAllDeliveriesRead({ subject, surface }) {
|
||||
const result = await pool.query(
|
||||
`update notification_deliveries
|
||||
set status = 'read', read_at = coalesce(read_at, now()), updated_at = now()
|
||||
where surface = $1
|
||||
and status = 'unread'
|
||||
and (
|
||||
($2::text is not null and recipient_user_id = $2)
|
||||
or ($3::text is not null and lower(recipient_email) = lower($3))
|
||||
)`,
|
||||
[surface, subject.userId, subject.email]
|
||||
);
|
||||
return { count: result.rowCount };
|
||||
}
|
||||
|
||||
async function loadDeliveriesForEvent(client, eventId) {
|
||||
const result = await client.query(
|
||||
`select
|
||||
d.*,
|
||||
e.type as event_type,
|
||||
e.source_service,
|
||||
e.payload as event_payload
|
||||
from notification_deliveries d
|
||||
join notification_events e on e.id = d.event_id
|
||||
where d.event_id = $1
|
||||
order by d.created_at asc`,
|
||||
[eventId]
|
||||
);
|
||||
return result.rows.map(toDelivery);
|
||||
}
|
||||
|
||||
async function loadDelivery(deliveryId) {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
d.*,
|
||||
e.type as event_type,
|
||||
e.source_service,
|
||||
e.payload as event_payload
|
||||
from notification_deliveries d
|
||||
join notification_events e on e.id = d.event_id
|
||||
where d.id = $1`,
|
||||
[deliveryId]
|
||||
);
|
||||
return result.rows[0] ? toDelivery(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async function loadDeliveryForSubject({ subject, deliveryId }) {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
d.*,
|
||||
e.type as event_type,
|
||||
e.source_service,
|
||||
e.payload as event_payload
|
||||
from notification_deliveries d
|
||||
join notification_events e on e.id = d.event_id
|
||||
where d.id = $1
|
||||
and (
|
||||
($2::text is not null and d.recipient_user_id = $2)
|
||||
or ($3::text is not null and lower(d.recipient_email) = lower($3))
|
||||
)`,
|
||||
[deliveryId, subject.userId, subject.email]
|
||||
);
|
||||
return result.rows[0] ? toDelivery(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
await pool.query(`
|
||||
create table if not exists notification_events (
|
||||
id uuid primary key,
|
||||
type text not null,
|
||||
source_service text not null,
|
||||
actor_user_id text,
|
||||
actor_email text,
|
||||
subject_type text,
|
||||
subject_id text,
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
idempotency_key text unique,
|
||||
created_at timestamptz not null default now()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
create table if not exists notification_deliveries (
|
||||
id uuid primary key,
|
||||
event_id uuid not null references notification_events(id) on delete cascade,
|
||||
recipient_user_id text,
|
||||
recipient_email text,
|
||||
surface text not null,
|
||||
title text not null,
|
||||
body text not null default '',
|
||||
meta jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'unread',
|
||||
actionable boolean not null default false,
|
||||
action_type text,
|
||||
action_url text,
|
||||
entity_type text,
|
||||
entity_id text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
read_at timestamptz,
|
||||
constraint notification_deliveries_recipient_check
|
||||
check (recipient_user_id is not null or recipient_email is not null),
|
||||
constraint notification_deliveries_status_check
|
||||
check (status in ('unread', 'read', 'archived'))
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query("create index if not exists notification_deliveries_recipient_user_idx on notification_deliveries(recipient_user_id, surface, status, created_at desc)");
|
||||
await pool.query("create index if not exists notification_deliveries_recipient_email_idx on notification_deliveries(lower(recipient_email), surface, status, created_at desc)");
|
||||
await pool.query("create index if not exists notification_events_type_idx on notification_events(type, created_at desc)");
|
||||
}
|
||||
|
||||
function sanitizeEventCommand(payload) {
|
||||
const type = requireNonEmptyString(payload?.type, "type");
|
||||
const sourceService = normalizeKey(payload?.sourceService || payload?.source_service || "platform");
|
||||
const deliveries = Array.isArray(payload?.deliveries) ? payload.deliveries.map(sanitizeDeliveryCommand).filter(Boolean) : [];
|
||||
|
||||
if (deliveries.length === 0) {
|
||||
throw badRequest("deliveries_required");
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
sourceService,
|
||||
actor: sanitizeActor(payload?.actor),
|
||||
subject: sanitizeSubject(payload?.subject),
|
||||
payload: isPlainObject(payload?.payload) ? payload.payload : {},
|
||||
deliveries,
|
||||
idempotencyKey: optionalString(payload?.idempotencyKey || payload?.idempotency_key),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeDeliveryCommand(payload) {
|
||||
const recipientUserId = optionalString(payload?.recipientUserId || payload?.recipient_user_id);
|
||||
const recipientEmail = normalizeEmail(payload?.recipientEmail || payload?.recipient_email);
|
||||
|
||||
if (!recipientUserId && !recipientEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
recipientUserId,
|
||||
recipientEmail,
|
||||
surface: sanitizeSurface(payload?.surface || "hub"),
|
||||
title: requireNonEmptyString(payload?.title, "delivery.title"),
|
||||
body: optionalString(payload?.body) || "",
|
||||
meta: isPlainObject(payload?.meta) ? payload.meta : {},
|
||||
status: sanitizeStatus(payload?.status || "unread"),
|
||||
actionable: payload?.actionable === true,
|
||||
actionType: optionalString(payload?.actionType || payload?.action_type),
|
||||
actionUrl: optionalString(payload?.actionUrl || payload?.action_url),
|
||||
entityType: optionalString(payload?.entityType || payload?.entity_type),
|
||||
entityId: optionalString(payload?.entityId || payload?.entity_id),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeActor(payload) {
|
||||
const actor = isPlainObject(payload) ? payload : {};
|
||||
return {
|
||||
userId: optionalString(actor.userId || actor.user_id),
|
||||
email: normalizeEmail(actor.email),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeSubject(payload) {
|
||||
const subject = isPlainObject(payload) ? payload : {};
|
||||
return {
|
||||
type: optionalString(subject.type),
|
||||
id: optionalString(subject.id),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestSubject(req) {
|
||||
const userId = optionalString(req.headers["x-nodedc-user-id"] || req.query.userId);
|
||||
const email = normalizeEmail(req.headers["x-nodedc-user-email"] || req.query.email);
|
||||
if (!userId && !email) {
|
||||
throw badRequest("notification_subject_required");
|
||||
}
|
||||
return { userId, email };
|
||||
}
|
||||
|
||||
function toEvent(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
sourceService: row.source_service,
|
||||
actorUserId: row.actor_user_id,
|
||||
actorEmail: row.actor_email,
|
||||
subjectType: row.subject_type,
|
||||
subjectId: row.subject_id,
|
||||
payload: row.payload || {},
|
||||
idempotencyKey: row.idempotency_key,
|
||||
createdAt: toIso(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function toDelivery(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
eventType: row.event_type,
|
||||
sourceService: row.source_service,
|
||||
recipientUserId: row.recipient_user_id,
|
||||
recipientEmail: row.recipient_email,
|
||||
surface: row.surface,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
meta: row.meta || {},
|
||||
status: row.status,
|
||||
unread: row.status === "unread",
|
||||
actionable: row.actionable,
|
||||
actionType: row.action_type,
|
||||
actionUrl: row.action_url,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
eventPayload: row.event_payload || {},
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
readAt: toIso(row.read_at),
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastDeliveryUpdate(eventName, delivery) {
|
||||
for (const client of streamClients) {
|
||||
if (!deliveryMatchesClient(delivery, client)) continue;
|
||||
writeSse(client.res, eventName, { delivery });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSubjectUpdate(eventName, payload) {
|
||||
for (const client of streamClients) {
|
||||
if (client.surface !== payload.surface) continue;
|
||||
if (!subjectsMatch(client.subject, payload.subject)) continue;
|
||||
writeSse(client.res, eventName, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryMatchesClient(delivery, client) {
|
||||
if (delivery.surface !== client.surface) return false;
|
||||
return subjectsMatch(client.subject, {
|
||||
userId: delivery.recipientUserId,
|
||||
email: delivery.recipientEmail,
|
||||
});
|
||||
}
|
||||
|
||||
function subjectsMatch(left, right) {
|
||||
return Boolean(
|
||||
(left.userId && right.userId && left.userId === right.userId) ||
|
||||
(left.email && right.email && left.email.toLowerCase() === right.email.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function writeSse(res, event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function requireInternalApi(req, res, next) {
|
||||
if (!config.internalAccessToken) {
|
||||
res.status(503).json({ ok: false, error: "notification_core_internal_token_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
|
||||
const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1] || "";
|
||||
const headerToken = typeof req.headers["x-nodedc-internal-token"] === "string" ? req.headers["x-nodedc-internal-token"] : "";
|
||||
const requestToken = bearerToken || headerToken;
|
||||
|
||||
if (!safeTokenEquals(requestToken, config.internalAccessToken)) {
|
||||
res.status(401).json({ ok: false, error: "notification_core_unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function safeTokenEquals(actual, expected) {
|
||||
if (!actual || !expected) return false;
|
||||
const actualBuffer = Buffer.from(actual);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (actualBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
function sanitizeSurface(value) {
|
||||
const surface = normalizeKey(value || "hub");
|
||||
if (!["hub", "engine", "operational-core"].includes(surface)) {
|
||||
throw badRequest("unsupported_notification_surface");
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
|
||||
function sanitizeStatus(value) {
|
||||
const status = normalizeKey(value || "unread");
|
||||
if (!["unread", "read", "archived"].includes(status)) {
|
||||
throw badRequest("unsupported_notification_status");
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function sanitizeLimit(value) {
|
||||
const limit = Number(value || 100);
|
||||
if (!Number.isFinite(limit)) return 100;
|
||||
return Math.min(Math.max(Math.trunc(limit), 1), 200);
|
||||
}
|
||||
|
||||
function sanitizeUuid(value, name) {
|
||||
const text = optionalString(value);
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text || "")) {
|
||||
throw badRequest(`${name}_invalid`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireNonEmptyString(value, name) {
|
||||
const text = optionalString(value);
|
||||
if (!text) throw badRequest(`${name}_required`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const text = value.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function normalizeEmail(value) {
|
||||
const text = optionalString(value);
|
||||
return text ? text.toLowerCase() : null;
|
||||
}
|
||||
|
||||
function normalizeKey(value) {
|
||||
return String(value || "").trim().toLowerCase().replace(/_/g, "-");
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function badRequest(message) {
|
||||
const error = new Error(message);
|
||||
error.status = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function asyncRoute(handler) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ||
|
||||
process.env.NOTIFICATION_DATABASE_URL ||
|
||||
"postgres://nodedc_notifications:nodedc_notifications@localhost:5432/nodedc_notifications";
|
||||
|
||||
return {
|
||||
port: Number(process.env.PORT || process.env.NOTIFICATION_CORE_PORT || "5185"),
|
||||
databaseUrl,
|
||||
databasePoolSize: Number(process.env.NOTIFICATION_DATABASE_POOL_SIZE || "10"),
|
||||
internalAccessToken:
|
||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN ||
|
||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN ||
|
||||
"",
|
||||
};
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
try {
|
||||
httpServer.close();
|
||||
await pool.end();
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user