feat: integrate hub notifications with core
This commit is contained in:
@@ -38,7 +38,9 @@ const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
|
||||
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||||
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
|
||||
const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||||
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
|
||||
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
|
||||
const engineServiceRoles = new Set(["viewer", "member"]);
|
||||
const publicPoolClientId = "client_public_pool";
|
||||
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
|
||||
const publicPoolClient = {
|
||||
@@ -1164,12 +1166,15 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
};
|
||||
|
||||
Object.assign(request, {
|
||||
requestType: "workflow",
|
||||
workflowId,
|
||||
workflowName,
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail,
|
||||
targetName: targetUser.name ?? null,
|
||||
role,
|
||||
requestedServiceRole: null,
|
||||
currentServiceRole: null,
|
||||
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
|
||||
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
|
||||
requesterName,
|
||||
@@ -1201,6 +1206,78 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function createEngineRoleAccessRequest(payload, identity = { name: "NODE.DC Engine", source: "engine" }) {
|
||||
const data = readData();
|
||||
const now = isoNow();
|
||||
const actor = resolveActor(data, identity);
|
||||
const requesterEmail = normalizeEmail(payload?.requesterEmail ?? actor.email ?? payload?.email ?? "");
|
||||
const targetUser =
|
||||
(payload?.requesterUserId ? data.users.find((user) => user.id === payload.requesterUserId) : null) ??
|
||||
(requesterEmail ? data.users.find((user) => normalizeEmail(user.email) === requesterEmail) : null) ??
|
||||
null;
|
||||
|
||||
if (!targetUser || targetUser.globalStatus !== "active") {
|
||||
throw new Error("engine_target_user_not_found");
|
||||
}
|
||||
|
||||
const requestedServiceRole = normalizeEngineServiceRole(payload?.requestedServiceRole ?? payload?.requestedRole ?? "member");
|
||||
const currentServiceRole = normalizeNullableEngineServiceRole(payload?.currentServiceRole ?? payload?.currentRole);
|
||||
const role = requestedServiceRole === "viewer" ? "viewer" : "editor";
|
||||
const existingRequest = data.engineWorkflowAccessRequests.find(
|
||||
(request) =>
|
||||
request.status === "new" &&
|
||||
request.requestType === "service_role" &&
|
||||
request.targetUserId === targetUser.id
|
||||
);
|
||||
const request =
|
||||
existingRequest ??
|
||||
{
|
||||
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_access_request", targetUser.email),
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
Object.assign(request, {
|
||||
requestType: "service_role",
|
||||
workflowId: "engine:service-role",
|
||||
workflowName: "NODE.DC Engine",
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail: normalizeEmail(targetUser.email),
|
||||
targetName: targetUser.name ?? null,
|
||||
role,
|
||||
requestedServiceRole,
|
||||
currentServiceRole,
|
||||
requesterUserId: targetUser.id,
|
||||
requesterEmail: normalizeEmail(targetUser.email),
|
||||
requesterName: optionalString(payload?.requesterName, targetUser.name ?? targetUser.email),
|
||||
status: "new",
|
||||
reviewedByUserId: null,
|
||||
reviewedAt: null,
|
||||
engineAppliedAt: null,
|
||||
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!existingRequest) {
|
||||
data.engineWorkflowAccessRequests.push(request);
|
||||
}
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: existingRequest ? "Обновлена заявка повышения роли Engine" : "Создана заявка повышения роли Engine",
|
||||
objectType: "engine_role_access_request",
|
||||
objectName: `${request.targetName ?? request.targetEmail}:${requestedServiceRole}`,
|
||||
result: "success",
|
||||
details: `Current role: ${currentServiceRole ?? "unknown"}; requested role: ${requestedServiceRole}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return {
|
||||
engineWorkflowAccessRequest: request,
|
||||
affectedUserIds: [targetUser.id, "user_root"].filter(Boolean),
|
||||
targetUser,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
@@ -1212,6 +1289,32 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
}
|
||||
|
||||
const targetUser = findById(data.users, request.targetUserId, "user");
|
||||
if (request.requestType === "service_role") {
|
||||
const serviceRole = normalizeEngineServiceRole(payload?.serviceRole ?? payload?.requestedServiceRole ?? request.requestedServiceRole);
|
||||
request.requestedServiceRole = serviceRole;
|
||||
request.role = serviceRole === "viewer" ? "viewer" : "editor";
|
||||
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
|
||||
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
|
||||
|
||||
request.status = "approved";
|
||||
request.reviewedByUserId = actor.id;
|
||||
request.reviewedAt = now;
|
||||
request.engineAppliedAt = nullableStringWithFallback(payload?.engineAppliedAt, now);
|
||||
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||||
request.updatedAt = now;
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Подтверждена заявка повышения роли Engine",
|
||||
objectType: "engine_role_access_request",
|
||||
objectName: `${request.targetName ?? request.targetEmail}:${serviceRole}`,
|
||||
result: "success",
|
||||
details: `Engine role: ${serviceRole}`,
|
||||
});
|
||||
|
||||
await writeData(data);
|
||||
return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data };
|
||||
}
|
||||
|
||||
const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
|
||||
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
|
||||
|
||||
@@ -1251,9 +1354,11 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
request.updatedAt = now;
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Отклонена заявка доступа Engine workflow",
|
||||
objectType: "engine_workflow_access_request",
|
||||
objectName: `${request.workflowName}:${request.targetEmail}`,
|
||||
action: request.requestType === "service_role" ? "Отклонена заявка повышения роли Engine" : "Отклонена заявка доступа Engine workflow",
|
||||
objectType: request.requestType === "service_role" ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||
objectName: request.requestType === "service_role"
|
||||
? `${request.targetName ?? request.targetEmail}:${request.requestedServiceRole ?? request.role}`
|
||||
: `${request.workflowName}:${request.targetEmail}`,
|
||||
result: "warning",
|
||||
details: request.comment ?? null,
|
||||
});
|
||||
@@ -1649,6 +1754,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
const user = findById(data.users, userId, "user");
|
||||
const service = findById(data.services, serviceId, "service");
|
||||
const engineService = isEngineService(service);
|
||||
const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null;
|
||||
|
||||
if (engineService && (value === "admin" || value === "owner")) {
|
||||
throw new Error("Для Engine доступны только роли guest, member или блокировка");
|
||||
@@ -1690,6 +1796,17 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
throw new Error(`Unsupported access value: ${value}`);
|
||||
}
|
||||
|
||||
let engineWorkflowAccessRequest = null;
|
||||
if (engineService && (value === "viewer" || value === "member") && previousEngineServiceRole !== value) {
|
||||
engineWorkflowAccessRequest = appendEngineRoleChangeNotification(data, {
|
||||
actor,
|
||||
currentServiceRole: previousEngineServiceRole,
|
||||
serviceRole: value,
|
||||
targetUser: user,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
addAuditEvent(data, actor, {
|
||||
action: "Обновлён доступ пользователя к сервису",
|
||||
objectType: "grant",
|
||||
@@ -1700,7 +1817,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
|
||||
|
||||
await writeData(data);
|
||||
return { data };
|
||||
return { data, engineWorkflowAccessRequest, affectedUserIds: [user.id] };
|
||||
}
|
||||
|
||||
async function setServiceModuleEntitlement(payload, identity) {
|
||||
@@ -2011,6 +2128,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||
buildAuthentikSyncPlan,
|
||||
cancelTaskerInviteRequest,
|
||||
createAccessRequest,
|
||||
createEngineRoleAccessRequest,
|
||||
createEngineWorkflowAccessRequest,
|
||||
createTaskerInviteRequest,
|
||||
createClient,
|
||||
@@ -2229,6 +2347,7 @@ function normalizeTaskerInviteRequest(payload) {
|
||||
function normalizeEngineWorkflowAccessRequest(payload) {
|
||||
if (typeof payload !== "object" || payload === null) return null;
|
||||
const now = isoNow();
|
||||
const requestType = pickEnum(payload.requestType ?? payload.type, engineWorkflowAccessRequestTypes, "workflow");
|
||||
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
|
||||
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
|
||||
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
|
||||
@@ -2238,12 +2357,17 @@ function normalizeEngineWorkflowAccessRequest(payload) {
|
||||
|
||||
return {
|
||||
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
|
||||
requestType,
|
||||
workflowId,
|
||||
workflowName: optionalString(payload.workflowName, workflowId),
|
||||
targetUserId,
|
||||
targetEmail,
|
||||
targetName: nullableStringWithFallback(payload.targetName, null),
|
||||
role: normalizeEngineWorkflowRole(payload.role),
|
||||
requestedServiceRole: requestType === "service_role"
|
||||
? normalizeEngineServiceRole(payload.requestedServiceRole ?? payload.requestedRole ?? payload.serviceRole)
|
||||
: nullableStringWithFallback(payload.requestedServiceRole, null),
|
||||
currentServiceRole: normalizeNullableEngineServiceRole(payload.currentServiceRole ?? payload.currentRole),
|
||||
requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
|
||||
requesterEmail,
|
||||
requesterName: optionalString(payload.requesterName, requesterEmail),
|
||||
@@ -2671,6 +2795,49 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
|
||||
return grant;
|
||||
}
|
||||
|
||||
function resolveDirectEngineServiceRole(data, serviceId, userId) {
|
||||
const denied = data.exceptions.some(
|
||||
(exception) => exception.serviceId === serviceId && exception.userId === userId && exception.type === "deny"
|
||||
);
|
||||
if (denied) return null;
|
||||
|
||||
const grant = data.grants.find(
|
||||
(candidate) => candidate.serviceId === serviceId && candidate.targetType === "user" && candidate.targetId === userId
|
||||
);
|
||||
if (!grant || grant.status !== "active") return null;
|
||||
if (grant.appRole === "viewer") return "viewer";
|
||||
if (grant.appRole === "member") return "member";
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendEngineRoleChangeNotification(data, { actor, currentServiceRole, serviceRole, targetUser, now }) {
|
||||
const requestedServiceRole = normalizeEngineServiceRole(serviceRole);
|
||||
const request = {
|
||||
id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_change", `${targetUser.email}-${now}`),
|
||||
requestType: "service_role",
|
||||
workflowId: "engine:service-role",
|
||||
workflowName: "NODE.DC Engine",
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail: normalizeEmail(targetUser.email),
|
||||
targetName: targetUser.name ?? null,
|
||||
role: requestedServiceRole === "viewer" ? "viewer" : "editor",
|
||||
requestedServiceRole,
|
||||
currentServiceRole: normalizeNullableEngineServiceRole(currentServiceRole),
|
||||
requesterUserId: actor.id ?? null,
|
||||
requesterEmail: normalizeEmail(actor.email) || "admin@nodedc.ru",
|
||||
requesterName: actor.name || actor.email || "Администратор",
|
||||
status: "approved",
|
||||
reviewedByUserId: actor.id ?? null,
|
||||
reviewedAt: now,
|
||||
engineAppliedAt: now,
|
||||
comment: "admin_role_changed",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.engineWorkflowAccessRequests.push(request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) {
|
||||
const invitedByUserId = request.requesterUserId || actor?.id || null;
|
||||
let membership = data.memberships.find(
|
||||
@@ -3099,6 +3266,19 @@ function normalizeEngineWorkflowRole(value) {
|
||||
return engineWorkflowRoles.has(normalized) ? normalized : "viewer";
|
||||
}
|
||||
|
||||
function normalizeEngineServiceRole(value) {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (normalized === "guest" || normalized === "viewer" || normalized === "read_only" || normalized === "readonly") return "viewer";
|
||||
if (normalized === "participant" || normalized === "editor" || normalized === "member") return "member";
|
||||
return engineServiceRoles.has(normalized) ? normalized : "member";
|
||||
}
|
||||
|
||||
function normalizeNullableEngineServiceRole(value) {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (!normalized || normalized === "null" || normalized === "undefined") return null;
|
||||
return normalizeEngineServiceRole(normalized);
|
||||
}
|
||||
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
+757
-3
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
|
||||
import { createControlPlaneStore } from "./control-plane-store.mjs";
|
||||
import { createNotificationCoreClient } from "./notification-core-client.mjs";
|
||||
|
||||
const serverRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(serverRoot, "..");
|
||||
@@ -34,6 +35,7 @@ const controlPlaneStore = createControlPlaneStore({ projectRoot });
|
||||
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
|
||||
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
|
||||
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
|
||||
const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken });
|
||||
const pendingLogins = new Map();
|
||||
const serviceHandoffs = new Map();
|
||||
const sessions = new Map();
|
||||
@@ -58,6 +60,7 @@ app.get("/healthz", (_req, res) => {
|
||||
oidcConfigured: config.oidcConfigured,
|
||||
authentikApiConfigured: authentikSyncClient.isConfigured(),
|
||||
internalAccessApiConfigured: Boolean(config.internalAccessToken),
|
||||
notificationCoreConfigured: notificationCoreClient.isConfigured(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +102,28 @@ app.post("/api/access-requests", asyncRoute(async (req, res) => {
|
||||
});
|
||||
|
||||
publishControlPlaneEvent("access-request.created", [result.user.id]);
|
||||
publishNotificationEvent({
|
||||
type: "nodedc.access_request.created",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: result.user.id, email: result.user.email },
|
||||
subject: { type: "access_request", id: result.accessRequest.id },
|
||||
payload: {
|
||||
accessRequestId: result.accessRequest.id,
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
status: result.accessRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:created:${result.accessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
rootAdminNotificationDelivery(result.data, {
|
||||
...buildAccessRequestHubNotification(result.accessRequest),
|
||||
actionable: true,
|
||||
actionType: "nodedc.access_request.review",
|
||||
entityType: "access_request",
|
||||
entityId: result.accessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
res.status(201).json({ accessRequest: result.accessRequest });
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
@@ -268,6 +293,92 @@ app.get("/api/me", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/notifications", requireSession, asyncRoute(async (req, res) => {
|
||||
if (!notificationCoreClient.isConfigured()) {
|
||||
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||
const user = findLauncherUser(data, actor.id);
|
||||
const payload = await notificationCoreClient.listNotifications({
|
||||
subject: notificationSubjectFromUser(user),
|
||||
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
|
||||
limit: req.query.limit,
|
||||
unreadOnly: req.query.unreadOnly === "1" || req.query.unreadOnly === "true",
|
||||
});
|
||||
res.json(payload);
|
||||
}));
|
||||
|
||||
app.post("/api/notifications/:deliveryId/read", requireSession, asyncRoute(async (req, res) => {
|
||||
if (!notificationCoreClient.isConfigured()) {
|
||||
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||
const user = findLauncherUser(data, actor.id);
|
||||
const payload = await notificationCoreClient.markNotificationRead({
|
||||
subject: notificationSubjectFromUser(user),
|
||||
deliveryId: req.params.deliveryId,
|
||||
});
|
||||
res.json(payload);
|
||||
}));
|
||||
|
||||
app.post("/api/notifications/read-all", requireSession, asyncRoute(async (req, res) => {
|
||||
if (!notificationCoreClient.isConfigured()) {
|
||||
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||
const user = findLauncherUser(data, actor.id);
|
||||
const payload = await notificationCoreClient.markAllNotificationsRead({
|
||||
subject: notificationSubjectFromUser(user),
|
||||
surface: sanitizeNotificationSurface(req.query.surface ?? req.body?.surface, "hub"),
|
||||
});
|
||||
res.json(payload);
|
||||
}));
|
||||
|
||||
app.get("/api/notifications/stream", requireSession, asyncRoute(async (req, res) => {
|
||||
if (!notificationCoreClient.isConfigured()) {
|
||||
res.status(503).json({ ok: false, error: "notification_core_not_configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { actor, data } = getLauncherProfileContext(req.nodedcSession);
|
||||
const user = findLauncherUser(data, actor.id);
|
||||
const abortController = new AbortController();
|
||||
|
||||
req.on("close", () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const upstream = await notificationCoreClient.openNotificationStream({
|
||||
subject: notificationSubjectFromUser(user),
|
||||
surface: sanitizeNotificationSurface(req.query.surface, "hub"),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
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?.();
|
||||
|
||||
const reader = upstream.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(Buffer.from(value));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
res.end();
|
||||
}
|
||||
}));
|
||||
|
||||
app.get("/api/apps", (req, res) => {
|
||||
const session = getCurrentSession(req);
|
||||
|
||||
@@ -514,6 +625,39 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
|
||||
"tasker.invite-request.created",
|
||||
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id]
|
||||
);
|
||||
publishNotificationEvent({
|
||||
type: result.taskerInviteRequest.status === "approved" ? "tasker.workspace_invite.approved" : "tasker.workspace_invite.requested",
|
||||
sourceService: "tasker",
|
||||
actor: { userId: inviter.id, email: inviter.email },
|
||||
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||
payload: {
|
||||
requestId: result.taskerInviteRequest.id,
|
||||
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||
status: result.taskerInviteRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:${result.taskerInviteRequest.status}:${result.taskerInviteRequest.updatedAt}`,
|
||||
deliveries: (
|
||||
result.taskerInviteRequest.status === "new"
|
||||
? [
|
||||
rootAdminNotificationDelivery(result.data, {
|
||||
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||
actionable: true,
|
||||
actionType: "tasker.workspace_invite.review",
|
||||
entityType: "tasker_invite_request",
|
||||
entityId: result.taskerInviteRequest.id,
|
||||
}),
|
||||
]
|
||||
: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||
notificationDeliveryForUser(user, "hub", {
|
||||
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||
entityType: "tasker_invite_request",
|
||||
entityId: result.taskerInviteRequest.id,
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
});
|
||||
res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) });
|
||||
}));
|
||||
|
||||
@@ -607,9 +751,137 @@ app.post("/api/internal/engine/workflow-access-requests", asyncRoute(async (req,
|
||||
"engine.workflow-access-request.created",
|
||||
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id]
|
||||
);
|
||||
publishNotificationEvent({
|
||||
type: "engine.workflow_share.access_requested",
|
||||
sourceService: "engine",
|
||||
actor: { userId: requester.id, email: requester.email },
|
||||
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail: targetUser.email,
|
||||
role: result.engineWorkflowAccessRequest.role,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
rootAdminNotificationDelivery(result.data, {
|
||||
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
|
||||
actionable: true,
|
||||
actionType: "engine.workflow_access.review",
|
||||
entityType: "engine_workflow_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
||||
}));
|
||||
|
||||
app.post("/api/internal/engine/role-access-requests", asyncRoute(async (req, res) => {
|
||||
if (!isInternalRequestAuthorized(req)) {
|
||||
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||
ok: false,
|
||||
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request" });
|
||||
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
|
||||
const requester = findInternalAccessUser(snapshot.data, {
|
||||
subject: requesterPayload.subject,
|
||||
email: requesterPayload.email,
|
||||
userId: requesterPayload.userId,
|
||||
});
|
||||
|
||||
if (!requester || requester.globalStatus !== "active") {
|
||||
res.status(404).json({ ok: false, error: "requester_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await controlPlaneStore.createEngineRoleAccessRequest({
|
||||
requestedRole: req.body?.requestedRole,
|
||||
currentRole: req.body?.currentRole,
|
||||
requesterUserId: requester.id,
|
||||
requesterEmail: requester.email,
|
||||
requesterName: requester.name,
|
||||
}, requester);
|
||||
|
||||
publishControlPlaneEvent(
|
||||
"engine.role-access-request.created",
|
||||
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, "user_root"]
|
||||
);
|
||||
publishNotificationEvent({
|
||||
type: "engine.role_upgrade.requested",
|
||||
sourceService: "engine",
|
||||
actor: { userId: requester.id, email: requester.email },
|
||||
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
targetUserId: requester.id,
|
||||
targetEmail: requester.email,
|
||||
requestedServiceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
rootAdminNotificationDelivery(result.data, {
|
||||
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||
actionable: true,
|
||||
actionType: "engine.role_upgrade.review",
|
||||
entityType: "engine_role_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest });
|
||||
}));
|
||||
|
||||
app.post("/api/internal/engine/role-access-requests/list", asyncRoute(async (req, res) => {
|
||||
if (!isInternalRequestAuthorized(req)) {
|
||||
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||
ok: false,
|
||||
error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request list" });
|
||||
const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {};
|
||||
const requester = findInternalAccessUser(snapshot.data, {
|
||||
subject: requesterPayload.subject,
|
||||
email: requesterPayload.email,
|
||||
userId: requesterPayload.userId,
|
||||
});
|
||||
|
||||
if (!requester || requester.globalStatus !== "active") {
|
||||
res.status(404).json({ ok: false, error: "requester_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const requesterEmail = String(requester.email || "").toLowerCase();
|
||||
const requesterGroups = Array.isArray(requesterPayload.groups)
|
||||
? requesterPayload.groups.map((group) => String(group || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
const canReviewEngineRoleRequests = requester.id === "user_root" || isLauncherAdmin(requesterGroups);
|
||||
const requests = snapshot.data.engineWorkflowAccessRequests
|
||||
.filter((request) => {
|
||||
if (request.requestType !== "service_role" || request.status === "cancelled") return false;
|
||||
const isRelated =
|
||||
request.requesterUserId === requester.id ||
|
||||
request.targetUserId === requester.id ||
|
||||
String(request.requesterEmail || "").toLowerCase() === requesterEmail ||
|
||||
String(request.targetEmail || "").toLowerCase() === requesterEmail;
|
||||
|
||||
if (canReviewEngineRoleRequests && request.status === "new") return true;
|
||||
return isRelated && request.status !== "new";
|
||||
})
|
||||
.sort((left, right) => String(right.updatedAt || right.createdAt).localeCompare(String(left.updatedAt || left.createdAt)));
|
||||
|
||||
res.json({ ok: true, requests });
|
||||
}));
|
||||
|
||||
app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
|
||||
if (!isInternalRequestAuthorized(req)) {
|
||||
res.status(config.internalAccessToken ? 401 : 503).json({
|
||||
@@ -1358,6 +1630,28 @@ app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherA
|
||||
}
|
||||
|
||||
publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []);
|
||||
if (result.user) {
|
||||
publishNotificationEvent({
|
||||
type: "nodedc.access_request.approved",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "access_request", id: result.accessRequest.id },
|
||||
payload: {
|
||||
accessRequestId: result.accessRequest.id,
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
status: result.accessRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:approved:${result.accessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(result.user, "hub", {
|
||||
...buildAccessRequestHubNotification(result.accessRequest),
|
||||
entityType: "access_request",
|
||||
entityId: result.accessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
}
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
@@ -1368,6 +1662,29 @@ app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAd
|
||||
try {
|
||||
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
|
||||
publishControlPlaneEvent("admin.access-request.rejected");
|
||||
const targetUser = result.data.users.find((user) => user.email.toLowerCase() === result.accessRequest.email.toLowerCase()) ?? null;
|
||||
if (targetUser) {
|
||||
publishNotificationEvent({
|
||||
type: "nodedc.access_request.rejected",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "access_request", id: result.accessRequest.id },
|
||||
payload: {
|
||||
accessRequestId: result.accessRequest.id,
|
||||
userId: targetUser.id,
|
||||
email: targetUser.email,
|
||||
status: result.accessRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:access-request:${result.accessRequest.id}:rejected:${result.accessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(targetUser, "hub", {
|
||||
...buildAccessRequestHubNotification(result.accessRequest),
|
||||
entityType: "access_request",
|
||||
entityId: result.accessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
}
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
} catch (error) {
|
||||
sendAccessRequestApiError(res, error);
|
||||
@@ -1407,6 +1724,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", req
|
||||
);
|
||||
|
||||
publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]);
|
||||
publishNotificationEvent({
|
||||
type: "tasker.workspace_invite.approved",
|
||||
sourceService: "tasker",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||
payload: {
|
||||
requestId: result.taskerInviteRequest.id,
|
||||
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||
status: result.taskerInviteRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:approved:${result.taskerInviteRequest.updatedAt}`,
|
||||
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||
notificationDeliveryForUser(user, "hub", {
|
||||
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||
entityType: "tasker_invite_request",
|
||||
entityId: result.taskerInviteRequest.id,
|
||||
})
|
||||
).filter(Boolean),
|
||||
});
|
||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||
}));
|
||||
|
||||
@@ -1429,6 +1767,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requ
|
||||
const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
|
||||
|
||||
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]);
|
||||
publishNotificationEvent({
|
||||
type: "tasker.workspace_invite.rejected",
|
||||
sourceService: "tasker",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id },
|
||||
payload: {
|
||||
requestId: result.taskerInviteRequest.id,
|
||||
workspaceSlug: result.taskerInviteRequest.workspaceSlug,
|
||||
workspaceName: result.taskerInviteRequest.workspaceName,
|
||||
inviteeEmail: result.taskerInviteRequest.inviteeEmail,
|
||||
status: result.taskerInviteRequest.status,
|
||||
},
|
||||
idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:rejected:${result.taskerInviteRequest.updatedAt}`,
|
||||
deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) =>
|
||||
notificationDeliveryForUser(user, "hub", {
|
||||
...buildTaskerInviteHubNotification(result.taskerInviteRequest),
|
||||
entityType: "tasker_invite_request",
|
||||
entityId: result.taskerInviteRequest.id,
|
||||
})
|
||||
).filter(Boolean),
|
||||
});
|
||||
res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
|
||||
}));
|
||||
|
||||
@@ -1443,6 +1802,50 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.requestType === "service_role") {
|
||||
const result = await controlPlaneStore.approveEngineWorkflowAccessRequest(
|
||||
req.params.engineWorkflowAccessRequestId,
|
||||
{
|
||||
serviceRole: req.body?.serviceRole ?? req.body?.requestedServiceRole ?? req.body?.role,
|
||||
comment: req.body?.comment,
|
||||
},
|
||||
req.nodedcSession.user
|
||||
);
|
||||
const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user);
|
||||
|
||||
publishControlPlaneEvent("admin.engine-role-access-request.approved", [
|
||||
result.engineWorkflowAccessRequest.requesterUserId,
|
||||
result.targetUser.id,
|
||||
]);
|
||||
publishNotificationEvent({
|
||||
type: "engine.role_upgrade.approved",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
targetUserId: result.targetUser.id,
|
||||
targetEmail: result.targetUser.email,
|
||||
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(result.targetUser, "hub", {
|
||||
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_role_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
notificationDeliveryForUser(result.targetUser, "engine", {
|
||||
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_role_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||
return;
|
||||
}
|
||||
|
||||
const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
|
||||
body: {
|
||||
email: request.targetEmail,
|
||||
@@ -1466,6 +1869,33 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||
result.engineWorkflowAccessRequest.requesterUserId,
|
||||
result.targetUser.id,
|
||||
]);
|
||||
publishNotificationEvent({
|
||||
type: "engine.workflow_share.approved",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id },
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||
targetUserId: result.targetUser.id,
|
||||
targetEmail: result.targetUser.email,
|
||||
role: result.engineWorkflowAccessRequest.role,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(result.targetUser, "hub", {
|
||||
...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_workflow_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
notificationDeliveryForUser(result.targetUser, "engine", {
|
||||
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_workflow_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult }));
|
||||
}));
|
||||
|
||||
@@ -1479,6 +1909,41 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
|
||||
publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
|
||||
result.engineWorkflowAccessRequest.requesterUserId,
|
||||
]);
|
||||
const targetUser = result.data.users.find((user) => user.id === result.engineWorkflowAccessRequest.targetUserId) ?? null;
|
||||
if (targetUser) {
|
||||
const isServiceRoleRequest = result.engineWorkflowAccessRequest.requestType === "service_role";
|
||||
publishNotificationEvent({
|
||||
type: isServiceRoleRequest ? "engine.role_upgrade.rejected" : "engine.workflow_share.rejected",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: {
|
||||
type: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||
id: result.engineWorkflowAccessRequest.id,
|
||||
},
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail: targetUser.email,
|
||||
workflowId: result.engineWorkflowAccessRequest.workflowId,
|
||||
workflowName: result.engineWorkflowAccessRequest.workflowName,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-request:${result.engineWorkflowAccessRequest.id}:rejected:${result.engineWorkflowAccessRequest.updatedAt}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(targetUser, "hub", {
|
||||
...(isServiceRoleRequest
|
||||
? buildEngineRoleHubNotification(result.engineWorkflowAccessRequest)
|
||||
: buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest)),
|
||||
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
notificationDeliveryForUser(targetUser, "engine", {
|
||||
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
}
|
||||
res.json(scopeAdminMutationResult(req, result));
|
||||
}));
|
||||
|
||||
@@ -1618,7 +2083,41 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn
|
||||
|
||||
const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
|
||||
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user);
|
||||
publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds);
|
||||
publishControlPlaneEvent(
|
||||
"admin.access.user-service.updated",
|
||||
result.affectedUserIds?.length ? result.affectedUserIds : syncResult.userIds
|
||||
);
|
||||
if (result.engineWorkflowAccessRequest) {
|
||||
const targetUser = syncResult.data.users.find((user) => user.id === req.body?.userId) ?? null;
|
||||
if (targetUser) {
|
||||
publishNotificationEvent({
|
||||
type: "engine.role.changed",
|
||||
sourceService: "launcher",
|
||||
actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email },
|
||||
subject: { type: "engine_role_change", id: result.engineWorkflowAccessRequest.id },
|
||||
payload: {
|
||||
requestId: result.engineWorkflowAccessRequest.id,
|
||||
targetUserId: targetUser.id,
|
||||
targetEmail: targetUser.email,
|
||||
serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole,
|
||||
currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole,
|
||||
},
|
||||
idempotencyKey: `launcher:engine-role-change:${result.engineWorkflowAccessRequest.id}`,
|
||||
deliveries: [
|
||||
notificationDeliveryForUser(targetUser, "hub", {
|
||||
...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_role_change",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
notificationDeliveryForUser(targetUser, "engine", {
|
||||
...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest),
|
||||
entityType: "engine_role_change",
|
||||
entityId: result.engineWorkflowAccessRequest.id,
|
||||
}),
|
||||
].filter(Boolean),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
|
||||
}));
|
||||
|
||||
@@ -1785,6 +2284,10 @@ function readConfig() {
|
||||
process.env.NODEDC_ENGINE_BASE_URL ??
|
||||
process.env.ENGINE_BASE_URL ??
|
||||
"https://engine.nodedc.ru",
|
||||
notificationCoreBaseUrl:
|
||||
process.env.NODEDC_NOTIFICATION_CORE_URL ??
|
||||
process.env.NOTIFICATION_CORE_URL ??
|
||||
"",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3320,6 +3823,239 @@ function findLauncherUser(data, userId) {
|
||||
return user;
|
||||
}
|
||||
|
||||
function notificationSubjectFromUser(user) {
|
||||
return {
|
||||
userId: user?.id ?? null,
|
||||
email: typeof user?.email === "string" ? user.email.toLowerCase() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function notificationDeliveryForUser(user, surface, notification) {
|
||||
if (!user?.id && !user?.email) return null;
|
||||
return {
|
||||
recipientUserId: user?.id ?? null,
|
||||
recipientEmail: typeof user?.email === "string" ? user.email.toLowerCase() : null,
|
||||
surface,
|
||||
title: notification.title,
|
||||
body: notification.body ?? "",
|
||||
meta: notification.meta ?? {},
|
||||
actionable: notification.actionable === true,
|
||||
actionType: notification.actionType ?? null,
|
||||
actionUrl: notification.actionUrl ?? null,
|
||||
entityType: notification.entityType ?? null,
|
||||
entityId: notification.entityId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function rootAdminNotificationDelivery(data, notification) {
|
||||
const rootUser = data.users.find((user) => user.id === "user_root") ?? data.users[0] ?? null;
|
||||
return notificationDeliveryForUser(rootUser, "hub", notification);
|
||||
}
|
||||
|
||||
function notificationUsersForTaskerInvite(data, request) {
|
||||
const users = [];
|
||||
const seen = new Set();
|
||||
const addUser = (user) => {
|
||||
if (!user?.id || seen.has(user.id)) return;
|
||||
seen.add(user.id);
|
||||
users.push(user);
|
||||
};
|
||||
|
||||
addUser(data.users.find((user) => user.id === request?.inviterUserId));
|
||||
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviterEmail)));
|
||||
addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviteeEmail)));
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
function buildAccessRequestHubNotification(accessRequest) {
|
||||
const applicantName = [accessRequest?.lastName, accessRequest?.firstName].filter(Boolean).join(" ") || accessRequest?.email || "NODE.DC user";
|
||||
const status = accessRequest?.status || "new";
|
||||
return {
|
||||
title: status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён",
|
||||
body: `${applicantName} · ${accessRequest?.email || ""}`,
|
||||
meta: {
|
||||
displayStatus: platformNotificationStatusLabel(status),
|
||||
meta: accessRequest?.company || formatNotificationDate(accessRequest?.createdAt),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskerInviteHubNotification(request) {
|
||||
const status = request?.status || "new";
|
||||
return {
|
||||
title: status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
|
||||
body: `${request?.workspaceName || request?.workspaceSlug || "Operational Core"} · ${request?.inviteeEmail || ""}`,
|
||||
meta: {
|
||||
displayStatus: platformNotificationStatusLabel(status),
|
||||
meta: request?.inviterName || formatNotificationDate(request?.createdAt),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEngineRoleHubNotification(request) {
|
||||
const target = request?.targetName || request?.targetEmail || "Engine user";
|
||||
const status = request?.status || "new";
|
||||
return {
|
||||
title: engineRoleHubNotificationTitle(request),
|
||||
body: engineRoleHubNotificationBody(request, target),
|
||||
meta: {
|
||||
displayStatus: platformNotificationStatusLabel(status),
|
||||
meta: `Запрошено: ${engineServiceRoleLabel(request?.requestedServiceRole)}`,
|
||||
requestId: request?.id,
|
||||
requestedServiceRole: request?.requestedServiceRole ?? null,
|
||||
currentServiceRole: request?.currentServiceRole ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEngineWorkflowHubNotification(request) {
|
||||
const status = request?.status || "new";
|
||||
return {
|
||||
title: status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
|
||||
body: `${request?.workflowName || request?.workflowId || "Engine workflow"} · ${request?.targetEmail || ""}`,
|
||||
meta: {
|
||||
displayStatus: platformNotificationStatusLabel(status),
|
||||
meta: request?.requesterName || formatNotificationDate(request?.createdAt),
|
||||
requestId: request?.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEngineSurfaceNotification(request) {
|
||||
if (request?.requestType === "service_role") {
|
||||
return {
|
||||
title: request?.workflowName || "NODE.DC Engine",
|
||||
body: engineRoleSurfaceNotificationBody(request),
|
||||
meta: {
|
||||
displayStatus: engineSurfaceStatusLabel(request),
|
||||
meta: engineSurfaceMeta(request),
|
||||
requestId: request?.id,
|
||||
requestedServiceRole: request?.requestedServiceRole ?? null,
|
||||
currentServiceRole: request?.currentServiceRole ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: request?.workflowName || request?.workflowId || "Engine workflow",
|
||||
body: `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${engineWorkflowRoleLabel(request?.role)}`,
|
||||
meta: {
|
||||
displayStatus: engineSurfaceStatusLabel(request),
|
||||
meta: engineSurfaceMeta(request),
|
||||
requestId: request?.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function engineRoleHubNotificationTitle(request) {
|
||||
if (request?.status === "approved") return request?.comment === "admin_role_changed" ? "Engine: роль изменена" : "Engine: роль обновлена";
|
||||
if (request?.status === "rejected") return "Engine: повышение роли отклонено";
|
||||
return "Engine: запрос повышения роли";
|
||||
}
|
||||
|
||||
function engineRoleHubNotificationBody(request, target) {
|
||||
if (request?.status === "rejected") return `${target} · администратор отклонил заявку`;
|
||||
if (request?.status === "approved") {
|
||||
return request?.comment === "admin_role_changed" ? `${target} · роль изменена администратором` : `${target} · новая роль применена`;
|
||||
}
|
||||
return `${target} · текущая роль ${engineServiceRoleLabel(request?.currentServiceRole)}`;
|
||||
}
|
||||
|
||||
function engineRoleSurfaceNotificationBody(request) {
|
||||
const requestedRole = engineServiceRoleLabel(request?.requestedServiceRole);
|
||||
const requestedByTarget = normalizeNotificationEmail(request?.requesterEmail) === normalizeNotificationEmail(request?.targetEmail);
|
||||
if (request?.status === "approved") {
|
||||
return requestedByTarget && request?.comment !== "admin_role_changed"
|
||||
? `Администратор подтвердил повышение роли: ${requestedRole}`
|
||||
: `Администратор изменил роль: ${requestedRole}`;
|
||||
}
|
||||
if (request?.status === "rejected") {
|
||||
return "Администратор отклонил заявку на повышение роли";
|
||||
}
|
||||
return `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${requestedRole}`;
|
||||
}
|
||||
|
||||
function engineSurfaceStatusLabel(request) {
|
||||
if (request?.requestType === "service_role" && request?.status === "approved" && request?.comment === "admin_role_changed") {
|
||||
return "Изменено";
|
||||
}
|
||||
if (request?.status === "approved") return "Одобрено";
|
||||
if (request?.status === "rejected") return "Отклонено";
|
||||
return "Новая заявка";
|
||||
}
|
||||
|
||||
function engineSurfaceMeta(request) {
|
||||
const createdAt = formatShortNotificationDate(request?.createdAt);
|
||||
return ["Ваш запрос", createdAt].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function engineServiceRoleLabel(role) {
|
||||
if (role === "member") return "Участник";
|
||||
if (role === "viewer") return "Гость";
|
||||
return "не назначена";
|
||||
}
|
||||
|
||||
function engineWorkflowRoleLabel(role) {
|
||||
if (role === "admin") return "Соавтор";
|
||||
if (role === "editor") return "Редактор";
|
||||
return "Просмотр";
|
||||
}
|
||||
|
||||
function platformNotificationStatusLabel(status) {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
if (normalized === "approved") return "Подтверждено";
|
||||
if (normalized === "rejected") return "Отклонено";
|
||||
if (normalized === "cancelled") return "Отменено";
|
||||
return "Входящее";
|
||||
}
|
||||
|
||||
function formatNotificationDate(value) {
|
||||
const timestamp = Date.parse(String(value || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function formatShortNotificationDate(value) {
|
||||
const timestamp = Date.parse(String(value || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function normalizeNotificationEmail(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function sanitizeNotificationSurface(value, fallback = "hub") {
|
||||
const surface = typeof value === "string" && value.trim() ? value.trim().toLowerCase() : fallback;
|
||||
if (surface === "hub" || surface === "engine" || surface === "operational-core") return surface;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function publishNotificationEvent(command) {
|
||||
if (!notificationCoreClient.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(command?.deliveries) || command.deliveries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
notificationCoreClient.createEvent(command).catch((error) => {
|
||||
console.warn("[notification-core] event publish failed:", error?.message || error);
|
||||
});
|
||||
}
|
||||
|
||||
function isLauncherAdmin(groups) {
|
||||
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin");
|
||||
}
|
||||
@@ -3511,6 +4247,12 @@ function scopeControlPlaneData(data, scope) {
|
||||
}
|
||||
|
||||
const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id));
|
||||
const userEmails = new Set(
|
||||
data.users
|
||||
.filter((user) => userIds.has(user.id))
|
||||
.map((user) => String(user.email || "").toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -3521,7 +4263,12 @@ function scopeControlPlaneData(data, scope) {
|
||||
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
|
||||
accessRequests: [],
|
||||
taskerInviteRequests: [],
|
||||
engineWorkflowAccessRequests: [],
|
||||
engineWorkflowAccessRequests: data.engineWorkflowAccessRequests.filter((request) =>
|
||||
userIds.has(request.requesterUserId) ||
|
||||
userIds.has(request.targetUserId) ||
|
||||
userEmails.has(String(request.requesterEmail || "").toLowerCase()) ||
|
||||
userEmails.has(String(request.targetEmail || "").toLowerCase())
|
||||
),
|
||||
grants: data.grants.filter((grant) => {
|
||||
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||
@@ -3574,6 +4321,13 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||
.filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
|
||||
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
|
||||
const groupIds = new Set(groups.map((group) => group.id));
|
||||
const userEmail = String(user.email || "").toLowerCase();
|
||||
const relatedEngineRequests = data.engineWorkflowAccessRequests.filter((request) =>
|
||||
request.requesterUserId === user.id ||
|
||||
request.targetUserId === user.id ||
|
||||
String(request.requesterEmail || "").toLowerCase() === userEmail ||
|
||||
String(request.targetEmail || "").toLowerCase() === userEmail
|
||||
);
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -3585,7 +4339,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
|
||||
accessRequests: [],
|
||||
revokedAccounts: [],
|
||||
taskerInviteRequests: [],
|
||||
engineWorkflowAccessRequests: [],
|
||||
engineWorkflowAccessRequests: relatedEngineRequests,
|
||||
grants: data.grants.filter((grant) => {
|
||||
if (grant.targetType === "client") return clientIds.has(grant.targetId);
|
||||
if (grant.targetType === "group") return groupIds.has(grant.targetId);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export function createNotificationCoreClient({ baseUrl, token }) {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const internalToken = typeof token === "string" ? token.trim() : "";
|
||||
|
||||
return {
|
||||
isConfigured() {
|
||||
return Boolean(normalizedBaseUrl && internalToken);
|
||||
},
|
||||
|
||||
async createEvent(command) {
|
||||
return requestJson({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
token: internalToken,
|
||||
method: "POST",
|
||||
pathname: "/internal/notifications/events",
|
||||
body: command,
|
||||
});
|
||||
},
|
||||
|
||||
async listNotifications({ subject, surface, limit, unreadOnly }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("surface", surface || "hub");
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
if (unreadOnly) searchParams.set("unreadOnly", "1");
|
||||
return requestJson({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
token: internalToken,
|
||||
method: "GET",
|
||||
pathname: `/api/notifications?${searchParams.toString()}`,
|
||||
subject,
|
||||
});
|
||||
},
|
||||
|
||||
async markNotificationRead({ subject, deliveryId }) {
|
||||
return requestJson({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
token: internalToken,
|
||||
method: "POST",
|
||||
pathname: `/api/notifications/${encodeURIComponent(deliveryId)}/read`,
|
||||
subject,
|
||||
});
|
||||
},
|
||||
|
||||
async markAllNotificationsRead({ subject, surface }) {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("surface", surface || "hub");
|
||||
return requestJson({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
token: internalToken,
|
||||
method: "POST",
|
||||
pathname: `/api/notifications/read-all?${searchParams.toString()}`,
|
||||
subject,
|
||||
});
|
||||
},
|
||||
|
||||
async openNotificationStream({ subject, surface, signal }) {
|
||||
if (!normalizedBaseUrl || !internalToken) {
|
||||
throw new Error("notification_core_not_configured");
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("surface", surface || "hub");
|
||||
const response = await fetch(`${normalizedBaseUrl}/api/notifications/stream?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(internalToken, subject),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson({ baseUrl, token, method, pathname, body, subject }) {
|
||||
if (!baseUrl || !token) {
|
||||
throw new Error("notification_core_not_configured");
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${pathname}`, {
|
||||
method,
|
||||
headers: buildHeaders(token, subject, body !== undefined),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(parsed?.error ? String(parsed.error) : `notification_core_http_${response.status}`);
|
||||
error.status = response.status;
|
||||
error.payload = parsed;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildHeaders(token, subject, hasJsonBody = false) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (hasJsonBody) headers["Content-Type"] = "application/json";
|
||||
if (subject?.userId) headers["X-NODEDC-User-Id"] = subject.userId;
|
||||
if (subject?.email) headers["X-NODEDC-User-Email"] = subject.email;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readError(response) {
|
||||
try {
|
||||
const parsed = await response.json();
|
||||
if (parsed?.error) return String(parsed.error);
|
||||
} catch {}
|
||||
return `notification_core_http_${response.status}`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim().replace(/\/+$/, "") : "";
|
||||
}
|
||||
Reference in New Issue
Block a user