feat: integrate hub notifications with core

This commit is contained in:
DCCONSTRUCTIONS 2026-05-26 18:01:39 +03:00
parent de446c1879
commit 8f56fd2ab6
10 changed files with 1484 additions and 126 deletions

View File

@ -38,7 +38,9 @@ const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]);
const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]); const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]);
const engineServiceRoles = new Set(["viewer", "member"]);
const publicPoolClientId = "client_public_pool"; const publicPoolClientId = "client_public_pool";
const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"]; const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"];
const publicPoolClient = { const publicPoolClient = {
@ -1164,12 +1166,15 @@ export function createControlPlaneStore({ projectRoot }) {
}; };
Object.assign(request, { Object.assign(request, {
requestType: "workflow",
workflowId, workflowId,
workflowName, workflowName,
targetUserId: targetUser.id, targetUserId: targetUser.id,
targetEmail, targetEmail,
targetName: targetUser.name ?? null, targetName: targetUser.name ?? null,
role, role,
requestedServiceRole: null,
currentServiceRole: null,
requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null), requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null),
requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru", requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru",
requesterName, 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) { async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) {
const data = readData(); const data = readData();
const actor = resolveActor(data, identity); const actor = resolveActor(data, identity);
@ -1212,6 +1289,32 @@ export function createControlPlaneStore({ projectRoot }) {
} }
const targetUser = findById(data.users, request.targetUserId, "user"); 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 membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now);
const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now); const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now);
@ -1251,9 +1354,11 @@ export function createControlPlaneStore({ projectRoot }) {
request.updatedAt = now; request.updatedAt = now;
addAuditEvent(data, actor, { addAuditEvent(data, actor, {
action: "Отклонена заявка доступа Engine workflow", action: request.requestType === "service_role" ? "Отклонена заявка повышения роли Engine" : "Отклонена заявка доступа Engine workflow",
objectType: "engine_workflow_access_request", objectType: request.requestType === "service_role" ? "engine_role_access_request" : "engine_workflow_access_request",
objectName: `${request.workflowName}:${request.targetEmail}`, objectName: request.requestType === "service_role"
? `${request.targetName ?? request.targetEmail}:${request.requestedServiceRole ?? request.role}`
: `${request.workflowName}:${request.targetEmail}`,
result: "warning", result: "warning",
details: request.comment ?? null, details: request.comment ?? null,
}); });
@ -1649,6 +1754,7 @@ export function createControlPlaneStore({ projectRoot }) {
const user = findById(data.users, userId, "user"); const user = findById(data.users, userId, "user");
const service = findById(data.services, serviceId, "service"); const service = findById(data.services, serviceId, "service");
const engineService = isEngineService(service); const engineService = isEngineService(service);
const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null;
if (engineService && (value === "admin" || value === "owner")) { if (engineService && (value === "admin" || value === "owner")) {
throw new Error("Для Engine доступны только роли guest, member или блокировка"); throw new Error("Для Engine доступны только роли guest, member или блокировка");
@ -1690,6 +1796,17 @@ export function createControlPlaneStore({ projectRoot }) {
throw new Error(`Unsupported access value: ${value}`); 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, { addAuditEvent(data, actor, {
action: "Обновлён доступ пользователя к сервису", action: "Обновлён доступ пользователя к сервису",
objectType: "grant", objectType: "grant",
@ -1700,7 +1817,7 @@ export function createControlPlaneStore({ projectRoot }) {
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`); markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
await writeData(data); await writeData(data);
return { data }; return { data, engineWorkflowAccessRequest, affectedUserIds: [user.id] };
} }
async function setServiceModuleEntitlement(payload, identity) { async function setServiceModuleEntitlement(payload, identity) {
@ -2011,6 +2128,7 @@ export function createControlPlaneStore({ projectRoot }) {
buildAuthentikSyncPlan, buildAuthentikSyncPlan,
cancelTaskerInviteRequest, cancelTaskerInviteRequest,
createAccessRequest, createAccessRequest,
createEngineRoleAccessRequest,
createEngineWorkflowAccessRequest, createEngineWorkflowAccessRequest,
createTaskerInviteRequest, createTaskerInviteRequest,
createClient, createClient,
@ -2229,6 +2347,7 @@ function normalizeTaskerInviteRequest(payload) {
function normalizeEngineWorkflowAccessRequest(payload) { function normalizeEngineWorkflowAccessRequest(payload) {
if (typeof payload !== "object" || payload === null) return null; if (typeof payload !== "object" || payload === null) return null;
const now = isoNow(); const now = isoNow();
const requestType = pickEnum(payload.requestType ?? payload.type, engineWorkflowAccessRequestTypes, "workflow");
const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : ""; const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : "";
const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : ""; const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : "";
const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email); const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email);
@ -2238,12 +2357,17 @@ function normalizeEngineWorkflowAccessRequest(payload) {
return { return {
id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`), id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`),
requestType,
workflowId, workflowId,
workflowName: optionalString(payload.workflowName, workflowId), workflowName: optionalString(payload.workflowName, workflowId),
targetUserId, targetUserId,
targetEmail, targetEmail,
targetName: nullableStringWithFallback(payload.targetName, null), targetName: nullableStringWithFallback(payload.targetName, null),
role: normalizeEngineWorkflowRole(payload.role), 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), requesterUserId: nullableStringWithFallback(payload.requesterUserId, null),
requesterEmail, requesterEmail,
requesterName: optionalString(payload.requesterName, requesterEmail), requesterName: optionalString(payload.requesterName, requesterEmail),
@ -2671,6 +2795,49 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) {
return grant; 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) { function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) {
const invitedByUserId = request.requesterUserId || actor?.id || null; const invitedByUserId = request.requesterUserId || actor?.id || null;
let membership = data.memberships.find( let membership = data.memberships.find(
@ -3099,6 +3266,19 @@ function normalizeEngineWorkflowRole(value) {
return engineWorkflowRoles.has(normalized) ? normalized : "viewer"; 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) { function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }

View File

@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
import { createRemoteJWKSet, jwtVerify } from "jose"; import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs"; import { createControlPlaneStore } from "./control-plane-store.mjs";
import { createNotificationCoreClient } from "./notification-core-client.mjs";
const serverRoot = dirname(fileURLToPath(import.meta.url)); const serverRoot = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(serverRoot, ".."); const projectRoot = resolve(serverRoot, "..");
@ -34,6 +35,7 @@ const controlPlaneStore = createControlPlaneStore({ projectRoot });
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot); const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot); const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken });
const pendingLogins = new Map(); const pendingLogins = new Map();
const serviceHandoffs = new Map(); const serviceHandoffs = new Map();
const sessions = new Map(); const sessions = new Map();
@ -58,6 +60,7 @@ app.get("/healthz", (_req, res) => {
oidcConfigured: config.oidcConfigured, oidcConfigured: config.oidcConfigured,
authentikApiConfigured: authentikSyncClient.isConfigured(), authentikApiConfigured: authentikSyncClient.isConfigured(),
internalAccessApiConfigured: Boolean(config.internalAccessToken), 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]); 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 }); res.status(201).json({ accessRequest: result.accessRequest });
} catch (error) { } catch (error) {
sendAccessRequestApiError(res, 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) => { app.get("/api/apps", (req, res) => {
const session = getCurrentSession(req); const session = getCurrentSession(req);
@ -514,6 +625,39 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) =>
"tasker.invite-request.created", "tasker.invite-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id] 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) }); 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", "engine.workflow-access-request.created",
result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id] 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 }); 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) => { app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => {
if (!isInternalRequestAuthorized(req)) { if (!isInternalRequestAuthorized(req)) {
res.status(config.internalAccessToken ? 401 : 503).json({ 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] : []); 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)); res.json(scopeAdminMutationResult(req, result));
} catch (error) { } catch (error) {
sendAccessRequestApiError(res, error); sendAccessRequestApiError(res, error);
@ -1368,6 +1662,29 @@ app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAd
try { try {
const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.access-request.rejected"); 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)); res.json(scopeAdminMutationResult(req, result));
} catch (error) { } catch (error) {
sendAccessRequestApiError(res, 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]); 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 })); 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); const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user);
publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]); 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 })); res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult }));
})); }));
@ -1443,6 +1802,50 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
return; 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`, { const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, {
body: { body: {
email: request.targetEmail, email: request.targetEmail,
@ -1466,6 +1869,33 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques
result.engineWorkflowAccessRequest.requesterUserId, result.engineWorkflowAccessRequest.requesterUserId,
result.targetUser.id, 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 })); 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", [ publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [
result.engineWorkflowAccessRequest.requesterUserId, 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)); 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 result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user);
const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], 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 })); res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data }));
})); }));
@ -1785,6 +2284,10 @@ function readConfig() {
process.env.NODEDC_ENGINE_BASE_URL ?? process.env.NODEDC_ENGINE_BASE_URL ??
process.env.ENGINE_BASE_URL ?? process.env.ENGINE_BASE_URL ??
"https://engine.nodedc.ru", "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; 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) { function isLauncherAdmin(groups) {
return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin"); 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 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 { return {
...data, ...data,
@ -3521,7 +4263,12 @@ function scopeControlPlaneData(data, scope) {
invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), invites: data.invites.filter((invite) => clientIds.has(invite.clientId)),
accessRequests: [], accessRequests: [],
taskerInviteRequests: [], 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) => { grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.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)) .filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id))
.map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) })); .map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) }));
const groupIds = new Set(groups.map((group) => group.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 { return {
...data, ...data,
@ -3585,7 +4339,7 @@ function scopeRuntimeControlPlaneData(data, userId) {
accessRequests: [], accessRequests: [],
revokedAccounts: [], revokedAccounts: [],
taskerInviteRequests: [], taskerInviteRequests: [],
engineWorkflowAccessRequests: [], engineWorkflowAccessRequests: relatedEngineRequests,
grants: data.grants.filter((grant) => { grants: data.grants.filter((grant) => {
if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "client") return clientIds.has(grant.targetId);
if (grant.targetType === "group") return groupIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId);

View File

@ -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(/\/+$/, "") : "";
}

View File

@ -62,6 +62,7 @@ import {
} from "../shared/api/authApi"; } from "../shared/api/authApi";
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi"; import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead, type NotificationDelivery } from "../shared/api/notificationsApi";
import type { CreateAccessRequestCommand } from "../entities/access-request/types"; import type { CreateAccessRequestCommand } from "../entities/access-request/types";
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { loadPersistedLauncherData } from "../shared/api/storageApi";
@ -114,6 +115,7 @@ export function LauncherApp() {
const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]); const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState<TaskManagerWorkspaceSummary[]>([]);
const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false);
const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState<string | null>(null);
const [hubNotifications, setHubNotifications] = useState<LauncherNotificationItem[]>([]);
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null)); const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
const runtimeDataRef = useRef(data); const runtimeDataRef = useRef(data);
const runtimeProfileIdRef = useRef(activeProfileId); const runtimeProfileIdRef = useRef(activeProfileId);
@ -155,7 +157,6 @@ export function LauncherApp() {
}; };
}, [authSession, me]); }, [authSession, me]);
const resolvedClientId = me.activeClientId; const resolvedClientId = me.activeClientId;
const notifications = useMemo(() => buildLauncherNotifications(data, runtimeMe), [data, runtimeMe]);
const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin); const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin);
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
const launcherServices = useMemo( const launcherServices = useMemo(
@ -260,6 +261,30 @@ export function LauncherApp() {
return request; return request;
}, []); }, []);
const loadHubNotifications = useCallback(async () => {
if (!authSession?.authenticated) {
setHubNotifications([]);
return;
}
try {
const deliveries = await fetchNotifications("hub");
setHubNotifications(deliveries.map(mapNotificationDeliveryToLauncherItem));
} catch (error: unknown) {
setHubNotifications([]);
console.warn(error instanceof Error ? error.message : "Не удалось загрузить уведомления Hub");
}
}, [authSession?.authenticated]);
const markHubNotificationsRead = useCallback(async () => {
try {
await markAllNotificationsRead("hub");
await loadHubNotifications();
} catch (error: unknown) {
console.warn(error instanceof Error ? error.message : "Не удалось отметить уведомления Hub прочитанными");
}
}, [loadHubNotifications]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@ -402,6 +427,32 @@ export function LauncherApp() {
}; };
}, [authSession?.authenticated, refreshRuntimeState]); }, [authSession?.authenticated, refreshRuntimeState]);
useEffect(() => {
if (!authSession?.authenticated) {
setHubNotifications([]);
return;
}
void loadHubNotifications();
const eventSource = new EventSource("/api/notifications/stream?surface=hub");
const refreshHubNotifications = () => {
void loadHubNotifications();
};
eventSource.addEventListener("notification.ready", refreshHubNotifications);
eventSource.addEventListener("notification.delivery.created", refreshHubNotifications);
eventSource.addEventListener("notification.delivery.read", refreshHubNotifications);
eventSource.addEventListener("notification.deliveries.read-all", refreshHubNotifications);
eventSource.onerror = () => {
console.warn("Hub notification stream disconnected; browser will retry automatically");
};
return () => {
eventSource.close();
};
}, [authSession?.authenticated, loadHubNotifications]);
useEffect(() => { useEffect(() => {
if (!authSession?.authenticated) return; if (!authSession?.authenticated) return;
@ -698,14 +749,14 @@ export function LauncherApp() {
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1] patch: Parameters<typeof approveAdminEngineWorkflowAccessRequest>[1]
) { ) {
applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); return applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
} }
function handleRejectEngineWorkflowAccessRequest( function handleRejectEngineWorkflowAccessRequest(
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1] patch: Parameters<typeof rejectAdminEngineWorkflowAccessRequest>[1]
) { ) {
applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); return applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch));
} }
function handleRetrySync(syncId: string) { function handleRetrySync(syncId: string) {
@ -883,7 +934,20 @@ export function LauncherApp() {
onOpenProfileSettings={() => setProfileSettingsOpen(true)} onOpenProfileSettings={() => setProfileSettingsOpen(true)}
onLogout={handleLogout} onLogout={handleLogout}
brandLinkUrl={data.settings.brand.logoLinkUrl} brandLinkUrl={data.settings.brand.logoLinkUrl}
notifications={notifications} notifications={hubNotifications}
onMarkNotificationsRead={markHubNotificationsRead}
onResolveEngineRoleRequest={async (requestId, action, serviceRole, deliveryId) => {
const outcome = action === "approve"
? await handleApproveEngineWorkflowAccessRequest(requestId, { serviceRole: serviceRole ?? "member" })
: await handleRejectEngineWorkflowAccessRequest(requestId, {});
if (!outcome.ok) return;
if (deliveryId) {
await markNotificationRead(deliveryId);
}
await loadHubNotifications();
}}
/> />
<main className="launcher-main"> <main className="launcher-main">
@ -1518,82 +1582,52 @@ function parseInviteToken(pathname: string) {
return match?.[1] ? decodeURIComponent(match[1]) : null; return match?.[1] ? decodeURIComponent(match[1]) : null;
} }
function buildLauncherNotifications(data: LauncherData, me: ReturnType<typeof buildMe>): LauncherNotificationItem[] { function mapNotificationDeliveryToLauncherItem(delivery: NotificationDelivery): LauncherNotificationItem {
const currentUserId = me.user.id; const requestId = notificationString(delivery.entityId)
const currentEmail = me.user.email.toLowerCase(); ?? notificationString(delivery.meta?.requestId)
const canModerate = me.permissions.canOpenAdmin; ?? notificationString(delivery.eventPayload?.requestId);
const items: LauncherNotificationItem[] = []; const requestedServiceRole = notificationEngineServiceRole(
delivery.meta?.requestedServiceRole ?? delivery.eventPayload?.requestedServiceRole ?? delivery.eventPayload?.serviceRole
);
for (const request of data.accessRequests) { return {
const isOwnRequest = request.email.toLowerCase() === currentEmail; id: delivery.id,
if (!canModerate && !isOwnRequest) continue; kind: notificationKindFromDelivery(delivery),
title: delivery.title,
const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email; description: delivery.body,
items.push({ meta: notificationString(delivery.meta?.meta),
id: `nodedc:${request.id}`, displayStatus: notificationString(delivery.meta?.displayStatus),
kind: "nodedc", status: delivery.status,
title: request.status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён", createdAt: delivery.createdAt,
description: `${applicantName} · ${request.email}`, updatedAt: delivery.updatedAt,
meta: request.company || formatNotificationDate(request.createdAt), actionKind: delivery.actionType === "engine.role_upgrade.review" ? "engine-role-request" : undefined,
status: request.status, requestId: delivery.actionType === "engine.role_upgrade.review" ? requestId : undefined,
createdAt: request.createdAt, requestedServiceRole,
}); };
}
for (const request of data.taskerInviteRequests) {
const isRelated =
request.inviterUserId === currentUserId ||
request.inviterEmail.toLowerCase() === currentEmail ||
request.inviteeEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue;
items.push({
id: `tasker:${request.id}`,
kind: "operational-core",
title: request.status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена",
description: `${request.workspaceName} · ${request.inviteeEmail}`,
meta: request.inviterName || formatNotificationDate(request.createdAt),
status: request.status,
createdAt: request.createdAt,
});
}
for (const request of data.engineWorkflowAccessRequests) {
const isRelated =
request.requesterUserId === currentUserId ||
request.targetUserId === currentUserId ||
request.requesterEmail.toLowerCase() === currentEmail ||
request.targetEmail.toLowerCase() === currentEmail;
if (!canModerate && !isRelated) continue;
items.push({
id: `engine:${request.id}`,
kind: "engine",
title: request.status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена",
description: `${request.workflowName} · ${request.targetEmail}`,
meta: request.requesterName || formatNotificationDate(request.createdAt),
status: request.status,
createdAt: request.createdAt,
});
}
return items.sort((left, right) => {
if (left.status === "new" && right.status !== "new") return -1;
if (left.status !== "new" && right.status === "new") return 1;
return Date.parse(right.createdAt) - Date.parse(left.createdAt);
});
} }
function formatNotificationDate(value: string) { function notificationKindFromDelivery(delivery: NotificationDelivery): LauncherNotificationItem["kind"] {
const timestamp = Date.parse(value); const source = String(delivery.sourceService || "").toLowerCase();
if (!Number.isFinite(timestamp)) return value; const eventType = String(delivery.eventType || "").toLowerCase();
return new Intl.DateTimeFormat("ru-RU", { const entityType = String(delivery.entityType || "").toLowerCase();
day: "2-digit",
month: "2-digit", if (source === "tasker" || source === "operational-core" || eventType.startsWith("tasker.") || entityType.startsWith("tasker")) {
year: "numeric", return "operational-core";
hour: "2-digit", }
minute: "2-digit",
}).format(new Date(timestamp)); if (source === "engine" || eventType.startsWith("engine.") || entityType.startsWith("engine_")) {
return "engine";
}
return "nodedc";
}
function notificationString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function notificationEngineServiceRole(value: unknown): "viewer" | "member" | null {
return value === "viewer" || value === "member" ? value : null;
} }
function isAccessRequestPath(pathname: string) { function isAccessRequestPath(pathname: string) {

View File

@ -1,14 +1,19 @@
export type EngineWorkflowRole = "viewer" | "editor" | "admin"; export type EngineWorkflowRole = "viewer" | "editor" | "admin";
export type EngineServiceRole = "viewer" | "member";
export type EngineWorkflowAccessRequestType = "workflow" | "service_role";
export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled"; export type EngineWorkflowAccessRequestStatus = "new" | "approved" | "rejected" | "cancelled";
export interface EngineWorkflowAccessRequest { export interface EngineWorkflowAccessRequest {
id: string; id: string;
requestType?: EngineWorkflowAccessRequestType;
workflowId: string; workflowId: string;
workflowName: string; workflowName: string;
targetUserId: string; targetUserId: string;
targetEmail: string; targetEmail: string;
targetName?: string | null; targetName?: string | null;
role: EngineWorkflowRole; role: EngineWorkflowRole;
requestedServiceRole?: EngineServiceRole | null;
currentServiceRole?: EngineServiceRole | null;
requesterUserId?: string | null; requesterUserId?: string | null;
requesterEmail: string; requesterEmail: string;
requesterName: string; requesterName: string;

View File

@ -407,7 +407,7 @@ export async function rejectAdminTaskerInviteRequest(
export async function approveAdminEngineWorkflowAccessRequest( export async function approveAdminEngineWorkflowAccessRequest(
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
payload: Partial<Pick<EngineWorkflowAccessRequest, "comment">> = {} payload: Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & { serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"] } = {}
): Promise<EngineWorkflowAccessRequestMutationResult> { ): Promise<EngineWorkflowAccessRequestMutationResult> {
return requestJson<EngineWorkflowAccessRequestMutationResult>( return requestJson<EngineWorkflowAccessRequestMutationResult>(
`/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`, `/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`,

View File

@ -0,0 +1,53 @@
export type NotificationDeliveryStatus = "unread" | "read" | "archived";
export type NotificationSurface = "hub" | "engine" | "operational-core";
export interface NotificationDelivery {
id: string;
eventId: string;
eventType?: string | null;
sourceService?: string | null;
recipientUserId?: string | null;
recipientEmail?: string | null;
surface: NotificationSurface;
title: string;
body: string;
meta?: Record<string, unknown>;
status: NotificationDeliveryStatus;
unread?: boolean;
actionable?: boolean;
actionType?: string | null;
actionUrl?: string | null;
entityType?: string | null;
entityId?: string | null;
eventPayload?: Record<string, unknown>;
createdAt: string;
updatedAt?: string | null;
readAt?: string | null;
}
export async function fetchNotifications(surface: NotificationSurface) {
const response = await fetch(`/api/notifications?surface=${encodeURIComponent(surface)}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`notifications_failed_${response.status}`);
const payload = await response.json();
return Array.isArray(payload?.deliveries) ? payload.deliveries as NotificationDelivery[] : [];
}
export async function markAllNotificationsRead(surface: NotificationSurface) {
const response = await fetch(`/api/notifications/read-all?surface=${encodeURIComponent(surface)}`, {
method: "POST",
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`notifications_read_all_failed_${response.status}`);
return response.json();
}
export async function markNotificationRead(deliveryId: string) {
const response = await fetch(`/api/notifications/${encodeURIComponent(deliveryId)}/read`, {
method: "POST",
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error(`notification_read_failed_${response.status}`);
return response.json();
}

View File

@ -4678,6 +4678,12 @@ code {
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
} }
.nodedc-notifications-head__actions {
display: flex;
align-items: center;
gap: 0.55rem;
}
.nodedc-notifications-head h2 { .nodedc-notifications-head h2 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
@ -4705,6 +4711,32 @@ code {
padding: 0; padding: 0;
} }
.nodedc-notifications-only-new {
min-height: 2.35rem;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
padding: 0 1rem;
font-size: 0.76rem;
font-weight: 820;
}
.nodedc-notifications-only-new:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
.nodedc-notifications-only-new[data-active="true"] {
background: #ffffff;
color: #101014;
}
.nodedc-notifications-only-new[data-active="true"]:hover {
background: #ffffff;
color: #101014;
}
.nodedc-notifications-close:hover { .nodedc-notifications-close:hover {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
color: var(--text-primary); color: var(--text-primary);
@ -4759,14 +4791,21 @@ code {
.nodedc-notification-card { .nodedc-notification-card {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.45rem; gap: 0.45rem;
border-radius: 1.05rem; border-radius: 1.05rem;
background: rgba(255, 255, 255, 0.055); background:
linear-gradient(135deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.044)),
rgba(20, 20, 24, 0.86);
padding: 0.95rem 1rem; padding: 0.95rem 1rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
} }
.nodedc-notification-card[data-status="new"] { .nodedc-notification-card[data-status="new"] {
background: rgba(195, 255, 102, 0.1); background:
linear-gradient(135deg, rgb(var(--nodedc-accent-rgb) / 0.22), rgb(var(--nodedc-accent-rgb) / 0.1)),
rgba(255, 255, 255, 0.06);
} }
.nodedc-notification-card__kicker, .nodedc-notification-card__kicker,
@ -4790,10 +4829,67 @@ code {
.nodedc-notification-card__foot { .nodedc-notification-card__foot {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
gap: 1rem; gap: 1rem;
} }
.nodedc-notification-card__content {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.nodedc-notification-card__actions {
display: flex;
align-items: center;
gap: 0.45rem;
}
.nodedc-notification-card__action-button,
.nodedc-notification-card__role-select {
min-height: 2.25rem;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.085);
color: var(--text-primary);
padding: 0 0.8rem;
font-size: 0.72rem;
font-weight: 780;
outline: none;
}
.nodedc-notification-card__action-button:hover:not(:disabled),
.nodedc-notification-card__role-select:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.14);
}
.nodedc-notification-card__action-button:disabled,
.nodedc-notification-card__role-select:disabled {
opacity: 0.58;
cursor: progress;
}
.nodedc-notification-card__role-select-wrap {
display: inline-flex;
}
.nodedc-notification-card__role-select {
min-width: 10.2rem;
}
.nodedc-notification-card__role-select .nodedc-ui-select-trigger__text {
justify-content: flex-start;
}
.nodedc-notification-card__role-menu {
z-index: 15050;
border-radius: 0.95rem;
background: rgba(28, 28, 32, 0.96);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 1.4rem 3rem rgba(0, 0, 0, 0.38);
}
.nodedc-ui-profile-card__cover { .nodedc-ui-profile-card__cover {
position: relative; position: relative;
display: grid; display: grid;

View File

@ -92,6 +92,10 @@ type AdminSection =
| "company"; | "company";
type AdminOverlayMode = "admin" | "platform"; type AdminOverlayMode = "admin" | "platform";
type AdminMutationOutcome = { ok: true } | { ok: false; message: string }; type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
type EngineWorkflowAccessRequestDecisionPatch =
Partial<Pick<EngineWorkflowAccessRequest, "comment" | "requestedServiceRole">> & {
serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"];
};
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">; type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
@ -255,11 +259,11 @@ export function AdminOverlay({
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onApproveEngineWorkflowAccessRequest: ( onApproveEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
onRejectEngineWorkflowAccessRequest: ( onRejectEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
onRetrySync: (syncId: string) => void; onRetrySync: (syncId: string) => void;
onCreateClient: () => void; onCreateClient: () => void;
@ -3847,11 +3851,11 @@ function InvitesSection({
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onApproveEngineWorkflowAccessRequest: ( onApproveEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
onRejectEngineWorkflowAccessRequest: ( onRejectEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
}) { }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -4153,11 +4157,11 @@ function AccessRequestsPanel({
onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void; onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial<Pick<TaskerInviteRequest, "comment">>) => void;
onApproveEngineWorkflowAccessRequest: ( onApproveEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
onRejectEngineWorkflowAccessRequest: ( onRejectEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
}) { }) {
const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)); const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
@ -4472,20 +4476,20 @@ function EngineWorkflowAccessRequestsPanel({
requests: EngineWorkflowAccessRequest[]; requests: EngineWorkflowAccessRequest[];
onApproveEngineWorkflowAccessRequest: ( onApproveEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
onRejectEngineWorkflowAccessRequest: ( onRejectEngineWorkflowAccessRequest: (
engineWorkflowAccessRequestId: string, engineWorkflowAccessRequestId: string,
patch: Partial<Pick<EngineWorkflowAccessRequest, "comment">> patch: EngineWorkflowAccessRequestDecisionPatch
) => void; ) => void;
}) { }) {
return ( return (
<GlassSurface className="table-shell"> <GlassSurface className="table-shell">
<div className="table-toolbar"> <div className="table-toolbar">
<div> <div>
<h3>NODE.DC Engine: запросы доступа к workflow</h3> <h3>NODE.DC Engine: запросы доступа</h3>
<p className="admin-helper-note"> <p className="admin-helper-note">
Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine. Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.
</p> </p>
</div> </div>
</div> </div>
@ -4509,12 +4513,14 @@ function EngineWorkflowAccessRequestsPanel({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{requests.map((request) => ( {requests.map((request) => {
const isServiceRoleRequest = request.requestType === "service_role";
return (
<tr key={request.id}> <tr key={request.id}>
<td> <td>
<div className="access-request-applicant"> <div className="access-request-applicant">
<strong>{request.workflowName}</strong> <strong>{isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName}</strong>
<small>{request.workflowId}</small> <small>{isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}</small>
</div> </div>
</td> </td>
<td> <td>
@ -4529,7 +4535,7 @@ function EngineWorkflowAccessRequestsPanel({
<small>{request.requesterEmail}</small> <small>{request.requesterEmail}</small>
</div> </div>
</td> </td>
<td>{engineWorkflowRoleLabel(request.role)}</td> <td>{isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)}</td>
<td> <td>
<AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} /> <AdminStatusPill value={request.status} options={engineWorkflowAccessRequestStatusOptions} />
</td> </td>
@ -4540,7 +4546,10 @@ function EngineWorkflowAccessRequestsPanel({
aria-label={`Подтвердить доступ Engine ${request.targetEmail}`} aria-label={`Подтвердить доступ Engine ${request.targetEmail}`}
className="access-request-decision-button access-request-decision-button--accept" className="access-request-decision-button access-request-decision-button--accept"
type="button" type="button"
onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})} onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest
? { serviceRole: request.requestedServiceRole ?? "member" }
: {}
)}
> >
<Check size={16} strokeWidth={2.6} /> <Check size={16} strokeWidth={2.6} />
</button> </button>
@ -4558,7 +4567,8 @@ function EngineWorkflowAccessRequestsPanel({
)} )}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -5020,6 +5030,12 @@ function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): str
return labels[role] ?? role; return labels[role] ?? role;
} }
function engineServiceRoleLabel(role?: EngineWorkflowAccessRequest["requestedServiceRole"] | null): string {
if (role === "member") return "Участник";
if (role === "viewer") return "Гость";
return "не назначена";
}
function sectionTitle(section: AdminSection): string { function sectionTitle(section: AdminSection): string {
const labels: Record<AdminSection, string> = { const labels: Record<AdminSection, string> = {
overview: "Обзор", overview: "Обзор",

View File

@ -5,11 +5,11 @@ import type { Client } from "../../entities/client/types";
import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants";
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi"; import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
import { initials } from "../../shared/lib/format"; import { initials } from "../../shared/lib/format";
import { NodeDcProfileMenu, NodeDcSelect } from "../../shared/nodedc-ui"; import { NodeDcProfileMenu, NodeDcSelect, type NodeDcSelectOption } from "../../shared/nodedc-ui";
export type LauncherAdminMode = "admin" | "platform"; export type LauncherAdminMode = "admin" | "platform";
export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine"; export type LauncherNotificationKind = "nodedc" | "operational-core" | "engine";
export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled"; export type LauncherNotificationStatus = "unread" | "read" | "archived";
export interface LauncherNotificationItem { export interface LauncherNotificationItem {
id: string; id: string;
@ -17,10 +17,23 @@ export interface LauncherNotificationItem {
title: string; title: string;
description: string; description: string;
meta?: string; meta?: string;
displayStatus?: string;
status: LauncherNotificationStatus; status: LauncherNotificationStatus;
createdAt: string; createdAt: string;
updatedAt?: string | null;
actionKind?: "engine-role-request";
requestId?: string;
requestedServiceRole?: "viewer" | "member" | null;
} }
type EngineRoleActionValue = "choose" | "viewer" | "member";
const engineRoleActionOptions: Array<NodeDcSelectOption<EngineRoleActionValue>> = [
{ value: "choose", label: "Изменить роль" },
{ value: "viewer", label: "Гость" },
{ value: "member", label: "Участник" },
];
export function TopBar({ export function TopBar({
me, me,
clients, clients,
@ -38,6 +51,8 @@ export function TopBar({
onLogout, onLogout,
brandLinkUrl = "/", brandLinkUrl = "/",
notifications = [], notifications = [],
onMarkNotificationsRead,
onResolveEngineRoleRequest,
}: { }: {
me: MeResponse; me: MeResponse;
clients: Client[]; clients: Client[];
@ -55,9 +70,13 @@ export function TopBar({
onLogout?: () => void; onLogout?: () => void;
brandLinkUrl?: string; brandLinkUrl?: string;
notifications?: LauncherNotificationItem[]; notifications?: LauncherNotificationItem[];
onMarkNotificationsRead?: () => void | Promise<unknown>;
onResolveEngineRoleRequest?: (requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) => void | Promise<unknown>;
}) { }) {
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all"); const [notificationFilter, setNotificationFilter] = useState<LauncherNotificationKind | "all">("all");
const [notificationsOnlyNew, setNotificationsOnlyNew] = useState(false);
const [resolvingNotificationIds, setResolvingNotificationIds] = useState<Record<string, boolean>>({});
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
const clientsWithPublicPool = [ const clientsWithPublicPool = [
...clients, ...clients,
@ -72,25 +91,60 @@ export function TopBar({
})); }));
const canOpenPlatform = me.launcherRole === "root_admin"; const canOpenPlatform = me.launcherRole === "root_admin";
const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform; const showLauncherNavigation = me.permissions.canOpenAdmin || canOpenPlatform;
const unreadCount = notifications.filter((notification) => notification.status === "new").length; const unreadCount = notifications.filter((notification) => notification.status === "unread").length;
const visibleNotifications = useMemo( const visibleNotifications = useMemo(
() => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter), () => notifications.filter((notification) => {
[notificationFilter, notifications] if (notificationsOnlyNew && notification.status !== "unread") return false;
return notificationFilter === "all" || notification.kind === notificationFilter;
}),
[notificationFilter, notifications, notificationsOnlyNew]
); );
function markNotificationsSeen() {
if (unreadCount <= 0) return;
void Promise.resolve(onMarkNotificationsRead?.());
}
function closeNotifications() {
markNotificationsSeen();
setNotificationsOpen(false);
}
function resolveEngineRoleNotification(requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) {
if (!onResolveEngineRoleRequest || resolvingNotificationIds[requestId]) return;
setResolvingNotificationIds((current) => ({ ...current, [requestId]: true }));
Promise.resolve(onResolveEngineRoleRequest(requestId, action, serviceRole, deliveryId))
.finally(() => {
setResolvingNotificationIds((current) => {
const { [requestId]: _done, ...rest } = current;
return rest;
});
});
}
const notificationsModal = notificationsOpen && typeof document !== "undefined" const notificationsModal = notificationsOpen && typeof document !== "undefined"
? createPortal( ? createPortal(
<div className="nodedc-notifications-overlay" onMouseDown={() => setNotificationsOpen(false)}> <div className="nodedc-notifications-overlay" onMouseDown={closeNotifications}>
<section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}> <section className="nodedc-notifications-modal" aria-modal="true" role="dialog" onMouseDown={(event) => event.stopPropagation()}>
<div className="nodedc-notifications-head"> <div className="nodedc-notifications-head">
<div> <div>
<h2>Уведомления</h2> <h2>Уведомления</h2>
<span>NODE DC</span> <span>NODE DC</span>
</div> </div>
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={() => setNotificationsOpen(false)}> <div className="nodedc-notifications-head__actions">
<button
className="nodedc-notifications-only-new"
type="button"
data-active={notificationsOnlyNew ? "true" : "false"}
onClick={() => setNotificationsOnlyNew((current) => !current)}
>
Только новые
</button>
<button className="nodedc-notifications-close" type="button" aria-label="Закрыть" onClick={closeNotifications}>
<X size={18} strokeWidth={1.8} /> <X size={18} strokeWidth={1.8} />
</button> </button>
</div> </div>
</div>
<div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений"> <div className="nodedc-notifications-tabs" role="tablist" aria-label="Фильтр уведомлений">
<NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}> <NotificationFilterButton active={notificationFilter === "all"} onClick={() => setNotificationFilter("all")}>
@ -114,17 +168,59 @@ export function TopBar({
<span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span> <span>История заявок NODE.DC, Operational Core и Engine появится здесь.</span>
</div> </div>
) : ( ) : (
visibleNotifications.map((notification) => ( visibleNotifications.map((notification) => {
<article className="nodedc-notification-card" data-status={notification.status} key={notification.id}> const unread = notification.status === "unread";
return (
<article className="nodedc-notification-card" data-status={unread ? "new" : notification.status} key={notification.id}>
<div className="nodedc-notification-card__content">
<div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div> <div className="nodedc-notification-card__kicker">{notificationKindLabel(notification.kind)}</div>
<div className="nodedc-notification-card__title">{notification.title}</div> <div className="nodedc-notification-card__title">{notification.title}</div>
<div className="nodedc-notification-card__description">{notification.description}</div> <div className="nodedc-notification-card__description">{notification.description}</div>
<div className="nodedc-notification-card__foot"> <div className="nodedc-notification-card__foot">
<span>{notificationStatusLabel(notification.status)}</span> <span>{notification.displayStatus || notificationStatusLabel(notification.status)}</span>
{notification.meta ? <span>{notification.meta}</span> : null} {notification.meta ? <span>{notification.meta}</span> : null}
</div> </div>
</div>
{notification.actionKind === "engine-role-request" &&
notification.requestId &&
notification.status === "unread" &&
onResolveEngineRoleRequest ? (
<div className="nodedc-notification-card__actions">
{(() => {
const requestBusy = Boolean(resolvingNotificationIds[notification.requestId]);
return (
<>
<button
className="nodedc-notification-card__action-button"
type="button"
disabled={requestBusy}
onClick={() => resolveEngineRoleNotification(notification.requestId!, "reject", undefined, notification.id)}
>
Отклонить
</button>
<NodeDcSelect<EngineRoleActionValue>
className="nodedc-notification-card__role-select-wrap"
triggerClassName="nodedc-notification-card__role-select"
menuClassName="nodedc-notification-card__role-menu"
value="choose"
options={engineRoleActionOptions}
label="Изменить роль Engine"
minMenuWidth={166}
disabled={requestBusy}
onChange={(value) => {
if (value === "viewer" || value === "member") {
resolveEngineRoleNotification(notification.requestId!, "approve", value, notification.id);
}
}}
/>
</>
);
})()}
</div>
) : null}
</article> </article>
)) );
})
)} )}
</div> </div>
</section> </section>
@ -284,10 +380,9 @@ function notificationKindLabel(kind: LauncherNotificationKind): string {
function notificationStatusLabel(status: LauncherNotificationStatus): string { function notificationStatusLabel(status: LauncherNotificationStatus): string {
const labels: Record<LauncherNotificationStatus, string> = { const labels: Record<LauncherNotificationStatus, string> = {
new: "Входящее", unread: "Входящее",
approved: "Подтверждено", read: "Прочитано",
rejected: "Отклонено", archived: "В архиве",
cancelled: "Отменено",
}; };
return labels[status]; return labels[status];