From 8f56fd2ab66c7e5d2def4d8f047ef60f3b315f8d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 26 May 2026 18:01:39 +0300 Subject: [PATCH] feat: integrate hub notifications with core --- server/control-plane-store.mjs | 188 ++++- server/dev-server.mjs | 760 +++++++++++++++++- server/notification-core-client.mjs | 125 +++ src/app/LauncherApp.tsx | 188 +++-- .../engine-workflow-access-request/types.ts | 5 + src/shared/api/adminApi.ts | 2 +- src/shared/api/notificationsApi.ts | 53 ++ src/styles/globals.css | 102 ++- src/widgets/admin-overlay/AdminOverlay.tsx | 48 +- src/widgets/top-bar/TopBar.tsx | 139 +++- 10 files changed, 1484 insertions(+), 126 deletions(-) create mode 100644 server/notification-core-client.mjs create mode 100644 src/shared/api/notificationsApi.ts diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index af9a2b6..4f055e3 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -38,7 +38,9 @@ const accessRequestStatuses = new Set(["new", "approved", "rejected"]); const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); const taskManagerInviteRoles = new Set(["guest", "member", "admin"]); const engineWorkflowAccessRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]); +const engineWorkflowAccessRequestTypes = new Set(["workflow", "service_role"]); const engineWorkflowRoles = new Set(["viewer", "editor", "admin"]); +const engineServiceRoles = new Set(["viewer", "member"]); const publicPoolClientId = "client_public_pool"; const engineAuthentikGroups = ["nodedc_admin", "nodedc_editor", "nodedc_viewer"]; const publicPoolClient = { @@ -1164,12 +1166,15 @@ export function createControlPlaneStore({ projectRoot }) { }; Object.assign(request, { + requestType: "workflow", workflowId, workflowName, targetUserId: targetUser.id, targetEmail, targetName: targetUser.name ?? null, role, + requestedServiceRole: null, + currentServiceRole: null, requesterUserId: requesterUser?.id ?? nullableStringWithFallback(payload?.requesterUserId, null), requesterEmail: requesterEmail || normalizeEmail(payload?.requesterEmail) || actor.email || "engine@nodedc.ru", requesterName, @@ -1201,6 +1206,78 @@ export function createControlPlaneStore({ projectRoot }) { }; } + async function createEngineRoleAccessRequest(payload, identity = { name: "NODE.DC Engine", source: "engine" }) { + const data = readData(); + const now = isoNow(); + const actor = resolveActor(data, identity); + const requesterEmail = normalizeEmail(payload?.requesterEmail ?? actor.email ?? payload?.email ?? ""); + const targetUser = + (payload?.requesterUserId ? data.users.find((user) => user.id === payload.requesterUserId) : null) ?? + (requesterEmail ? data.users.find((user) => normalizeEmail(user.email) === requesterEmail) : null) ?? + null; + + if (!targetUser || targetUser.globalStatus !== "active") { + throw new Error("engine_target_user_not_found"); + } + + const requestedServiceRole = normalizeEngineServiceRole(payload?.requestedServiceRole ?? payload?.requestedRole ?? "member"); + const currentServiceRole = normalizeNullableEngineServiceRole(payload?.currentServiceRole ?? payload?.currentRole); + const role = requestedServiceRole === "viewer" ? "viewer" : "editor"; + const existingRequest = data.engineWorkflowAccessRequests.find( + (request) => + request.status === "new" && + request.requestType === "service_role" && + request.targetUserId === targetUser.id + ); + const request = + existingRequest ?? + { + id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_access_request", targetUser.email), + createdAt: now, + }; + + Object.assign(request, { + requestType: "service_role", + workflowId: "engine:service-role", + workflowName: "NODE.DC Engine", + targetUserId: targetUser.id, + targetEmail: normalizeEmail(targetUser.email), + targetName: targetUser.name ?? null, + role, + requestedServiceRole, + currentServiceRole, + requesterUserId: targetUser.id, + requesterEmail: normalizeEmail(targetUser.email), + requesterName: optionalString(payload?.requesterName, targetUser.name ?? targetUser.email), + status: "new", + reviewedByUserId: null, + reviewedAt: null, + engineAppliedAt: null, + comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null), + updatedAt: now, + }); + + if (!existingRequest) { + data.engineWorkflowAccessRequests.push(request); + } + + addAuditEvent(data, actor, { + action: existingRequest ? "Обновлена заявка повышения роли Engine" : "Создана заявка повышения роли Engine", + objectType: "engine_role_access_request", + objectName: `${request.targetName ?? request.targetEmail}:${requestedServiceRole}`, + result: "success", + details: `Current role: ${currentServiceRole ?? "unknown"}; requested role: ${requestedServiceRole}`, + }); + + await writeData(data); + return { + engineWorkflowAccessRequest: request, + affectedUserIds: [targetUser.id, "user_root"].filter(Boolean), + targetUser, + data, + }; + } + async function approveEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, payload, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -1212,6 +1289,32 @@ export function createControlPlaneStore({ projectRoot }) { } const targetUser = findById(data.users, request.targetUserId, "user"); + if (request.requestType === "service_role") { + const serviceRole = normalizeEngineServiceRole(payload?.serviceRole ?? payload?.requestedServiceRole ?? request.requestedServiceRole); + request.requestedServiceRole = serviceRole; + request.role = serviceRole === "viewer" ? "viewer" : "editor"; + const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now); + const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now); + + request.status = "approved"; + request.reviewedByUserId = actor.id; + request.reviewedAt = now; + request.engineAppliedAt = nullableStringWithFallback(payload?.engineAppliedAt, now); + request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null); + request.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Подтверждена заявка повышения роли Engine", + objectType: "engine_role_access_request", + objectName: `${request.targetName ?? request.targetEmail}:${serviceRole}`, + result: "success", + details: `Engine role: ${serviceRole}`, + }); + + await writeData(data); + return { engineWorkflowAccessRequest: request, grant, membership, targetUser, data }; + } + const membership = ensureEngineWorkflowPublicPoolMembership(data, targetUser, request, actor, now); const grant = ensureEngineWorkflowServiceAccess(data, targetUser, request.role, now); @@ -1251,9 +1354,11 @@ export function createControlPlaneStore({ projectRoot }) { request.updatedAt = now; addAuditEvent(data, actor, { - action: "Отклонена заявка доступа Engine workflow", - objectType: "engine_workflow_access_request", - objectName: `${request.workflowName}:${request.targetEmail}`, + action: request.requestType === "service_role" ? "Отклонена заявка повышения роли Engine" : "Отклонена заявка доступа Engine workflow", + objectType: request.requestType === "service_role" ? "engine_role_access_request" : "engine_workflow_access_request", + objectName: request.requestType === "service_role" + ? `${request.targetName ?? request.targetEmail}:${request.requestedServiceRole ?? request.role}` + : `${request.workflowName}:${request.targetEmail}`, result: "warning", details: request.comment ?? null, }); @@ -1649,6 +1754,7 @@ export function createControlPlaneStore({ projectRoot }) { const user = findById(data.users, userId, "user"); const service = findById(data.services, serviceId, "service"); const engineService = isEngineService(service); + const previousEngineServiceRole = engineService ? resolveDirectEngineServiceRole(data, serviceId, userId) : null; if (engineService && (value === "admin" || value === "owner")) { throw new Error("Для Engine доступны только роли guest, member или блокировка"); @@ -1690,6 +1796,17 @@ export function createControlPlaneStore({ projectRoot }) { throw new Error(`Unsupported access value: ${value}`); } + let engineWorkflowAccessRequest = null; + if (engineService && (value === "viewer" || value === "member") && previousEngineServiceRole !== value) { + engineWorkflowAccessRequest = appendEngineRoleChangeNotification(data, { + actor, + currentServiceRole: previousEngineServiceRole, + serviceRole: value, + targetUser: user, + now, + }); + } + addAuditEvent(data, actor, { action: "Обновлён доступ пользователя к сервису", objectType: "grant", @@ -1700,7 +1817,7 @@ export function createControlPlaneStore({ projectRoot }) { markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`); await writeData(data); - return { data }; + return { data, engineWorkflowAccessRequest, affectedUserIds: [user.id] }; } async function setServiceModuleEntitlement(payload, identity) { @@ -2011,6 +2128,7 @@ export function createControlPlaneStore({ projectRoot }) { buildAuthentikSyncPlan, cancelTaskerInviteRequest, createAccessRequest, + createEngineRoleAccessRequest, createEngineWorkflowAccessRequest, createTaskerInviteRequest, createClient, @@ -2229,6 +2347,7 @@ function normalizeTaskerInviteRequest(payload) { function normalizeEngineWorkflowAccessRequest(payload) { if (typeof payload !== "object" || payload === null) return null; const now = isoNow(); + const requestType = pickEnum(payload.requestType ?? payload.type, engineWorkflowAccessRequestTypes, "workflow"); const workflowId = typeof payload.workflowId === "string" ? payload.workflowId.trim() : ""; const targetUserId = typeof payload.targetUserId === "string" ? payload.targetUserId.trim() : ""; const targetEmail = normalizeEmail(payload.targetEmail ?? payload.email); @@ -2238,12 +2357,17 @@ function normalizeEngineWorkflowAccessRequest(payload) { return { id: optionalString(payload.id, `engine_workflow_access_request_${slugify(`${workflowId}-${targetEmail}`)}`), + requestType, workflowId, workflowName: optionalString(payload.workflowName, workflowId), targetUserId, targetEmail, targetName: nullableStringWithFallback(payload.targetName, null), role: normalizeEngineWorkflowRole(payload.role), + requestedServiceRole: requestType === "service_role" + ? normalizeEngineServiceRole(payload.requestedServiceRole ?? payload.requestedRole ?? payload.serviceRole) + : nullableStringWithFallback(payload.requestedServiceRole, null), + currentServiceRole: normalizeNullableEngineServiceRole(payload.currentServiceRole ?? payload.currentRole), requesterUserId: nullableStringWithFallback(payload.requesterUserId, null), requesterEmail, requesterName: optionalString(payload.requesterName, requesterEmail), @@ -2671,6 +2795,49 @@ function ensureEngineWorkflowServiceAccess(data, user, workflowRole, now) { return grant; } +function resolveDirectEngineServiceRole(data, serviceId, userId) { + const denied = data.exceptions.some( + (exception) => exception.serviceId === serviceId && exception.userId === userId && exception.type === "deny" + ); + if (denied) return null; + + const grant = data.grants.find( + (candidate) => candidate.serviceId === serviceId && candidate.targetType === "user" && candidate.targetId === userId + ); + if (!grant || grant.status !== "active") return null; + if (grant.appRole === "viewer") return "viewer"; + if (grant.appRole === "member") return "member"; + return null; +} + +function appendEngineRoleChangeNotification(data, { actor, currentServiceRole, serviceRole, targetUser, now }) { + const requestedServiceRole = normalizeEngineServiceRole(serviceRole); + const request = { + id: uniqueId(data.engineWorkflowAccessRequests, "engine_role_change", `${targetUser.email}-${now}`), + requestType: "service_role", + workflowId: "engine:service-role", + workflowName: "NODE.DC Engine", + targetUserId: targetUser.id, + targetEmail: normalizeEmail(targetUser.email), + targetName: targetUser.name ?? null, + role: requestedServiceRole === "viewer" ? "viewer" : "editor", + requestedServiceRole, + currentServiceRole: normalizeNullableEngineServiceRole(currentServiceRole), + requesterUserId: actor.id ?? null, + requesterEmail: normalizeEmail(actor.email) || "admin@nodedc.ru", + requesterName: actor.name || actor.email || "Администратор", + status: "approved", + reviewedByUserId: actor.id ?? null, + reviewedAt: now, + engineAppliedAt: now, + comment: "admin_role_changed", + createdAt: now, + updatedAt: now, + }; + data.engineWorkflowAccessRequests.push(request); + return request; +} + function ensureEngineWorkflowPublicPoolMembership(data, user, request, actor, now) { const invitedByUserId = request.requesterUserId || actor?.id || null; let membership = data.memberships.find( @@ -3099,6 +3266,19 @@ function normalizeEngineWorkflowRole(value) { return engineWorkflowRoles.has(normalized) ? normalized : "viewer"; } +function normalizeEngineServiceRole(value) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "guest" || normalized === "viewer" || normalized === "read_only" || normalized === "readonly") return "viewer"; + if (normalized === "participant" || normalized === "editor" || normalized === "member") return "member"; + return engineServiceRoles.has(normalized) ? normalized : "member"; +} + +function normalizeNullableEngineServiceRole(value) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized || normalized === "null" || normalized === "undefined") return null; + return normalizeEngineServiceRole(normalized); +} + function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 9226e0a..225fdfc 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; import { createControlPlaneStore } from "./control-plane-store.mjs"; +import { createNotificationCoreClient } from "./notification-core-client.mjs"; const serverRoot = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(serverRoot, ".."); @@ -34,6 +35,7 @@ const controlPlaneStore = createControlPlaneStore({ projectRoot }); const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot); const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); +const notificationCoreClient = createNotificationCoreClient({ baseUrl: config.notificationCoreBaseUrl, token: config.internalAccessToken }); const pendingLogins = new Map(); const serviceHandoffs = new Map(); const sessions = new Map(); @@ -58,6 +60,7 @@ app.get("/healthz", (_req, res) => { oidcConfigured: config.oidcConfigured, authentikApiConfigured: authentikSyncClient.isConfigured(), internalAccessApiConfigured: Boolean(config.internalAccessToken), + notificationCoreConfigured: notificationCoreClient.isConfigured(), }); }); @@ -99,6 +102,28 @@ app.post("/api/access-requests", asyncRoute(async (req, res) => { }); publishControlPlaneEvent("access-request.created", [result.user.id]); + publishNotificationEvent({ + type: "nodedc.access_request.created", + sourceService: "launcher", + actor: { userId: result.user.id, email: result.user.email }, + subject: { type: "access_request", id: result.accessRequest.id }, + payload: { + accessRequestId: result.accessRequest.id, + userId: result.user.id, + email: result.user.email, + status: result.accessRequest.status, + }, + idempotencyKey: `launcher:access-request:${result.accessRequest.id}:created:${result.accessRequest.updatedAt}`, + deliveries: [ + rootAdminNotificationDelivery(result.data, { + ...buildAccessRequestHubNotification(result.accessRequest), + actionable: true, + actionType: "nodedc.access_request.review", + entityType: "access_request", + entityId: result.accessRequest.id, + }), + ].filter(Boolean), + }); res.status(201).json({ accessRequest: result.accessRequest }); } catch (error) { sendAccessRequestApiError(res, error); @@ -268,6 +293,92 @@ app.get("/api/me", (req, res) => { }); }); +app.get("/api/notifications", requireSession, asyncRoute(async (req, res) => { + if (!notificationCoreClient.isConfigured()) { + res.status(503).json({ ok: false, error: "notification_core_not_configured" }); + return; + } + + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const user = findLauncherUser(data, actor.id); + const payload = await notificationCoreClient.listNotifications({ + subject: notificationSubjectFromUser(user), + surface: sanitizeNotificationSurface(req.query.surface, "hub"), + limit: req.query.limit, + unreadOnly: req.query.unreadOnly === "1" || req.query.unreadOnly === "true", + }); + res.json(payload); +})); + +app.post("/api/notifications/:deliveryId/read", requireSession, asyncRoute(async (req, res) => { + if (!notificationCoreClient.isConfigured()) { + res.status(503).json({ ok: false, error: "notification_core_not_configured" }); + return; + } + + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const user = findLauncherUser(data, actor.id); + const payload = await notificationCoreClient.markNotificationRead({ + subject: notificationSubjectFromUser(user), + deliveryId: req.params.deliveryId, + }); + res.json(payload); +})); + +app.post("/api/notifications/read-all", requireSession, asyncRoute(async (req, res) => { + if (!notificationCoreClient.isConfigured()) { + res.status(503).json({ ok: false, error: "notification_core_not_configured" }); + return; + } + + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const user = findLauncherUser(data, actor.id); + const payload = await notificationCoreClient.markAllNotificationsRead({ + subject: notificationSubjectFromUser(user), + surface: sanitizeNotificationSurface(req.query.surface ?? req.body?.surface, "hub"), + }); + res.json(payload); +})); + +app.get("/api/notifications/stream", requireSession, asyncRoute(async (req, res) => { + if (!notificationCoreClient.isConfigured()) { + res.status(503).json({ ok: false, error: "notification_core_not_configured" }); + return; + } + + const { actor, data } = getLauncherProfileContext(req.nodedcSession); + const user = findLauncherUser(data, actor.id); + const abortController = new AbortController(); + + req.on("close", () => { + abortController.abort(); + }); + + const upstream = await notificationCoreClient.openNotificationStream({ + subject: notificationSubjectFromUser(user), + surface: sanitizeNotificationSurface(req.query.surface, "hub"), + signal: abortController.signal, + }); + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); + + const reader = upstream.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + res.end(); + } +})); + app.get("/api/apps", (req, res) => { const session = getCurrentSession(req); @@ -514,6 +625,39 @@ app.post("/api/internal/tasker/invite-requests", asyncRoute(async (req, res) => "tasker.invite-request.created", result.affectedUserIds?.length ? result.affectedUserIds : [inviter.id] ); + publishNotificationEvent({ + type: result.taskerInviteRequest.status === "approved" ? "tasker.workspace_invite.approved" : "tasker.workspace_invite.requested", + sourceService: "tasker", + actor: { userId: inviter.id, email: inviter.email }, + subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, + payload: { + requestId: result.taskerInviteRequest.id, + workspaceSlug: result.taskerInviteRequest.workspaceSlug, + workspaceName: result.taskerInviteRequest.workspaceName, + inviteeEmail: result.taskerInviteRequest.inviteeEmail, + status: result.taskerInviteRequest.status, + }, + idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:${result.taskerInviteRequest.status}:${result.taskerInviteRequest.updatedAt}`, + deliveries: ( + result.taskerInviteRequest.status === "new" + ? [ + rootAdminNotificationDelivery(result.data, { + ...buildTaskerInviteHubNotification(result.taskerInviteRequest), + actionable: true, + actionType: "tasker.workspace_invite.review", + entityType: "tasker_invite_request", + entityId: result.taskerInviteRequest.id, + }), + ] + : notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => + notificationDeliveryForUser(user, "hub", { + ...buildTaskerInviteHubNotification(result.taskerInviteRequest), + entityType: "tasker_invite_request", + entityId: result.taskerInviteRequest.id, + }) + ) + ).filter(Boolean), + }); res.json({ ok: true, taskerInviteRequest: result.taskerInviteRequest, autoApproved: Boolean(result.autoApproved) }); })); @@ -607,9 +751,137 @@ app.post("/api/internal/engine/workflow-access-requests", asyncRoute(async (req, "engine.workflow-access-request.created", result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, targetUser.id] ); + publishNotificationEvent({ + type: "engine.workflow_share.access_requested", + sourceService: "engine", + actor: { userId: requester.id, email: requester.email }, + subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + workflowId: result.engineWorkflowAccessRequest.workflowId, + workflowName: result.engineWorkflowAccessRequest.workflowName, + targetUserId: targetUser.id, + targetEmail: targetUser.email, + role: result.engineWorkflowAccessRequest.role, + }, + idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`, + deliveries: [ + rootAdminNotificationDelivery(result.data, { + ...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest), + actionable: true, + actionType: "engine.workflow_access.review", + entityType: "engine_workflow_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest }); })); +app.post("/api/internal/engine/role-access-requests", asyncRoute(async (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured", + }); + return; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request" }); + const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {}; + const requester = findInternalAccessUser(snapshot.data, { + subject: requesterPayload.subject, + email: requesterPayload.email, + userId: requesterPayload.userId, + }); + + if (!requester || requester.globalStatus !== "active") { + res.status(404).json({ ok: false, error: "requester_not_found" }); + return; + } + + const result = await controlPlaneStore.createEngineRoleAccessRequest({ + requestedRole: req.body?.requestedRole, + currentRole: req.body?.currentRole, + requesterUserId: requester.id, + requesterEmail: requester.email, + requesterName: requester.name, + }, requester); + + publishControlPlaneEvent( + "engine.role-access-request.created", + result.affectedUserIds?.length ? result.affectedUserIds : [requester.id, "user_root"] + ); + publishNotificationEvent({ + type: "engine.role_upgrade.requested", + sourceService: "engine", + actor: { userId: requester.id, email: requester.email }, + subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + targetUserId: requester.id, + targetEmail: requester.email, + requestedServiceRole: result.engineWorkflowAccessRequest.requestedServiceRole, + currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole, + }, + idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:created:${result.engineWorkflowAccessRequest.updatedAt}`, + deliveries: [ + rootAdminNotificationDelivery(result.data, { + ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), + actionable: true, + actionType: "engine.role_upgrade.review", + entityType: "engine_role_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); + res.json({ ok: true, engineWorkflowAccessRequest: result.engineWorkflowAccessRequest }); +})); + +app.post("/api/internal/engine/role-access-requests/list", asyncRoute(async (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_access_unauthorized" : "internal_access_not_configured", + }); + return; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC Engine role access request list" }); + const requesterPayload = typeof req.body?.requester === "object" && req.body.requester !== null ? req.body.requester : {}; + const requester = findInternalAccessUser(snapshot.data, { + subject: requesterPayload.subject, + email: requesterPayload.email, + userId: requesterPayload.userId, + }); + + if (!requester || requester.globalStatus !== "active") { + res.status(404).json({ ok: false, error: "requester_not_found" }); + return; + } + + const requesterEmail = String(requester.email || "").toLowerCase(); + const requesterGroups = Array.isArray(requesterPayload.groups) + ? requesterPayload.groups.map((group) => String(group || "").trim()).filter(Boolean) + : []; + const canReviewEngineRoleRequests = requester.id === "user_root" || isLauncherAdmin(requesterGroups); + const requests = snapshot.data.engineWorkflowAccessRequests + .filter((request) => { + if (request.requestType !== "service_role" || request.status === "cancelled") return false; + const isRelated = + request.requesterUserId === requester.id || + request.targetUserId === requester.id || + String(request.requesterEmail || "").toLowerCase() === requesterEmail || + String(request.targetEmail || "").toLowerCase() === requesterEmail; + + if (canReviewEngineRoleRequests && request.status === "new") return true; + return isRelated && request.status !== "new"; + }) + .sort((left, right) => String(right.updatedAt || right.createdAt).localeCompare(String(left.updatedAt || left.createdAt))); + + res.json({ ok: true, requests }); +})); + app.post("/api/internal/tasker/profile-sync", asyncRoute(async (req, res) => { if (!isInternalRequestAuthorized(req)) { res.status(config.internalAccessToken ? 401 : 503).json({ @@ -1358,6 +1630,28 @@ app.post("/api/admin/access-requests/:accessRequestId/approve", requireLauncherA } publishControlPlaneEvent("admin.access-request.approved", result.user ? [result.user.id] : []); + if (result.user) { + publishNotificationEvent({ + type: "nodedc.access_request.approved", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "access_request", id: result.accessRequest.id }, + payload: { + accessRequestId: result.accessRequest.id, + userId: result.user.id, + email: result.user.email, + status: result.accessRequest.status, + }, + idempotencyKey: `launcher:access-request:${result.accessRequest.id}:approved:${result.accessRequest.updatedAt}`, + deliveries: [ + notificationDeliveryForUser(result.user, "hub", { + ...buildAccessRequestHubNotification(result.accessRequest), + entityType: "access_request", + entityId: result.accessRequest.id, + }), + ].filter(Boolean), + }); + } res.json(scopeAdminMutationResult(req, result)); } catch (error) { sendAccessRequestApiError(res, error); @@ -1368,6 +1662,29 @@ app.post("/api/admin/access-requests/:accessRequestId/reject", requireLauncherAd try { const result = await controlPlaneStore.rejectAccessRequest(req.params.accessRequestId, req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.access-request.rejected"); + const targetUser = result.data.users.find((user) => user.email.toLowerCase() === result.accessRequest.email.toLowerCase()) ?? null; + if (targetUser) { + publishNotificationEvent({ + type: "nodedc.access_request.rejected", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "access_request", id: result.accessRequest.id }, + payload: { + accessRequestId: result.accessRequest.id, + userId: targetUser.id, + email: targetUser.email, + status: result.accessRequest.status, + }, + idempotencyKey: `launcher:access-request:${result.accessRequest.id}:rejected:${result.accessRequest.updatedAt}`, + deliveries: [ + notificationDeliveryForUser(targetUser, "hub", { + ...buildAccessRequestHubNotification(result.accessRequest), + entityType: "access_request", + entityId: result.accessRequest.id, + }), + ].filter(Boolean), + }); + } res.json(scopeAdminMutationResult(req, result)); } catch (error) { sendAccessRequestApiError(res, error); @@ -1407,6 +1724,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/approve", req ); publishControlPlaneEvent("admin.tasker-invite-request.approved", [result.taskerInviteRequest.inviterUserId]); + publishNotificationEvent({ + type: "tasker.workspace_invite.approved", + sourceService: "tasker", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, + payload: { + requestId: result.taskerInviteRequest.id, + workspaceSlug: result.taskerInviteRequest.workspaceSlug, + workspaceName: result.taskerInviteRequest.workspaceName, + inviteeEmail: result.taskerInviteRequest.inviteeEmail, + status: result.taskerInviteRequest.status, + }, + idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:approved:${result.taskerInviteRequest.updatedAt}`, + deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => + notificationDeliveryForUser(user, "hub", { + ...buildTaskerInviteHubNotification(result.taskerInviteRequest), + entityType: "tasker_invite_request", + entityId: result.taskerInviteRequest.id, + }) + ).filter(Boolean), + }); res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult })); })); @@ -1429,6 +1767,27 @@ app.post("/api/admin/tasker-invite-requests/:taskerInviteRequestId/reject", requ const result = await controlPlaneStore.rejectTaskerInviteRequest(req.params.taskerInviteRequestId, req.body, req.nodedcSession.user); publishControlPlaneEvent("admin.tasker-invite-request.rejected", [result.taskerInviteRequest.inviterUserId]); + publishNotificationEvent({ + type: "tasker.workspace_invite.rejected", + sourceService: "tasker", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "tasker_invite_request", id: result.taskerInviteRequest.id }, + payload: { + requestId: result.taskerInviteRequest.id, + workspaceSlug: result.taskerInviteRequest.workspaceSlug, + workspaceName: result.taskerInviteRequest.workspaceName, + inviteeEmail: result.taskerInviteRequest.inviteeEmail, + status: result.taskerInviteRequest.status, + }, + idempotencyKey: `launcher:tasker-invite:${result.taskerInviteRequest.id}:rejected:${result.taskerInviteRequest.updatedAt}`, + deliveries: notificationUsersForTaskerInvite(result.data, result.taskerInviteRequest).map((user) => + notificationDeliveryForUser(user, "hub", { + ...buildTaskerInviteHubNotification(result.taskerInviteRequest), + entityType: "tasker_invite_request", + entityId: result.taskerInviteRequest.id, + }) + ).filter(Boolean), + }); res.json(scopeAdminMutationResult(req, { ...result, tasker: taskerResult })); })); @@ -1443,6 +1802,50 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques return; } + if (request.requestType === "service_role") { + const result = await controlPlaneStore.approveEngineWorkflowAccessRequest( + req.params.engineWorkflowAccessRequestId, + { + serviceRole: req.body?.serviceRole ?? req.body?.requestedServiceRole ?? req.body?.role, + comment: req.body?.comment, + }, + req.nodedcSession.user + ); + const syncResult = await syncUsersToAuthentik(result.data, [result.targetUser.id], req.nodedcSession.user); + + publishControlPlaneEvent("admin.engine-role-access-request.approved", [ + result.engineWorkflowAccessRequest.requesterUserId, + result.targetUser.id, + ]); + publishNotificationEvent({ + type: "engine.role_upgrade.approved", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "engine_role_access_request", id: result.engineWorkflowAccessRequest.id }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + targetUserId: result.targetUser.id, + targetEmail: result.targetUser.email, + serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole, + }, + idempotencyKey: `launcher:engine-role-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`, + deliveries: [ + notificationDeliveryForUser(result.targetUser, "hub", { + ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), + entityType: "engine_role_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + notificationDeliveryForUser(result.targetUser, "engine", { + ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), + entityType: "engine_role_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); + res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); + return; + } + const engineResult = await requestEngineInternalJson(`/api/internal/workflows/${encodeURIComponent(request.workflowId)}/share/users`, { body: { email: request.targetEmail, @@ -1466,6 +1869,33 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques result.engineWorkflowAccessRequest.requesterUserId, result.targetUser.id, ]); + publishNotificationEvent({ + type: "engine.workflow_share.approved", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "engine_workflow_access_request", id: result.engineWorkflowAccessRequest.id }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + workflowId: result.engineWorkflowAccessRequest.workflowId, + workflowName: result.engineWorkflowAccessRequest.workflowName, + targetUserId: result.targetUser.id, + targetEmail: result.targetUser.email, + role: result.engineWorkflowAccessRequest.role, + }, + idempotencyKey: `launcher:engine-workflow-request:${result.engineWorkflowAccessRequest.id}:approved:${result.engineWorkflowAccessRequest.updatedAt}`, + deliveries: [ + notificationDeliveryForUser(result.targetUser, "hub", { + ...buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest), + entityType: "engine_workflow_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + notificationDeliveryForUser(result.targetUser, "engine", { + ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), + entityType: "engine_workflow_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data, engine: engineResult })); })); @@ -1479,6 +1909,41 @@ app.post("/api/admin/engine-workflow-access-requests/:engineWorkflowAccessReques publishControlPlaneEvent("admin.engine-workflow-access-request.rejected", [ result.engineWorkflowAccessRequest.requesterUserId, ]); + const targetUser = result.data.users.find((user) => user.id === result.engineWorkflowAccessRequest.targetUserId) ?? null; + if (targetUser) { + const isServiceRoleRequest = result.engineWorkflowAccessRequest.requestType === "service_role"; + publishNotificationEvent({ + type: isServiceRoleRequest ? "engine.role_upgrade.rejected" : "engine.workflow_share.rejected", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { + type: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", + id: result.engineWorkflowAccessRequest.id, + }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + targetUserId: targetUser.id, + targetEmail: targetUser.email, + workflowId: result.engineWorkflowAccessRequest.workflowId, + workflowName: result.engineWorkflowAccessRequest.workflowName, + }, + idempotencyKey: `launcher:engine-request:${result.engineWorkflowAccessRequest.id}:rejected:${result.engineWorkflowAccessRequest.updatedAt}`, + deliveries: [ + notificationDeliveryForUser(targetUser, "hub", { + ...(isServiceRoleRequest + ? buildEngineRoleHubNotification(result.engineWorkflowAccessRequest) + : buildEngineWorkflowHubNotification(result.engineWorkflowAccessRequest)), + entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + notificationDeliveryForUser(targetUser, "engine", { + ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), + entityType: isServiceRoleRequest ? "engine_role_access_request" : "engine_workflow_access_request", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); + } res.json(scopeAdminMutationResult(req, result)); })); @@ -1618,7 +2083,41 @@ app.post("/api/admin/access/user-service", requireLauncherAdmin, asyncRoute(asyn const result = await controlPlaneStore.setUserServiceAccess(req.body, req.nodedcSession.user); const syncResult = await syncUsersToAuthentik(result.data, [req.body?.userId], req.nodedcSession.user); - publishControlPlaneEvent("admin.access.user-service.updated", syncResult.userIds); + publishControlPlaneEvent( + "admin.access.user-service.updated", + result.affectedUserIds?.length ? result.affectedUserIds : syncResult.userIds + ); + if (result.engineWorkflowAccessRequest) { + const targetUser = syncResult.data.users.find((user) => user.id === req.body?.userId) ?? null; + if (targetUser) { + publishNotificationEvent({ + type: "engine.role.changed", + sourceService: "launcher", + actor: { userId: req.nodedcSession.user?.id, email: req.nodedcSession.user?.email }, + subject: { type: "engine_role_change", id: result.engineWorkflowAccessRequest.id }, + payload: { + requestId: result.engineWorkflowAccessRequest.id, + targetUserId: targetUser.id, + targetEmail: targetUser.email, + serviceRole: result.engineWorkflowAccessRequest.requestedServiceRole, + currentServiceRole: result.engineWorkflowAccessRequest.currentServiceRole, + }, + idempotencyKey: `launcher:engine-role-change:${result.engineWorkflowAccessRequest.id}`, + deliveries: [ + notificationDeliveryForUser(targetUser, "hub", { + ...buildEngineRoleHubNotification(result.engineWorkflowAccessRequest), + entityType: "engine_role_change", + entityId: result.engineWorkflowAccessRequest.id, + }), + notificationDeliveryForUser(targetUser, "engine", { + ...buildEngineSurfaceNotification(result.engineWorkflowAccessRequest), + entityType: "engine_role_change", + entityId: result.engineWorkflowAccessRequest.id, + }), + ].filter(Boolean), + }); + } + } res.json(scopeAdminMutationResult(req, { ...result, data: syncResult.data })); })); @@ -1785,6 +2284,10 @@ function readConfig() { process.env.NODEDC_ENGINE_BASE_URL ?? process.env.ENGINE_BASE_URL ?? "https://engine.nodedc.ru", + notificationCoreBaseUrl: + process.env.NODEDC_NOTIFICATION_CORE_URL ?? + process.env.NOTIFICATION_CORE_URL ?? + "", }; } @@ -3320,6 +3823,239 @@ function findLauncherUser(data, userId) { return user; } +function notificationSubjectFromUser(user) { + return { + userId: user?.id ?? null, + email: typeof user?.email === "string" ? user.email.toLowerCase() : null, + }; +} + +function notificationDeliveryForUser(user, surface, notification) { + if (!user?.id && !user?.email) return null; + return { + recipientUserId: user?.id ?? null, + recipientEmail: typeof user?.email === "string" ? user.email.toLowerCase() : null, + surface, + title: notification.title, + body: notification.body ?? "", + meta: notification.meta ?? {}, + actionable: notification.actionable === true, + actionType: notification.actionType ?? null, + actionUrl: notification.actionUrl ?? null, + entityType: notification.entityType ?? null, + entityId: notification.entityId ?? null, + }; +} + +function rootAdminNotificationDelivery(data, notification) { + const rootUser = data.users.find((user) => user.id === "user_root") ?? data.users[0] ?? null; + return notificationDeliveryForUser(rootUser, "hub", notification); +} + +function notificationUsersForTaskerInvite(data, request) { + const users = []; + const seen = new Set(); + const addUser = (user) => { + if (!user?.id || seen.has(user.id)) return; + seen.add(user.id); + users.push(user); + }; + + addUser(data.users.find((user) => user.id === request?.inviterUserId)); + addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviterEmail))); + addUser(data.users.find((user) => normalizeNotificationEmail(user.email) === normalizeNotificationEmail(request?.inviteeEmail))); + + return users; +} + +function buildAccessRequestHubNotification(accessRequest) { + const applicantName = [accessRequest?.lastName, accessRequest?.firstName].filter(Boolean).join(" ") || accessRequest?.email || "NODE.DC user"; + const status = accessRequest?.status || "new"; + return { + title: status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён", + body: `${applicantName} · ${accessRequest?.email || ""}`, + meta: { + displayStatus: platformNotificationStatusLabel(status), + meta: accessRequest?.company || formatNotificationDate(accessRequest?.createdAt), + }, + }; +} + +function buildTaskerInviteHubNotification(request) { + const status = request?.status || "new"; + return { + title: status === "new" ? "Запрос доступа Operational Core" : "Operational Core: заявка обновлена", + body: `${request?.workspaceName || request?.workspaceSlug || "Operational Core"} · ${request?.inviteeEmail || ""}`, + meta: { + displayStatus: platformNotificationStatusLabel(status), + meta: request?.inviterName || formatNotificationDate(request?.createdAt), + }, + }; +} + +function buildEngineRoleHubNotification(request) { + const target = request?.targetName || request?.targetEmail || "Engine user"; + const status = request?.status || "new"; + return { + title: engineRoleHubNotificationTitle(request), + body: engineRoleHubNotificationBody(request, target), + meta: { + displayStatus: platformNotificationStatusLabel(status), + meta: `Запрошено: ${engineServiceRoleLabel(request?.requestedServiceRole)}`, + requestId: request?.id, + requestedServiceRole: request?.requestedServiceRole ?? null, + currentServiceRole: request?.currentServiceRole ?? null, + }, + }; +} + +function buildEngineWorkflowHubNotification(request) { + const status = request?.status || "new"; + return { + title: status === "new" ? "Запрос доступа к Engine workflow" : "Engine: заявка обновлена", + body: `${request?.workflowName || request?.workflowId || "Engine workflow"} · ${request?.targetEmail || ""}`, + meta: { + displayStatus: platformNotificationStatusLabel(status), + meta: request?.requesterName || formatNotificationDate(request?.createdAt), + requestId: request?.id, + }, + }; +} + +function buildEngineSurfaceNotification(request) { + if (request?.requestType === "service_role") { + return { + title: request?.workflowName || "NODE.DC Engine", + body: engineRoleSurfaceNotificationBody(request), + meta: { + displayStatus: engineSurfaceStatusLabel(request), + meta: engineSurfaceMeta(request), + requestId: request?.id, + requestedServiceRole: request?.requestedServiceRole ?? null, + currentServiceRole: request?.currentServiceRole ?? null, + }, + }; + } + + return { + title: request?.workflowName || request?.workflowId || "Engine workflow", + body: `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${engineWorkflowRoleLabel(request?.role)}`, + meta: { + displayStatus: engineSurfaceStatusLabel(request), + meta: engineSurfaceMeta(request), + requestId: request?.id, + }, + }; +} + +function engineRoleHubNotificationTitle(request) { + if (request?.status === "approved") return request?.comment === "admin_role_changed" ? "Engine: роль изменена" : "Engine: роль обновлена"; + if (request?.status === "rejected") return "Engine: повышение роли отклонено"; + return "Engine: запрос повышения роли"; +} + +function engineRoleHubNotificationBody(request, target) { + if (request?.status === "rejected") return `${target} · администратор отклонил заявку`; + if (request?.status === "approved") { + return request?.comment === "admin_role_changed" ? `${target} · роль изменена администратором` : `${target} · новая роль применена`; + } + return `${target} · текущая роль ${engineServiceRoleLabel(request?.currentServiceRole)}`; +} + +function engineRoleSurfaceNotificationBody(request) { + const requestedRole = engineServiceRoleLabel(request?.requestedServiceRole); + const requestedByTarget = normalizeNotificationEmail(request?.requesterEmail) === normalizeNotificationEmail(request?.targetEmail); + if (request?.status === "approved") { + return requestedByTarget && request?.comment !== "admin_role_changed" + ? `Администратор подтвердил повышение роли: ${requestedRole}` + : `Администратор изменил роль: ${requestedRole}`; + } + if (request?.status === "rejected") { + return "Администратор отклонил заявку на повышение роли"; + } + return `${request?.requesterName || request?.requesterEmail || "NODE.DC Engine"} просит роль ${requestedRole}`; +} + +function engineSurfaceStatusLabel(request) { + if (request?.requestType === "service_role" && request?.status === "approved" && request?.comment === "admin_role_changed") { + return "Изменено"; + } + if (request?.status === "approved") return "Одобрено"; + if (request?.status === "rejected") return "Отклонено"; + return "Новая заявка"; +} + +function engineSurfaceMeta(request) { + const createdAt = formatShortNotificationDate(request?.createdAt); + return ["Ваш запрос", createdAt].filter(Boolean).join(" · "); +} + +function engineServiceRoleLabel(role) { + if (role === "member") return "Участник"; + if (role === "viewer") return "Гость"; + return "не назначена"; +} + +function engineWorkflowRoleLabel(role) { + if (role === "admin") return "Соавтор"; + if (role === "editor") return "Редактор"; + return "Просмотр"; +} + +function platformNotificationStatusLabel(status) { + const normalized = String(status || "").trim().toLowerCase(); + if (normalized === "approved") return "Подтверждено"; + if (normalized === "rejected") return "Отклонено"; + if (normalized === "cancelled") return "Отменено"; + return "Входящее"; +} + +function formatNotificationDate(value) { + const timestamp = Date.parse(String(value || "")); + if (!Number.isFinite(timestamp)) return ""; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); +} + +function formatShortNotificationDate(value) { + const timestamp = Date.parse(String(value || "")); + if (!Number.isFinite(timestamp)) return ""; + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); +} + +function normalizeNotificationEmail(value) { + return String(value || "").trim().toLowerCase(); +} + +function sanitizeNotificationSurface(value, fallback = "hub") { + const surface = typeof value === "string" && value.trim() ? value.trim().toLowerCase() : fallback; + if (surface === "hub" || surface === "engine" || surface === "operational-core") return surface; + return fallback; +} + +function publishNotificationEvent(command) { + if (!notificationCoreClient.isConfigured()) { + return; + } + if (!Array.isArray(command?.deliveries) || command.deliveries.length === 0) { + return; + } + + notificationCoreClient.createEvent(command).catch((error) => { + console.warn("[notification-core] event publish failed:", error?.message || error); + }); +} + function isLauncherAdmin(groups) { return groups.includes("nodedc:superadmin") || groups.includes("nodedc:launcher:admin"); } @@ -3511,6 +4247,12 @@ function scopeControlPlaneData(data, scope) { } const groupIds = new Set(data.groups.filter((group) => clientIds.has(group.clientId)).map((group) => group.id)); + const userEmails = new Set( + data.users + .filter((user) => userIds.has(user.id)) + .map((user) => String(user.email || "").toLowerCase()) + .filter(Boolean) + ); return { ...data, @@ -3521,7 +4263,12 @@ function scopeControlPlaneData(data, scope) { invites: data.invites.filter((invite) => clientIds.has(invite.clientId)), accessRequests: [], taskerInviteRequests: [], - engineWorkflowAccessRequests: [], + engineWorkflowAccessRequests: data.engineWorkflowAccessRequests.filter((request) => + userIds.has(request.requesterUserId) || + userIds.has(request.targetUserId) || + userEmails.has(String(request.requesterEmail || "").toLowerCase()) || + userEmails.has(String(request.targetEmail || "").toLowerCase()) + ), grants: data.grants.filter((grant) => { if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId); @@ -3574,6 +4321,13 @@ function scopeRuntimeControlPlaneData(data, userId) { .filter((group) => clientIds.has(group.clientId) && group.memberIds.includes(user.id)) .map((group) => ({ ...group, memberIds: group.memberIds.filter((memberId) => memberId === user.id) })); const groupIds = new Set(groups.map((group) => group.id)); + const userEmail = String(user.email || "").toLowerCase(); + const relatedEngineRequests = data.engineWorkflowAccessRequests.filter((request) => + request.requesterUserId === user.id || + request.targetUserId === user.id || + String(request.requesterEmail || "").toLowerCase() === userEmail || + String(request.targetEmail || "").toLowerCase() === userEmail + ); return { ...data, @@ -3585,7 +4339,7 @@ function scopeRuntimeControlPlaneData(data, userId) { accessRequests: [], revokedAccounts: [], taskerInviteRequests: [], - engineWorkflowAccessRequests: [], + engineWorkflowAccessRequests: relatedEngineRequests, grants: data.grants.filter((grant) => { if (grant.targetType === "client") return clientIds.has(grant.targetId); if (grant.targetType === "group") return groupIds.has(grant.targetId); diff --git a/server/notification-core-client.mjs b/server/notification-core-client.mjs new file mode 100644 index 0000000..e2402b4 --- /dev/null +++ b/server/notification-core-client.mjs @@ -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(/\/+$/, "") : ""; +} diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 21a7935..d2ef293 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -62,6 +62,7 @@ import { } from "../shared/api/authApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; 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 { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; @@ -114,6 +115,7 @@ export function LauncherApp() { const [taskManagerWorkspaces, setTaskManagerWorkspaces] = useState([]); const [taskManagerWorkspacesLoading, setTaskManagerWorkspacesLoading] = useState(false); const [taskManagerWorkspacesError, setTaskManagerWorkspacesError] = useState(null); + const [hubNotifications, setHubNotifications] = useState([]); const [inviteFlow, setInviteFlow] = useState(() => (inviteToken ? { status: "loading" } : null)); const runtimeDataRef = useRef(data); const runtimeProfileIdRef = useRef(activeProfileId); @@ -155,7 +157,6 @@ export function LauncherApp() { }; }, [authSession, me]); const resolvedClientId = me.activeClientId; - const notifications = useMemo(() => buildLauncherNotifications(data, runtimeMe), [data, runtimeMe]); const canOpenAdminApi = Boolean(authSession?.authenticated && runtimeMe.permissions.canOpenAdmin); const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]); const launcherServices = useMemo( @@ -260,6 +261,30 @@ export function LauncherApp() { 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(() => { let isMounted = true; @@ -402,6 +427,32 @@ export function LauncherApp() { }; }, [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(() => { if (!authSession?.authenticated) return; @@ -698,14 +749,14 @@ export function LauncherApp() { engineWorkflowAccessRequestId: string, patch: Parameters[1] ) { - applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); + return applyControlPlaneMutation(approveAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); } function handleRejectEngineWorkflowAccessRequest( engineWorkflowAccessRequestId: string, patch: Parameters[1] ) { - applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); + return applyControlPlaneMutation(rejectAdminEngineWorkflowAccessRequest(engineWorkflowAccessRequestId, patch)); } function handleRetrySync(syncId: string) { @@ -883,7 +934,20 @@ export function LauncherApp() { onOpenProfileSettings={() => setProfileSettingsOpen(true)} onLogout={handleLogout} 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(); + }} />
@@ -1518,82 +1582,52 @@ function parseInviteToken(pathname: string) { return match?.[1] ? decodeURIComponent(match[1]) : null; } -function buildLauncherNotifications(data: LauncherData, me: ReturnType): LauncherNotificationItem[] { - const currentUserId = me.user.id; - const currentEmail = me.user.email.toLowerCase(); - const canModerate = me.permissions.canOpenAdmin; - const items: LauncherNotificationItem[] = []; +function mapNotificationDeliveryToLauncherItem(delivery: NotificationDelivery): LauncherNotificationItem { + const requestId = notificationString(delivery.entityId) + ?? notificationString(delivery.meta?.requestId) + ?? notificationString(delivery.eventPayload?.requestId); + const requestedServiceRole = notificationEngineServiceRole( + delivery.meta?.requestedServiceRole ?? delivery.eventPayload?.requestedServiceRole ?? delivery.eventPayload?.serviceRole + ); - for (const request of data.accessRequests) { - const isOwnRequest = request.email.toLowerCase() === currentEmail; - if (!canModerate && !isOwnRequest) continue; - - const applicantName = [request.lastName, request.firstName].filter(Boolean).join(" ") || request.email; - items.push({ - id: `nodedc:${request.id}`, - kind: "nodedc", - title: request.status === "new" ? "Входящий запрос доступа" : "Запрос доступа обновлён", - description: `${applicantName} · ${request.email}`, - meta: request.company || formatNotificationDate(request.createdAt), - status: request.status, - createdAt: request.createdAt, - }); - } - - 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); - }); + return { + id: delivery.id, + kind: notificationKindFromDelivery(delivery), + title: delivery.title, + description: delivery.body, + meta: notificationString(delivery.meta?.meta), + displayStatus: notificationString(delivery.meta?.displayStatus), + status: delivery.status, + createdAt: delivery.createdAt, + updatedAt: delivery.updatedAt, + actionKind: delivery.actionType === "engine.role_upgrade.review" ? "engine-role-request" : undefined, + requestId: delivery.actionType === "engine.role_upgrade.review" ? requestId : undefined, + requestedServiceRole, + }; } -function formatNotificationDate(value: string) { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return value; - 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 notificationKindFromDelivery(delivery: NotificationDelivery): LauncherNotificationItem["kind"] { + const source = String(delivery.sourceService || "").toLowerCase(); + const eventType = String(delivery.eventType || "").toLowerCase(); + const entityType = String(delivery.entityType || "").toLowerCase(); + + if (source === "tasker" || source === "operational-core" || eventType.startsWith("tasker.") || entityType.startsWith("tasker")) { + return "operational-core"; + } + + 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) { diff --git a/src/entities/engine-workflow-access-request/types.ts b/src/entities/engine-workflow-access-request/types.ts index 277478e..802c759 100644 --- a/src/entities/engine-workflow-access-request/types.ts +++ b/src/entities/engine-workflow-access-request/types.ts @@ -1,14 +1,19 @@ 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 interface EngineWorkflowAccessRequest { id: string; + requestType?: EngineWorkflowAccessRequestType; workflowId: string; workflowName: string; targetUserId: string; targetEmail: string; targetName?: string | null; role: EngineWorkflowRole; + requestedServiceRole?: EngineServiceRole | null; + currentServiceRole?: EngineServiceRole | null; requesterUserId?: string | null; requesterEmail: string; requesterName: string; diff --git a/src/shared/api/adminApi.ts b/src/shared/api/adminApi.ts index 233e9e5..3ccac30 100644 --- a/src/shared/api/adminApi.ts +++ b/src/shared/api/adminApi.ts @@ -407,7 +407,7 @@ export async function rejectAdminTaskerInviteRequest( export async function approveAdminEngineWorkflowAccessRequest( engineWorkflowAccessRequestId: string, - payload: Partial> = {} + payload: Partial> & { serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"] } = {} ): Promise { return requestJson( `/api/admin/engine-workflow-access-requests/${encodeURIComponent(engineWorkflowAccessRequestId)}/approve`, diff --git a/src/shared/api/notificationsApi.ts b/src/shared/api/notificationsApi.ts new file mode 100644 index 0000000..99ae88e --- /dev/null +++ b/src/shared/api/notificationsApi.ts @@ -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; + status: NotificationDeliveryStatus; + unread?: boolean; + actionable?: boolean; + actionType?: string | null; + actionUrl?: string | null; + entityType?: string | null; + entityId?: string | null; + eventPayload?: Record; + 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(); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 50f06c3..4562000 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4678,6 +4678,12 @@ code { 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 { margin: 0; font-size: 1rem; @@ -4705,6 +4711,32 @@ code { 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 { background: rgba(255, 255, 255, 0.12); color: var(--text-primary); @@ -4759,14 +4791,21 @@ code { .nodedc-notification-card { display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; gap: 0.45rem; 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; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); } .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, @@ -4790,10 +4829,67 @@ code { .nodedc-notification-card__foot { display: flex; - justify-content: space-between; + justify-content: flex-start; 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 { position: relative; display: grid; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 06f57b7..f5e92dd 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -92,6 +92,10 @@ type AdminSection = | "company"; type AdminOverlayMode = "admin" | "platform"; type AdminMutationOutcome = { ok: true } | { ok: false; message: string }; +type EngineWorkflowAccessRequestDecisionPatch = + Partial> & { + serviceRole?: EngineWorkflowAccessRequest["requestedServiceRole"]; + }; type AccessAssignmentRole = Exclude; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; @@ -255,11 +259,11 @@ export function AdminOverlay({ onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onApproveEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; onRejectEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; onRetrySync: (syncId: string) => void; onCreateClient: () => void; @@ -3847,11 +3851,11 @@ function InvitesSection({ onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onApproveEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; onRejectEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; }) { const [email, setEmail] = useState(""); @@ -4153,11 +4157,11 @@ function AccessRequestsPanel({ onRejectTaskerInviteRequest: (taskerInviteRequestId: string, patch: Partial>) => void; onApproveEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; onRejectEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; }) { const accessRequests = data.accessRequests.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt)); @@ -4472,20 +4476,20 @@ function EngineWorkflowAccessRequestsPanel({ requests: EngineWorkflowAccessRequest[]; onApproveEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; onRejectEngineWorkflowAccessRequest: ( engineWorkflowAccessRequestId: string, - patch: Partial> + patch: EngineWorkflowAccessRequestDecisionPatch ) => void; }) { return (
-

NODE.DC Engine: запросы доступа к workflow

+

NODE.DC Engine: запросы доступа

- Эти заявки создаются из Engine, когда workflow хотят пошарить зарегистрированному пользователю без доступа к Engine. + Здесь workflow-шаринг и запросы повышения глобальной роли Engine из режима только чтения.

@@ -4509,12 +4513,14 @@ function EngineWorkflowAccessRequestsPanel({ - {requests.map((request) => ( + {requests.map((request) => { + const isServiceRoleRequest = request.requestType === "service_role"; + return (
- {request.workflowName} - {request.workflowId} + {isServiceRoleRequest ? "Повышение роли Engine" : request.workflowName} + {isServiceRoleRequest ? `Текущая роль: ${engineServiceRoleLabel(request.currentServiceRole)}` : request.workflowId}
@@ -4529,7 +4535,7 @@ function EngineWorkflowAccessRequestsPanel({ {request.requesterEmail} - {engineWorkflowRoleLabel(request.role)} + {isServiceRoleRequest ? engineServiceRoleLabel(request.requestedServiceRole) : engineWorkflowRoleLabel(request.role)} @@ -4540,7 +4546,10 @@ function EngineWorkflowAccessRequestsPanel({ aria-label={`Подтвердить доступ Engine ${request.targetEmail}`} className="access-request-decision-button access-request-decision-button--accept" type="button" - onClick={() => onApproveEngineWorkflowAccessRequest(request.id, {})} + onClick={() => onApproveEngineWorkflowAccessRequest(request.id, isServiceRoleRequest + ? { serviceRole: request.requestedServiceRole ?? "member" } + : {} + )} > @@ -4558,7 +4567,8 @@ function EngineWorkflowAccessRequestsPanel({ )} - ))} + ); + })} @@ -5020,6 +5030,12 @@ function engineWorkflowRoleLabel(role: EngineWorkflowAccessRequest["role"]): str 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 { const labels: Record = { overview: "Обзор", diff --git a/src/widgets/top-bar/TopBar.tsx b/src/widgets/top-bar/TopBar.tsx index fecdab8..983536f 100644 --- a/src/widgets/top-bar/TopBar.tsx +++ b/src/widgets/top-bar/TopBar.tsx @@ -5,11 +5,11 @@ import type { Client } from "../../entities/client/types"; import { PUBLIC_POOL_CLIENT, isPublicPoolClientId } from "../../entities/public-pool/constants"; import type { MeResponse, ProfileOption } from "../../shared/api/mockApi"; 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 LauncherNotificationKind = "nodedc" | "operational-core" | "engine"; -export type LauncherNotificationStatus = "new" | "approved" | "rejected" | "cancelled"; +export type LauncherNotificationStatus = "unread" | "read" | "archived"; export interface LauncherNotificationItem { id: string; @@ -17,10 +17,23 @@ export interface LauncherNotificationItem { title: string; description: string; meta?: string; + displayStatus?: string; status: LauncherNotificationStatus; createdAt: string; + updatedAt?: string | null; + actionKind?: "engine-role-request"; + requestId?: string; + requestedServiceRole?: "viewer" | "member" | null; } +type EngineRoleActionValue = "choose" | "viewer" | "member"; + +const engineRoleActionOptions: Array> = [ + { value: "choose", label: "Изменить роль" }, + { value: "viewer", label: "Гость" }, + { value: "member", label: "Участник" }, +]; + export function TopBar({ me, clients, @@ -38,6 +51,8 @@ export function TopBar({ onLogout, brandLinkUrl = "/", notifications = [], + onMarkNotificationsRead, + onResolveEngineRoleRequest, }: { me: MeResponse; clients: Client[]; @@ -55,9 +70,13 @@ export function TopBar({ onLogout?: () => void; brandLinkUrl?: string; notifications?: LauncherNotificationItem[]; + onMarkNotificationsRead?: () => void | Promise; + onResolveEngineRoleRequest?: (requestId: string, action: "approve" | "reject", serviceRole?: "viewer" | "member", deliveryId?: string) => void | Promise; }) { const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationFilter, setNotificationFilter] = useState("all"); + const [notificationsOnlyNew, setNotificationsOnlyNew] = useState(false); + const [resolvingNotificationIds, setResolvingNotificationIds] = useState>({}); const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId)); const clientsWithPublicPool = [ ...clients, @@ -72,24 +91,59 @@ export function TopBar({ })); const canOpenPlatform = me.launcherRole === "root_admin"; 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( - () => notifications.filter((notification) => notificationFilter === "all" || notification.kind === notificationFilter), - [notificationFilter, notifications] + () => notifications.filter((notification) => { + 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" ? createPortal( -
setNotificationsOpen(false)}> +
event.stopPropagation()}>

Уведомления

NODE DC
- +
+ + +
@@ -114,17 +168,59 @@ export function TopBar({ История заявок NODE.DC, Operational Core и Engine появится здесь.
) : ( - visibleNotifications.map((notification) => ( -
-
{notificationKindLabel(notification.kind)}
-
{notification.title}
-
{notification.description}
-
- {notificationStatusLabel(notification.status)} - {notification.meta ? {notification.meta} : null} + visibleNotifications.map((notification) => { + const unread = notification.status === "unread"; + return ( +
+
+
{notificationKindLabel(notification.kind)}
+
{notification.title}
+
{notification.description}
+
+ {notification.displayStatus || notificationStatusLabel(notification.status)} + {notification.meta ? {notification.meta} : null} +
+ {notification.actionKind === "engine-role-request" && + notification.requestId && + notification.status === "unread" && + onResolveEngineRoleRequest ? ( +
+ {(() => { + const requestBusy = Boolean(resolvingNotificationIds[notification.requestId]); + return ( + <> + + + 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); + } + }} + /> + + ); + })()} +
+ ) : null}
- )) + ); + }) )}
@@ -284,10 +380,9 @@ function notificationKindLabel(kind: LauncherNotificationKind): string { function notificationStatusLabel(status: LauncherNotificationStatus): string { const labels: Record = { - new: "Входящее", - approved: "Подтверждено", - rejected: "Отклонено", - cancelled: "Отменено", + unread: "Входящее", + read: "Прочитано", + archived: "В архиве", }; return labels[status];