2736 lines
99 KiB
JavaScript
2736 lines
99 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
||
import { existsSync, readFileSync } from "node:fs";
|
||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
|
||
const collectionKeys = [
|
||
"clients",
|
||
"users",
|
||
"memberships",
|
||
"groups",
|
||
"services",
|
||
"grants",
|
||
"exceptions",
|
||
"invites",
|
||
"accessRequests",
|
||
"revokedAccounts",
|
||
"taskerInviteRequests",
|
||
"syncStatuses",
|
||
"auditEvents",
|
||
"taskManagerMemberships",
|
||
"taskManagerProjectMemberships",
|
||
];
|
||
|
||
const clientTypes = new Set(["company", "person"]);
|
||
const clientStatuses = new Set(["active", "suspended", "demo", "expired"]);
|
||
const userStatuses = new Set(["invited", "active", "blocked"]);
|
||
const membershipRoles = new Set(["client_owner", "client_admin", "member"]);
|
||
const grantTargetTypes = new Set(["client", "group", "user"]);
|
||
const appRoles = new Set(["viewer", "member", "admin", "owner"]);
|
||
const grantStatuses = new Set(["active", "disabled"]);
|
||
const exceptionTypes = new Set(["deny", "allow"]);
|
||
const serviceStatuses = new Set(["active", "maintenance", "hidden", "disabled"]);
|
||
const taskManagerWorkspaceManagedByValues = new Set(["launcher", "tasker"]);
|
||
const accessRequestStatuses = new Set(["new", "approved", "rejected"]);
|
||
const taskerInviteRequestStatuses = new Set(["new", "approved", "rejected", "cancelled"]);
|
||
const taskManagerInviteRoles = new Set(["guest", "member", "admin"]);
|
||
const publicPoolClientId = "client_public_pool";
|
||
const publicPoolClient = {
|
||
id: publicPoolClientId,
|
||
type: "person",
|
||
name: "Открытый контур",
|
||
legalName: "Public access pool",
|
||
status: "active",
|
||
contractStartsAt: null,
|
||
contractEndsAt: null,
|
||
paidUntil: null,
|
||
demoEndsAt: null,
|
||
contactName: "NODE.DC",
|
||
contactEmail: null,
|
||
avatarUrl: null,
|
||
notes: "Системный контур для публичных заявок, публичных инвайтов и self-service пользователей.",
|
||
createdAt: "2026-05-09T00:00:00.000Z",
|
||
updatedAt: "2026-05-09T00:00:00.000Z",
|
||
};
|
||
const defaultSettings = {
|
||
brand: {
|
||
logoLinkUrl: "/",
|
||
},
|
||
taskManager: {
|
||
workspaceCreationPolicy: "any_authorized_user",
|
||
},
|
||
};
|
||
|
||
export function createControlPlaneStore({ projectRoot }) {
|
||
const publicStorageRoot = join(projectRoot, "public", "storage");
|
||
const distStorageRoot = join(projectRoot, "dist", "storage");
|
||
const dataPath = join(publicStorageRoot, "launcher-data.json");
|
||
|
||
function readData() {
|
||
if (!existsSync(dataPath)) {
|
||
return normalizeData({});
|
||
}
|
||
|
||
return normalizeData(JSON.parse(readFileSync(dataPath, "utf8")));
|
||
}
|
||
|
||
async function writeData(data) {
|
||
const normalizedData = normalizeData(data);
|
||
const payload = `${JSON.stringify(normalizedData, null, 2)}\n`;
|
||
|
||
await Promise.all(
|
||
getWritableStorageRoots().map(async (storageRoot) => {
|
||
await mkdir(storageRoot, { recursive: true });
|
||
await writeJsonAtomically(join(storageRoot, "launcher-data.json"), payload);
|
||
})
|
||
);
|
||
|
||
return normalizedData;
|
||
}
|
||
|
||
function getSnapshot(identity) {
|
||
const data = readData();
|
||
|
||
return {
|
||
actor: resolveActor(data, identity),
|
||
counts: Object.fromEntries(collectionKeys.map((key) => [key, data[key].length])),
|
||
data,
|
||
};
|
||
}
|
||
|
||
async function replaceData(payload, identity) {
|
||
const data = normalizeData(payload);
|
||
const actor = resolveActor(data, identity);
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлено control-plane состояние",
|
||
objectType: "control_plane",
|
||
objectName: "Launcher data",
|
||
result: "success",
|
||
details: "Полная запись launcher-data.json через backend store.",
|
||
});
|
||
|
||
return writeData(data);
|
||
}
|
||
|
||
async function createClient(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const name = requireString(payload?.name, "name");
|
||
const client = {
|
||
id: uniqueId(data.clients, "client", name),
|
||
type: pickEnum(payload?.type, clientTypes, "company"),
|
||
name,
|
||
legalName: nullableString(payload?.legalName),
|
||
inn: nullableString(payload?.inn),
|
||
status: pickEnum(payload?.status, clientStatuses, "active"),
|
||
contractStartsAt: nullableString(payload?.contractStartsAt),
|
||
contractEndsAt: nullableString(payload?.contractEndsAt),
|
||
paidUntil: nullableString(payload?.paidUntil),
|
||
demoEndsAt: nullableString(payload?.demoEndsAt),
|
||
contactName: nullableString(payload?.contactName),
|
||
contactEmail: nullableString(payload?.contactEmail),
|
||
avatarUrl: nullableString(payload?.avatarUrl),
|
||
integrations: normalizeClientIntegrations(payload?.integrations),
|
||
notes: nullableString(payload?.notes),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.clients.push(client);
|
||
addAuditEvent(data, actor, {
|
||
action: "Создан клиент",
|
||
objectType: "client",
|
||
objectName: client.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, client, "client");
|
||
|
||
await writeData(data);
|
||
return { client, data };
|
||
}
|
||
|
||
async function updateClient(clientId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, clientId, "client");
|
||
|
||
client.type = pickEnum(payload?.type, clientTypes, client.type);
|
||
client.name = optionalString(payload?.name, client.name);
|
||
client.legalName = nullableStringWithFallback(payload?.legalName, client.legalName ?? null);
|
||
client.inn = nullableStringWithFallback(payload?.inn, client.inn ?? null);
|
||
client.status = pickEnum(payload?.status, clientStatuses, client.status);
|
||
client.contractStartsAt = nullableStringWithFallback(payload?.contractStartsAt, client.contractStartsAt ?? null);
|
||
client.contractEndsAt = nullableStringWithFallback(payload?.contractEndsAt, client.contractEndsAt ?? null);
|
||
client.paidUntil = nullableStringWithFallback(payload?.paidUntil, client.paidUntil ?? null);
|
||
client.demoEndsAt = nullableStringWithFallback(payload?.demoEndsAt, client.demoEndsAt ?? null);
|
||
client.contactName = nullableStringWithFallback(payload?.contactName, client.contactName ?? null);
|
||
client.contactEmail = nullableStringWithFallback(payload?.contactEmail, client.contactEmail ?? null);
|
||
client.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, client.avatarUrl ?? null);
|
||
if ("integrations" in (payload ?? {})) {
|
||
client.integrations = normalizeClientIntegrations(payload.integrations, client.integrations);
|
||
}
|
||
client.notes = nullableStringWithFallback(payload?.notes, client.notes ?? null);
|
||
client.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлён клиент",
|
||
objectType: "client",
|
||
objectName: client.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, client, "client");
|
||
|
||
await writeData(data);
|
||
return { client, data };
|
||
}
|
||
|
||
async function recordTaskManagerWorkspaceMembership(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, payload?.clientId, "client");
|
||
const user = findById(data.users, payload?.userId, "user");
|
||
const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {};
|
||
const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {};
|
||
const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {};
|
||
|
||
upsertTaskManagerMembership(data, {
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
|
||
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
|
||
role: normalizeTaskManagerMembershipRole(payload?.role),
|
||
managedBy: payload?.managedBy,
|
||
planeUserId: membership.member?.id ?? null,
|
||
planeRole: typeof membership.role === "number" ? membership.role : null,
|
||
});
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Назначен Tasker workspace",
|
||
objectType: "task-manager-membership",
|
||
objectName: user.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Role: ${payload?.role ?? "member"}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function removeTaskManagerWorkspaceMembership(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, payload?.clientId, "client");
|
||
const user = findById(data.users, payload?.userId, "user");
|
||
const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug");
|
||
|
||
data.taskManagerMemberships = data.taskManagerMemberships.filter(
|
||
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
|
||
);
|
||
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter(
|
||
(membership) => !(membership.clientId === client.id && membership.userId === user.id && membership.workspaceSlug === workspaceSlug)
|
||
);
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Снят Tasker workspace",
|
||
objectType: "task-manager-membership",
|
||
objectName: user.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Workspace: ${workspaceSlug}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function recordTaskManagerProjectMembership(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, payload?.clientId, "client");
|
||
const user = findById(data.users, payload?.userId, "user");
|
||
const taskManager = typeof payload?.taskManager === "object" && payload.taskManager !== null ? payload.taskManager : {};
|
||
const membership = typeof taskManager.membership === "object" && taskManager.membership !== null ? taskManager.membership : {};
|
||
const workspace = typeof membership.workspace === "object" && membership.workspace !== null ? membership.workspace : {};
|
||
const project = typeof membership.project === "object" && membership.project !== null ? membership.project : {};
|
||
|
||
upsertTaskManagerProjectMembership(data, {
|
||
clientId: client.id,
|
||
userId: user.id,
|
||
workspaceSlug: workspace.slug ?? payload?.workspaceSlug,
|
||
workspaceName: workspace.name ?? payload?.workspaceName ?? null,
|
||
projectId: project.id ?? payload?.projectId,
|
||
projectIdentifier: project.identifier ?? payload?.projectIdentifier ?? null,
|
||
projectName: project.name ?? payload?.projectName ?? null,
|
||
role: normalizeTaskManagerMembershipRole(payload?.role),
|
||
managedBy: payload?.managedBy,
|
||
planeUserId: membership.member?.id ?? null,
|
||
planeRole: typeof membership.role === "number" ? membership.role : null,
|
||
});
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Назначен Tasker project",
|
||
objectType: "task-manager-project-membership",
|
||
objectName: user.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Workspace: ${workspace.name ?? workspace.slug ?? payload?.workspaceSlug}; Project: ${project.name ?? project.identifier ?? payload?.projectId}; Role: ${payload?.role ?? "member"}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function removeTaskManagerProjectMembership(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, payload?.clientId, "client");
|
||
const user = findById(data.users, payload?.userId, "user");
|
||
const workspaceSlug = requireString(payload?.workspaceSlug ?? client.integrations?.taskManager?.workspaceSlug, "workspaceSlug");
|
||
const projectId = requireString(payload?.projectId, "projectId");
|
||
|
||
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter(
|
||
(membership) =>
|
||
!(
|
||
membership.clientId === client.id &&
|
||
membership.userId === user.id &&
|
||
membership.workspaceSlug === workspaceSlug &&
|
||
membership.projectId === projectId
|
||
)
|
||
);
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Снят Tasker project",
|
||
objectType: "task-manager-project-membership",
|
||
objectName: user.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Workspace: ${workspaceSlug}; Project: ${projectId}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function deleteClient(clientId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const client = findById(data.clients, clientId, "client");
|
||
|
||
if (data.clients.length <= 1) {
|
||
throw new Error("Cannot delete the last client");
|
||
}
|
||
|
||
const deletedGroupIds = new Set(data.groups.filter((group) => group.clientId === clientId).map((group) => group.id));
|
||
data.clients = data.clients.filter((item) => item.id !== clientId);
|
||
data.memberships = data.memberships.filter((membership) => membership.clientId !== clientId);
|
||
data.groups = data.groups.filter((group) => group.clientId !== clientId);
|
||
data.grants = data.grants.filter(
|
||
(grant) =>
|
||
!(grant.targetType === "client" && grant.targetId === clientId) &&
|
||
!(grant.targetType === "group" && deletedGroupIds.has(grant.targetId))
|
||
);
|
||
data.invites = data.invites.filter((invite) => invite.clientId !== clientId);
|
||
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== clientId);
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Удалён клиент",
|
||
objectType: "client",
|
||
objectName: client.name,
|
||
clientId,
|
||
result: "warning",
|
||
});
|
||
|
||
await writeData(data);
|
||
return { client, data };
|
||
}
|
||
|
||
async function updateSettings(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const patch = typeof payload === "object" && payload !== null ? payload : {};
|
||
const settings = normalizeSettings({
|
||
...data.settings,
|
||
...patch,
|
||
brand: {
|
||
...(data.settings?.brand ?? {}),
|
||
...(patch.brand ?? {}),
|
||
},
|
||
taskManager: {
|
||
...(data.settings?.taskManager ?? {}),
|
||
...(patch.taskManager ?? {}),
|
||
},
|
||
});
|
||
|
||
data.settings = settings;
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлены системные настройки",
|
||
objectType: "settings",
|
||
objectName: "Brand settings",
|
||
result: "success",
|
||
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { settings, data };
|
||
}
|
||
|
||
async function updateUserProfile(userId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const user = findById(data.users, userId, "user");
|
||
const now = isoNow();
|
||
|
||
user.name = optionalString(payload?.name, user.name);
|
||
user.email = optionalString(payload?.email, user.email);
|
||
user.phone = nullableStringWithFallback(payload?.phone, user.phone);
|
||
user.position = nullableStringWithFallback(payload?.position, user.position);
|
||
user.notes = nullableStringWithFallback(payload?.notes, user.notes);
|
||
user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null);
|
||
user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus);
|
||
user.updatedAt = now;
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлён профиль пользователя",
|
||
objectType: "user",
|
||
objectName: user.email,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, user, "user");
|
||
|
||
await writeData(data);
|
||
return { user, data };
|
||
}
|
||
|
||
async function createUser(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const clientId = requireString(payload?.clientId, "clientId");
|
||
const client = findClientById(data, clientId);
|
||
const email = requireString(payload?.email, "email").toLowerCase();
|
||
const existingUser = data.users.find((item) => item.email.toLowerCase() === email);
|
||
const user =
|
||
existingUser ??
|
||
{
|
||
id: uniqueId(data.users, "user", email),
|
||
authentikUserId: nullableString(payload?.authentikUserId),
|
||
name: optionalString(payload?.name, email.split("@")[0]),
|
||
email,
|
||
phone: nullableString(payload?.phone),
|
||
position: nullableString(payload?.position),
|
||
notes: nullableString(payload?.notes),
|
||
avatarUrl: nullableString(payload?.avatarUrl),
|
||
globalStatus: pickEnum(payload?.globalStatus, userStatuses, "active"),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
if (existingUser) {
|
||
user.name = optionalString(payload?.name, user.name);
|
||
user.phone = nullableStringWithFallback(payload?.phone, user.phone ?? null);
|
||
user.position = nullableStringWithFallback(payload?.position, user.position ?? null);
|
||
user.notes = nullableStringWithFallback(payload?.notes, user.notes ?? null);
|
||
user.avatarUrl = nullableStringWithFallback(payload?.avatarUrl, user.avatarUrl ?? null);
|
||
user.globalStatus = pickEnum(payload?.globalStatus, userStatuses, user.globalStatus);
|
||
user.updatedAt = now;
|
||
} else {
|
||
data.users.push(user);
|
||
}
|
||
|
||
const existingMembership = data.memberships.find((membership) => membership.clientId === clientId && membership.userId === user.id);
|
||
|
||
if (existingMembership) {
|
||
throw new Error(`User ${email} already belongs to client ${client.name}`);
|
||
}
|
||
|
||
const membership = {
|
||
id: uniqueId(data.memberships, "mem", `${clientId}-${email}`),
|
||
clientId,
|
||
userId: user.id,
|
||
role: pickEnum(payload?.role, membershipRoles, "member"),
|
||
status: pickEnum(payload?.membershipStatus, new Set(["active", "disabled"]), "active"),
|
||
invitedByUserId: actor.id,
|
||
inviteId: null,
|
||
source: "launcher",
|
||
sourceTaskerInviteRequestId: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.memberships.push(membership);
|
||
|
||
if (Array.isArray(payload?.groupIds)) {
|
||
for (const group of data.groups) {
|
||
if (group.clientId !== clientId || !payload.groupIds.includes(group.id) || group.memberIds.includes(user.id)) {
|
||
continue;
|
||
}
|
||
|
||
group.memberIds.push(user.id);
|
||
group.updatedAt = now;
|
||
}
|
||
}
|
||
|
||
clearRevokedAccount(data, email);
|
||
addAuditEvent(data, actor, {
|
||
action: existingUser ? "Добавлен пользователь в клиента" : "Создан пользователь",
|
||
objectType: "user",
|
||
objectName: email,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Role: ${membership.role}; status: ${membership.status}`,
|
||
});
|
||
markPendingSync(data, user, "user", email);
|
||
|
||
await writeData(data);
|
||
return { user, membership, data };
|
||
}
|
||
|
||
async function updateMembership(membershipId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const membership = findById(data.memberships, membershipId, "membership");
|
||
const user = findById(data.users, membership.userId, "user");
|
||
|
||
membership.role = pickEnum(payload?.role, membershipRoles, membership.role);
|
||
membership.status = pickEnum(payload?.status, new Set(["active", "disabled"]), membership.status);
|
||
membership.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлено членство",
|
||
objectType: "user",
|
||
objectName: user.email,
|
||
clientId: membership.clientId,
|
||
result: "success",
|
||
details: `Role: ${membership.role}; status: ${membership.status}`,
|
||
});
|
||
markPendingSync(data, user, "user");
|
||
|
||
await writeData(data);
|
||
return { membership, data };
|
||
}
|
||
|
||
async function deleteMembership(membershipId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const membership = findById(data.memberships, membershipId, "membership");
|
||
const user = findById(data.users, membership.userId, "user");
|
||
|
||
data.memberships = data.memberships.filter((item) => item.id !== membershipId);
|
||
data.groups = data.groups.map((group) =>
|
||
group.clientId === membership.clientId
|
||
? {
|
||
...group,
|
||
memberIds: group.memberIds.filter((userId) => userId !== membership.userId),
|
||
updatedAt: isoNow(),
|
||
}
|
||
: group
|
||
);
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Удалено членство",
|
||
objectType: "user",
|
||
objectName: user.email,
|
||
clientId: membership.clientId,
|
||
result: "warning",
|
||
});
|
||
markPendingSync(data, user, "user");
|
||
|
||
await writeData(data);
|
||
return { membership, data };
|
||
}
|
||
|
||
async function deleteUser(userId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const user = findById(data.users, userId, "user");
|
||
|
||
if (user.id === "user_root") {
|
||
throw new Error("Системного root-пользователя нельзя удалить");
|
||
}
|
||
|
||
const email = user.email.toLowerCase();
|
||
const removedMembershipIds = new Set(data.memberships.filter((membership) => membership.userId === user.id).map((membership) => membership.id));
|
||
const removedInviteIds = new Set(
|
||
data.invites
|
||
.filter((invite) => invite.email.toLowerCase() === email)
|
||
.map((invite) => invite.id)
|
||
);
|
||
const removedAccessRequestIds = new Set(
|
||
data.accessRequests
|
||
.filter((request) => request.email.toLowerCase() === email)
|
||
.map((request) => request.id)
|
||
);
|
||
const removedTaskerInviteRequestIds = new Set(
|
||
data.taskerInviteRequests
|
||
.filter(
|
||
(request) =>
|
||
request.inviteeEmail.toLowerCase() === email ||
|
||
request.inviterEmail.toLowerCase() === email ||
|
||
request.inviterUserId === user.id
|
||
)
|
||
.map((request) => request.id)
|
||
);
|
||
|
||
upsertRevokedAccount(data, user, actor);
|
||
data.users = data.users.filter((candidate) => candidate.id !== user.id);
|
||
data.memberships = data.memberships.filter((membership) => membership.userId !== user.id);
|
||
data.groups = data.groups.map((group) => ({
|
||
...group,
|
||
memberIds: group.memberIds.filter((memberId) => memberId !== user.id),
|
||
updatedAt: group.memberIds.includes(user.id) ? isoNow() : group.updatedAt,
|
||
}));
|
||
data.grants = data.grants.filter((grant) => !(grant.targetType === "user" && grant.targetId === user.id));
|
||
data.exceptions = data.exceptions.filter((exception) => exception.userId !== user.id);
|
||
data.invites = data.invites.filter(
|
||
(invite) => invite.email.toLowerCase() !== email && invite.invitedByUserId !== user.id
|
||
);
|
||
data.accessRequests = data.accessRequests.filter((request) => request.email.toLowerCase() !== email);
|
||
data.taskerInviteRequests = data.taskerInviteRequests.filter(
|
||
(request) =>
|
||
request.inviteeEmail.toLowerCase() !== email &&
|
||
request.inviterEmail.toLowerCase() !== email &&
|
||
request.inviterUserId !== user.id
|
||
);
|
||
data.taskManagerMemberships = data.taskManagerMemberships.filter((membership) => membership.userId !== user.id);
|
||
data.taskManagerProjectMemberships = data.taskManagerProjectMemberships.filter((membership) => membership.userId !== user.id);
|
||
data.syncStatuses = data.syncStatuses.filter((syncStatus) => {
|
||
if (syncStatus.objectId === user.id || syncStatus.objectName?.toLowerCase?.() === email) return false;
|
||
if (removedInviteIds.has(syncStatus.objectId)) return false;
|
||
if (removedAccessRequestIds.has(syncStatus.objectId)) return false;
|
||
if (removedTaskerInviteRequestIds.has(syncStatus.objectId)) return false;
|
||
if (removedMembershipIds.has(syncStatus.objectId)) return false;
|
||
return true;
|
||
});
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Пользователь удалён полностью",
|
||
objectType: "user",
|
||
objectName: email,
|
||
clientId: null,
|
||
result: "warning",
|
||
details: `Удалены профиль, членства: ${removedMembershipIds.size}, инвайты: ${removedInviteIds.size}, заявки: ${
|
||
removedAccessRequestIds.size + removedTaskerInviteRequestIds.size
|
||
}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return {
|
||
user,
|
||
affected: {
|
||
memberships: removedMembershipIds.size,
|
||
invites: removedInviteIds.size,
|
||
accessRequests: removedAccessRequestIds.size,
|
||
taskerInviteRequests: removedTaskerInviteRequestIds.size,
|
||
},
|
||
data,
|
||
};
|
||
}
|
||
|
||
async function createInvite(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const clientId = requireString(payload?.clientId, "clientId");
|
||
const client = findClientById(data, clientId);
|
||
const email = requireString(payload?.email, "email").toLowerCase();
|
||
const role = pickEnum(payload?.role, membershipRoles, "member");
|
||
const expiresAt = optionalString(payload?.expiresAt, new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString());
|
||
const invite = {
|
||
id: uniqueId(data.invites, "invite", email),
|
||
clientId,
|
||
email,
|
||
role,
|
||
invitedByUserId: actor.id,
|
||
source: "launcher",
|
||
sourceTaskerInviteRequestId: null,
|
||
sourceTaskerInviteId: null,
|
||
sourceWorkspaceSlug: null,
|
||
sourceWorkspaceName: null,
|
||
token: randomUUID(),
|
||
expiresAt,
|
||
status: "created",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.invites.push(invite);
|
||
addAuditEvent(data, actor, {
|
||
action: "Создан инвайт",
|
||
objectType: "invite",
|
||
objectName: email,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Роль: ${role}`,
|
||
});
|
||
markPendingSync(data, invite, "invite", email);
|
||
|
||
await writeData(data);
|
||
return { invite, data };
|
||
}
|
||
|
||
async function createAccessRequest(payload) {
|
||
const data = readData();
|
||
const now = isoNow();
|
||
const requestPayload = sanitizeAccessRequestPayload(payload);
|
||
const email = requestPayload.email.toLowerCase();
|
||
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||
|
||
if (existingUser?.globalStatus === "blocked") {
|
||
throw new Error("Аккаунт с этой почтой заблокирован. Обратитесь к администратору NODE.DC.");
|
||
}
|
||
|
||
if (existingUser?.globalStatus === "active") {
|
||
const hasOnlyPendingAccessRequest =
|
||
data.accessRequests.some((candidate) => candidate.email.toLowerCase() === email && candidate.status === "new") &&
|
||
data.memberships.some(
|
||
(membership) =>
|
||
membership.userId === existingUser.id &&
|
||
membership.clientId === publicPoolClientId &&
|
||
membership.source === "access_request" &&
|
||
membership.status === "disabled"
|
||
);
|
||
|
||
if (!hasOnlyPendingAccessRequest) {
|
||
throw new Error("Аккаунт с этой почтой уже существует. Войдите в NODE.DC или обратитесь к администратору.");
|
||
}
|
||
}
|
||
|
||
const user = upsertAccessRequestUser(data, requestPayload, now);
|
||
upsertAccessRequestMembership(data, user, requestPayload, { status: "disabled", now });
|
||
clearRevokedAccount(data, email);
|
||
markPendingSync(data, user, "user", user.email);
|
||
const existingRequest = data.accessRequests.find(
|
||
(candidate) => candidate.email.toLowerCase() === email && candidate.status === "new"
|
||
);
|
||
|
||
if (existingRequest) {
|
||
Object.assign(existingRequest, {
|
||
...requestPayload,
|
||
email,
|
||
updatedAt: now,
|
||
});
|
||
addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, {
|
||
action: "Обновлена публичная заявка",
|
||
objectType: "access_request",
|
||
objectName: email,
|
||
clientId: null,
|
||
result: "success",
|
||
details: requestPayload.company,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { accessRequest: existingRequest, user, data };
|
||
}
|
||
|
||
const accessRequest = {
|
||
id: uniqueId(data.accessRequests, "access_request", email),
|
||
...requestPayload,
|
||
email,
|
||
status: "new",
|
||
targetClientId: publicPoolClientId,
|
||
role: "member",
|
||
approvedInviteId: null,
|
||
reviewedByUserId: null,
|
||
reviewedAt: null,
|
||
comment: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.accessRequests.push(accessRequest);
|
||
addAuditEvent(data, { id: "public", name: requestPayload.company, email, source: "public" }, {
|
||
action: "Создана публичная заявка",
|
||
objectType: "access_request",
|
||
objectName: email,
|
||
clientId: null,
|
||
result: "success",
|
||
details: requestPayload.company,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { accessRequest, user, data };
|
||
}
|
||
|
||
async function updateAccessRequest(accessRequestId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||
|
||
if (payload?.targetClientId !== undefined) {
|
||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId);
|
||
}
|
||
|
||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||
accessRequest.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлена публичная заявка",
|
||
objectType: "access_request",
|
||
objectName: accessRequest.email,
|
||
clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId,
|
||
result: "success",
|
||
details: `Target: ${accessRequest.targetClientId}; role: ${accessRequest.role}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { accessRequest, data };
|
||
}
|
||
|
||
async function approveAccessRequest(accessRequestId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||
|
||
if (accessRequest.status === "rejected") {
|
||
throw new Error("Отклонённую заявку нельзя подтвердить без повторного запроса");
|
||
}
|
||
|
||
if (payload?.targetClientId !== undefined) {
|
||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, payload.targetClientId, accessRequest.targetClientId);
|
||
} else {
|
||
accessRequest.targetClientId = resolveAccessRequestTargetClientId(data, accessRequest.targetClientId, publicPoolClientId);
|
||
}
|
||
|
||
accessRequest.role = pickEnum(payload?.role, membershipRoles, accessRequest.role);
|
||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||
|
||
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === accessRequest.email.toLowerCase());
|
||
if (existingUser?.globalStatus === "blocked") {
|
||
throw new Error("Заблокированный аккаунт нельзя подтвердить без ручного разблокирования.");
|
||
}
|
||
|
||
const user = upsertAccessRequestUser(data, accessRequest, now);
|
||
const client = findClientById(data, accessRequest.targetClientId);
|
||
const membership = upsertAccessRequestMembership(data, user, accessRequest, {
|
||
status: "active",
|
||
clientId: client.id,
|
||
invitedByUserId: actor.id,
|
||
now,
|
||
});
|
||
|
||
if (client.id !== publicPoolClientId) {
|
||
data.memberships = data.memberships.filter(
|
||
(candidate) =>
|
||
!(
|
||
candidate.userId === user.id &&
|
||
candidate.clientId === publicPoolClientId &&
|
||
candidate.source === "access_request" &&
|
||
candidate.status === "disabled"
|
||
)
|
||
);
|
||
}
|
||
|
||
if (accessRequest.status === "approved") {
|
||
markPendingSync(data, user, "user", user.email);
|
||
await writeData(data);
|
||
return { accessRequest, user, membership, invite: null, data };
|
||
}
|
||
|
||
accessRequest.status = "approved";
|
||
accessRequest.approvedInviteId = null;
|
||
accessRequest.reviewedByUserId = actor.id;
|
||
accessRequest.reviewedAt = now;
|
||
accessRequest.updatedAt = now;
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Подтверждена публичная заявка",
|
||
objectType: "access_request",
|
||
objectName: accessRequest.email,
|
||
clientId: client.id === publicPoolClientId ? null : client.id,
|
||
result: "success",
|
||
details: `Account activated; target: ${client.name}; role: ${membership.role}`,
|
||
});
|
||
markPendingSync(data, user, "user", user.email);
|
||
|
||
await writeData(data);
|
||
return { accessRequest, user, membership, invite: null, data };
|
||
}
|
||
|
||
async function rejectAccessRequest(accessRequestId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const accessRequest = findAccessRequestById(data, accessRequestId);
|
||
const now = isoNow();
|
||
|
||
if (accessRequest.status === "approved") {
|
||
throw new Error("Подтверждённую заявку нельзя отклонить");
|
||
}
|
||
|
||
accessRequest.status = "rejected";
|
||
accessRequest.comment = nullableStringWithFallback(payload?.comment, accessRequest.comment ?? null);
|
||
accessRequest.reviewedByUserId = actor.id;
|
||
accessRequest.reviewedAt = now;
|
||
accessRequest.updatedAt = now;
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Отклонена публичная заявка",
|
||
objectType: "access_request",
|
||
objectName: accessRequest.email,
|
||
clientId: accessRequest.targetClientId === publicPoolClientId ? null : accessRequest.targetClientId,
|
||
result: "warning",
|
||
details: accessRequest.comment,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { accessRequest, data };
|
||
}
|
||
|
||
async function createTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
||
const data = readData();
|
||
const now = isoNow();
|
||
const actor = resolveActor(data, identity);
|
||
const taskerInviteId = requireString(payload?.taskerInviteId, "taskerInviteId");
|
||
const workspaceSlug = requireString(payload?.workspaceSlug, "workspaceSlug");
|
||
const workspaceName = optionalString(payload?.workspaceName, workspaceSlug);
|
||
const inviteeEmail = requireString(payload?.inviteeEmail, "inviteeEmail").toLowerCase();
|
||
const role = normalizeTaskManagerInviteRole(payload?.role);
|
||
const existingRequest = data.taskerInviteRequests.find(
|
||
(request) =>
|
||
request.taskerInviteId === taskerInviteId ||
|
||
(request.status === "new" && request.workspaceSlug === workspaceSlug && request.inviteeEmail.toLowerCase() === inviteeEmail)
|
||
);
|
||
const request =
|
||
existingRequest ??
|
||
{
|
||
id: uniqueId(data.taskerInviteRequests, "tasker_invite_request", `${workspaceSlug}-${inviteeEmail}`),
|
||
taskerInviteId,
|
||
createdAt: now,
|
||
};
|
||
|
||
Object.assign(request, {
|
||
taskerInviteId,
|
||
workspaceId: nullableStringWithFallback(payload?.workspaceId, request.workspaceId ?? null),
|
||
workspaceSlug,
|
||
workspaceName,
|
||
inviteeEmail,
|
||
role,
|
||
inviterUserId: nullableStringWithFallback(payload?.inviterUserId, request.inviterUserId ?? null),
|
||
inviterPlaneUserId: nullableStringWithFallback(payload?.inviterPlaneUserId, request.inviterPlaneUserId ?? null),
|
||
inviterEmail: requireString(payload?.inviterEmail, "inviterEmail").toLowerCase(),
|
||
inviterName: optionalString(payload?.inviterName, payload?.inviterEmail ?? "Operational Core user"),
|
||
status: existingRequest?.status && existingRequest.status !== "rejected" ? existingRequest.status : "new",
|
||
taskerInviteLink: existingRequest?.taskerInviteLink ?? null,
|
||
reviewedByUserId: existingRequest?.reviewedByUserId ?? null,
|
||
reviewedAt: existingRequest?.reviewedAt ?? null,
|
||
comment: nullableStringWithFallback(payload?.comment, existingRequest?.comment ?? null),
|
||
updatedAt: now,
|
||
});
|
||
|
||
if (!existingRequest) {
|
||
data.taskerInviteRequests.push(request);
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: existingRequest ? "Обновлена заявка workspace-инвайта" : "Создана заявка workspace-инвайта",
|
||
objectType: "tasker_invite_request",
|
||
objectName: `${workspaceSlug}:${inviteeEmail}`,
|
||
result: "success",
|
||
details: `Role: ${role}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { taskerInviteRequest: request, data };
|
||
}
|
||
|
||
async function approveTaskerInviteRequest(taskerInviteRequestId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||
const now = isoNow();
|
||
|
||
if (request.status === "rejected") {
|
||
throw new Error("Отклонённую заявку workspace-инвайта нельзя подтвердить");
|
||
}
|
||
|
||
request.status = "approved";
|
||
request.taskerInviteLink = nullableStringWithFallback(payload?.taskerInviteLink, request.taskerInviteLink ?? null);
|
||
request.platformInviteId = nullableStringWithFallback(payload?.platformInviteId, request.platformInviteId ?? null);
|
||
request.platformInviteToken = nullableStringWithFallback(payload?.platformInviteToken, request.platformInviteToken ?? null);
|
||
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||
request.reviewedByUserId = actor.id;
|
||
request.reviewedAt = now;
|
||
request.updatedAt = now;
|
||
|
||
if (request.platformInviteId) {
|
||
const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId);
|
||
if (invite) {
|
||
invite.sourceTaskerInviteLink = request.taskerInviteLink ?? invite.sourceTaskerInviteLink ?? null;
|
||
invite.updatedAt = now;
|
||
}
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Подтверждена заявка workspace-инвайта",
|
||
objectType: "tasker_invite_request",
|
||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||
result: "success",
|
||
details: request.taskerInviteLink ?? null,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { taskerInviteRequest: request, data };
|
||
}
|
||
|
||
async function ensureTaskerInvitePlatformInvite(taskerInviteRequestId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||
const now = isoNow();
|
||
const client = findClientById(data, publicPoolClientId);
|
||
let invite = request.platformInviteId ? data.invites.find((candidate) => candidate.id === request.platformInviteId) : null;
|
||
|
||
if (!invite) {
|
||
invite = data.invites.find(
|
||
(candidate) =>
|
||
candidate.source === "tasker_workspace_invite" &&
|
||
candidate.sourceTaskerInviteRequestId === request.id &&
|
||
candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase()
|
||
);
|
||
}
|
||
|
||
if (!invite) {
|
||
invite = {
|
||
id: uniqueId(data.invites, "invite", `${request.workspaceSlug}-${request.inviteeEmail}`),
|
||
clientId: client.id,
|
||
email: request.inviteeEmail,
|
||
role: "member",
|
||
invitedByUserId: request.inviterUserId || actor.id,
|
||
source: "tasker_workspace_invite",
|
||
sourceTaskerInviteRequestId: request.id,
|
||
sourceTaskerInviteId: request.taskerInviteId,
|
||
sourceWorkspaceSlug: request.workspaceSlug,
|
||
sourceWorkspaceName: request.workspaceName,
|
||
sourceTaskerInviteLink: request.taskerInviteLink ?? null,
|
||
token: randomUUID(),
|
||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||
status: "created",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.invites.push(invite);
|
||
addAuditEvent(data, actor, {
|
||
action: "Создан platform-инвайт для workspace-инвайта",
|
||
objectType: "invite",
|
||
objectName: invite.email,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `${request.workspaceSlug}; inviter: ${request.inviterEmail}`,
|
||
});
|
||
} else {
|
||
invite.clientId = client.id;
|
||
invite.role = "member";
|
||
invite.invitedByUserId = request.inviterUserId || invite.invitedByUserId || actor.id;
|
||
invite.source = "tasker_workspace_invite";
|
||
invite.sourceTaskerInviteRequestId = request.id;
|
||
invite.sourceTaskerInviteId = request.taskerInviteId;
|
||
invite.sourceWorkspaceSlug = request.workspaceSlug;
|
||
invite.sourceWorkspaceName = request.workspaceName;
|
||
invite.status = invite.status === "revoked" || invite.status === "expired" ? "created" : invite.status;
|
||
invite.updatedAt = now;
|
||
}
|
||
|
||
request.platformInviteId = invite.id;
|
||
request.platformInviteToken = invite.token;
|
||
request.updatedAt = now;
|
||
|
||
await writeData(data);
|
||
return { taskerInviteRequest: request, invite, data };
|
||
}
|
||
|
||
async function rejectTaskerInviteRequest(taskerInviteRequestId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const request = findTaskerInviteRequestById(data, taskerInviteRequestId);
|
||
const now = isoNow();
|
||
|
||
if (request.status === "approved") {
|
||
throw new Error("Подтверждённую заявку workspace-инвайта нельзя отклонить");
|
||
}
|
||
|
||
request.status = "rejected";
|
||
request.comment = nullableStringWithFallback(payload?.comment, request.comment ?? null);
|
||
request.reviewedByUserId = actor.id;
|
||
request.reviewedAt = now;
|
||
request.updatedAt = now;
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Отклонена заявка workspace-инвайта",
|
||
objectType: "tasker_invite_request",
|
||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||
result: "warning",
|
||
details: request.comment ?? null,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { taskerInviteRequest: request, data };
|
||
}
|
||
|
||
async function cancelTaskerInviteRequest(payload, identity = { name: "Operational Core", source: "tasker" }) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const request = findTaskerInviteRequestForCancellation(data, payload);
|
||
|
||
if (!request) {
|
||
return { taskerInviteRequest: null, affectedUserIds: [], data };
|
||
}
|
||
|
||
request.status = "cancelled";
|
||
request.taskerInviteLink = null;
|
||
request.comment = nullableStringWithFallback(payload?.comment, "Отозвано в Operational Core");
|
||
request.updatedAt = isoNow();
|
||
const affectedUserIds = revokeTaskerInviteServiceAccessIfOrphaned(data, request, request.updatedAt);
|
||
|
||
if (request.platformInviteId) {
|
||
const invite = data.invites.find((candidate) => candidate.id === request.platformInviteId);
|
||
if (invite && invite.status !== "accepted") {
|
||
invite.status = "revoked";
|
||
invite.updatedAt = request.updatedAt;
|
||
}
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Отозвана заявка workspace-инвайта",
|
||
objectType: "tasker_invite_request",
|
||
objectName: `${request.workspaceSlug}:${request.inviteeEmail}`,
|
||
result: "warning",
|
||
details: request.comment,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { taskerInviteRequest: request, affectedUserIds, data };
|
||
}
|
||
|
||
async function updateInvite(inviteId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const invite = findById(data.invites, inviteId, "invite");
|
||
|
||
invite.email = optionalString(payload?.email, invite.email).toLowerCase();
|
||
invite.role = pickEnum(payload?.role, membershipRoles, invite.role);
|
||
invite.expiresAt = optionalString(payload?.expiresAt, invite.expiresAt);
|
||
invite.status = pickEnum(payload?.status, new Set(["created", "sent", "accepted", "expired", "revoked"]), invite.status);
|
||
invite.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлён инвайт",
|
||
objectType: "invite",
|
||
objectName: invite.email,
|
||
clientId: invite.clientId,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, invite, "invite", invite.email);
|
||
|
||
await writeData(data);
|
||
return { invite, data };
|
||
}
|
||
|
||
async function deleteInvite(inviteId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const invite = findById(data.invites, inviteId, "invite");
|
||
|
||
data.invites = data.invites.filter((item) => item.id !== inviteId);
|
||
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== inviteId);
|
||
addAuditEvent(data, actor, {
|
||
action: "Удалён инвайт",
|
||
objectType: "invite",
|
||
objectName: invite.email,
|
||
clientId: invite.clientId,
|
||
result: "warning",
|
||
});
|
||
|
||
await writeData(data);
|
||
return { invite, data };
|
||
}
|
||
|
||
function getInviteByToken(token) {
|
||
const data = readData();
|
||
const invite = findInviteByToken(data, token);
|
||
const client = findClientById(data, invite.clientId);
|
||
const existingUser = data.users.find((user) => user.email.toLowerCase() === invite.email.toLowerCase()) ?? null;
|
||
|
||
return {
|
||
invite: toPublicInvite(invite),
|
||
client: toPublicClient(client),
|
||
redirectUrl: resolvePublicInviteRedirectUrl(invite),
|
||
account: {
|
||
exists: Boolean(existingUser?.authentikUserId),
|
||
email: invite.email,
|
||
},
|
||
};
|
||
}
|
||
|
||
function prepareInviteRegistration(token, payload = {}) {
|
||
const data = readData();
|
||
const result = applyInviteRegistration(data, token, payload, { commit: false });
|
||
|
||
return result;
|
||
}
|
||
|
||
async function commitInviteRegistration(token, payload = {}, provisioning) {
|
||
const data = readData();
|
||
const result = applyInviteRegistration(data, token, payload, { commit: true, provisioning });
|
||
|
||
await writeData(data);
|
||
return result;
|
||
}
|
||
|
||
async function acceptInvite(token, identity) {
|
||
const data = readData();
|
||
const invite = findInviteByToken(data, token);
|
||
const client = findClientById(data, invite.clientId);
|
||
const email = requireInviteIdentityEmail(identity);
|
||
const now = isoNow();
|
||
|
||
if (invite.email.toLowerCase() !== email) {
|
||
throw new Error("Этот инвайт выписан на другую почту");
|
||
}
|
||
|
||
if (invite.status === "revoked") {
|
||
throw new Error("Инвайт отозван");
|
||
}
|
||
|
||
if (invite.status === "expired" || isInviteExpired(invite)) {
|
||
invite.status = "expired";
|
||
invite.updatedAt = now;
|
||
await writeData(data);
|
||
throw new Error("Срок действия инвайта истёк");
|
||
}
|
||
validateTaskerInviteSourceCanBeAccepted(data, invite);
|
||
|
||
const actor = resolveActor(data, identity);
|
||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||
|
||
if (user?.globalStatus === "blocked") {
|
||
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||
}
|
||
|
||
if (user) {
|
||
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
||
user.name = optionalString(identity?.name, user.name);
|
||
user.avatarUrl = nullableStringWithFallback(identity?.avatarUrl, user.avatarUrl ?? null);
|
||
user.globalStatus = "active";
|
||
user.updatedAt = now;
|
||
} else {
|
||
user = {
|
||
id: uniqueId(data.users, "user", email),
|
||
authentikUserId: nullableString(identity?.sub),
|
||
name: optionalString(identity?.name, email.split("@")[0]),
|
||
email,
|
||
phone: null,
|
||
position: null,
|
||
notes: `Создан через инвайт клиента ${client.name}.`,
|
||
avatarUrl: nullableString(identity?.avatarUrl),
|
||
globalStatus: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.users.push(user);
|
||
}
|
||
|
||
let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id);
|
||
|
||
if (membership) {
|
||
membership.role = invite.role;
|
||
membership.status = "active";
|
||
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
|
||
membership.inviteId = invite.id;
|
||
membership.source = invite.source ?? membership.source ?? "launcher";
|
||
membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null;
|
||
membership.updatedAt = now;
|
||
} else {
|
||
membership = {
|
||
id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`),
|
||
clientId: invite.clientId,
|
||
userId: user.id,
|
||
role: invite.role,
|
||
status: "active",
|
||
invitedByUserId: invite.invitedByUserId ?? null,
|
||
inviteId: invite.id,
|
||
source: invite.source ?? "launcher",
|
||
sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.memberships.push(membership);
|
||
}
|
||
|
||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||
clearRevokedAccount(data, email);
|
||
invite.status = "accepted";
|
||
invite.updatedAt = now;
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Инвайт принят",
|
||
objectType: "invite",
|
||
objectName: invite.email,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Role: ${invite.role}`,
|
||
});
|
||
markPendingSync(data, user, "user", email);
|
||
|
||
await writeData(data);
|
||
return { invite, client, user, membership, data };
|
||
}
|
||
|
||
async function createGroup(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const clientId = requireString(payload?.clientId, "clientId");
|
||
const client = findById(data.clients, clientId, "client");
|
||
const groupName = optionalString(payload?.name, "Новая группа");
|
||
const group = {
|
||
id: uniqueId(data.groups, "group", `${clientId}-${groupName}`),
|
||
clientId,
|
||
name: groupName,
|
||
description: nullableString(payload?.description),
|
||
memberIds: Array.isArray(payload?.memberIds) ? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId)) : [],
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.groups.push(group);
|
||
addAuditEvent(data, actor, {
|
||
action: "Создана группа",
|
||
objectType: "group",
|
||
objectName: group.name,
|
||
clientId: client.id,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, group, "group");
|
||
|
||
await writeData(data);
|
||
return { group, data };
|
||
}
|
||
|
||
async function updateGroup(groupId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const group = findById(data.groups, groupId, "group");
|
||
|
||
group.name = optionalString(payload?.name, group.name);
|
||
group.description = nullableStringWithFallback(payload?.description, group.description ?? null);
|
||
group.memberIds = Array.isArray(payload?.memberIds)
|
||
? payload.memberIds.filter((userId) => data.users.some((user) => user.id === userId))
|
||
: group.memberIds;
|
||
group.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлена группа",
|
||
objectType: "group",
|
||
objectName: group.name,
|
||
clientId: group.clientId,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, group, "group");
|
||
|
||
await writeData(data);
|
||
return { group, data };
|
||
}
|
||
|
||
async function deleteGroup(groupId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const group = findById(data.groups, groupId, "group");
|
||
|
||
data.groups = data.groups.filter((item) => item.id !== groupId);
|
||
data.grants = data.grants.filter((grant) => !(grant.targetType === "group" && grant.targetId === groupId));
|
||
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== groupId);
|
||
addAuditEvent(data, actor, {
|
||
action: "Удалена группа",
|
||
objectType: "group",
|
||
objectName: group.name,
|
||
clientId: group.clientId,
|
||
result: "warning",
|
||
});
|
||
|
||
await writeData(data);
|
||
return { group, data };
|
||
}
|
||
|
||
async function upsertGrant(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const serviceId = requireString(payload?.serviceId, "serviceId");
|
||
const targetType = pickEnum(payload?.targetType, grantTargetTypes, "client");
|
||
const targetId = requireString(payload?.targetId, "targetId");
|
||
const service = findById(data.services, serviceId, "service");
|
||
|
||
assertGrantTargetExists(data, targetType, targetId);
|
||
|
||
const existingGrant = data.grants.find(
|
||
(grant) => grant.serviceId === serviceId && grant.targetType === targetType && grant.targetId === targetId
|
||
);
|
||
const grant =
|
||
existingGrant ??
|
||
{
|
||
id: uniqueId(data.grants, "grant", `${service.slug}-${targetType}-${targetId}`),
|
||
serviceId,
|
||
targetType,
|
||
targetId,
|
||
createdAt: now,
|
||
};
|
||
|
||
grant.appRole = pickEnum(payload?.appRole, appRoles, existingGrant?.appRole ?? "member");
|
||
grant.status = pickEnum(payload?.status, grantStatuses, existingGrant?.status ?? "active");
|
||
grant.updatedAt = now;
|
||
|
||
if (!existingGrant) {
|
||
data.grants.push(grant);
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: existingGrant ? "Обновлён доступ" : "Создан доступ",
|
||
objectType: "grant",
|
||
objectName: `${service.slug}:${targetType}:${targetId}`,
|
||
result: "success",
|
||
details: `App role: ${grant.appRole}; status: ${grant.status}`,
|
||
});
|
||
markPendingSync(data, grant, "grant", `${service.slug}:${targetType}:${targetId}`);
|
||
|
||
await writeData(data);
|
||
return { grant, data };
|
||
}
|
||
|
||
async function upsertException(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const serviceId = requireString(payload?.serviceId, "serviceId");
|
||
const userId = requireString(payload?.userId, "userId");
|
||
const type = pickEnum(payload?.type, exceptionTypes, "deny");
|
||
const service = findById(data.services, serviceId, "service");
|
||
const user = findById(data.users, userId, "user");
|
||
|
||
const existingException = data.exceptions.find(
|
||
(exception) => exception.serviceId === serviceId && exception.userId === userId
|
||
);
|
||
const exception =
|
||
existingException ??
|
||
{
|
||
id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`),
|
||
serviceId,
|
||
userId,
|
||
createdAt: now,
|
||
};
|
||
|
||
exception.type = type;
|
||
exception.reason = nullableString(payload?.reason);
|
||
exception.updatedAt = now;
|
||
|
||
if (!existingException) {
|
||
data.exceptions.push(exception);
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: existingException ? "Обновлено исключение доступа" : "Создано исключение доступа",
|
||
objectType: "exception",
|
||
objectName: `${service.slug}:${user.email}`,
|
||
result: "success",
|
||
details: `Type: ${type}`,
|
||
});
|
||
markPendingSync(data, exception, "grant", `${service.slug}:${user.email}`);
|
||
|
||
await writeData(data);
|
||
return { exception, data };
|
||
}
|
||
|
||
async function setUserServiceAccess(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const userId = requireString(payload?.userId, "userId");
|
||
const serviceId = requireString(payload?.serviceId, "serviceId");
|
||
const value = requireString(payload?.value, "value");
|
||
const user = findById(data.users, userId, "user");
|
||
const service = findById(data.services, serviceId, "service");
|
||
|
||
const directGrant = data.grants.find(
|
||
(grant) => grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId
|
||
);
|
||
data.grants = data.grants.filter(
|
||
(grant) => !(grant.serviceId === serviceId && grant.targetType === "user" && grant.targetId === userId)
|
||
);
|
||
data.exceptions = data.exceptions.filter((exception) => !(exception.serviceId === serviceId && exception.userId === userId));
|
||
|
||
if (value === "deny") {
|
||
data.exceptions.push({
|
||
id: uniqueId(data.exceptions, "exception", `${service.slug}-${user.email}`),
|
||
serviceId,
|
||
userId,
|
||
type: "deny",
|
||
reason: "Создано из матрицы доступа.",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
});
|
||
} else if (appRoles.has(value) && value !== "owner") {
|
||
data.grants.push({
|
||
id: directGrant?.id ?? uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`),
|
||
serviceId,
|
||
targetType: "user",
|
||
targetId: userId,
|
||
appRole: value,
|
||
status: "active",
|
||
createdAt: directGrant?.createdAt ?? now,
|
||
updatedAt: now,
|
||
});
|
||
} else if (value !== "unset") {
|
||
throw new Error(`Unsupported access value: ${value}`);
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлён доступ пользователя к сервису",
|
||
objectType: "grant",
|
||
objectName: `${user.email} / ${service.slug}`,
|
||
result: "success",
|
||
details: `Value: ${value}`,
|
||
});
|
||
markPendingSync(data, { id: `${serviceId}:${userId}` }, "grant", `${service.slug}:${user.email}`);
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function updateService(serviceId, payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const service = findById(data.services, serviceId, "service");
|
||
|
||
Object.assign(service, sanitizeServicePatch(payload, service));
|
||
Object.assign(service, syncServiceLaunchLink(service));
|
||
service.updatedAt = isoNow();
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Обновлён сервис",
|
||
objectType: "service",
|
||
objectName: service.title,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, service, "service");
|
||
|
||
await writeData(data);
|
||
return { service, data };
|
||
}
|
||
|
||
async function reorderServices(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const orderedServiceIds = Array.isArray(payload?.orderedServiceIds) ? payload.orderedServiceIds : [];
|
||
const orderById = new Map(orderedServiceIds.map((serviceId, index) => [serviceId, (index + 1) * 10]));
|
||
const now = isoNow();
|
||
|
||
data.services = data.services.map((service) => ({
|
||
...service,
|
||
order: orderById.get(service.id) ?? service.order,
|
||
updatedAt: orderById.has(service.id) ? now : service.updatedAt,
|
||
}));
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Изменён порядок сервисов",
|
||
objectType: "service",
|
||
objectName: "Каталог сервисов",
|
||
result: "success",
|
||
});
|
||
|
||
await writeData(data);
|
||
return { data };
|
||
}
|
||
|
||
async function createService(payload, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const now = isoNow();
|
||
const nextIndex = data.services.length + 1;
|
||
const nextOrder = Math.max(0, ...data.services.map((service) => Number(service.order) || 0)) + 10;
|
||
const title = optionalString(payload?.title, "New Service");
|
||
const service = syncServiceLaunchLink({
|
||
id: uniqueId(data.services, "service", title),
|
||
slug: optionalString(payload?.slug, `new-service-${nextIndex}`),
|
||
title,
|
||
subtitle: nullableString(payload?.subtitle) ?? "Новый сервис",
|
||
description: optionalString(payload?.description, "Описание сервиса для витрины."),
|
||
fullDescription: nullableString(payload?.fullDescription) ?? "Заполните описание, медиа и ссылку запуска в редакторе контента.",
|
||
url: optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"),
|
||
launchUrl: nullableString(payload?.launchUrl) ?? optionalString(payload?.url, "https://service.handhdc.ru/sso/launch"),
|
||
accentColor: nullableString(payload?.accentColor) ?? "#F7F8F4",
|
||
fallbackGradient:
|
||
nullableString(payload?.fallbackGradient) ??
|
||
"linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
|
||
coverMediaSource: nullableString(payload?.coverMediaSource) ?? "url",
|
||
coverMediaKind: nullableString(payload?.coverMediaKind) ?? "image",
|
||
ambientMediaSource: nullableString(payload?.ambientMediaSource) ?? "url",
|
||
ambientMediaKind: nullableString(payload?.ambientMediaKind) ?? "gif",
|
||
status: pickEnum(payload?.status, serviceStatuses, "hidden"),
|
||
order: nextOrder,
|
||
authentikApplicationSlug: nullableString(payload?.authentikApplicationSlug) ?? `new-service-${nextIndex}`,
|
||
authentikGroupName: nullableString(payload?.authentikGroupName) ?? `service-new-${nextIndex}`,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
});
|
||
|
||
data.services.push(service);
|
||
addAuditEvent(data, actor, {
|
||
action: "Создан сервис",
|
||
objectType: "service",
|
||
objectName: service.title,
|
||
result: "success",
|
||
});
|
||
markPendingSync(data, service, "service");
|
||
|
||
await writeData(data);
|
||
return { service, data };
|
||
}
|
||
|
||
async function deleteService(serviceId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const service = findById(data.services, serviceId, "service");
|
||
|
||
data.services = data.services.filter((item) => item.id !== serviceId);
|
||
data.grants = data.grants.filter((grant) => grant.serviceId !== serviceId);
|
||
data.exceptions = data.exceptions.filter((exception) => exception.serviceId !== serviceId);
|
||
data.syncStatuses = data.syncStatuses.filter((syncStatus) => syncStatus.objectId !== serviceId);
|
||
addAuditEvent(data, actor, {
|
||
action: "Удалён сервис",
|
||
objectType: "service",
|
||
objectName: service.title,
|
||
result: "warning",
|
||
});
|
||
|
||
await writeData(data);
|
||
return { service, data };
|
||
}
|
||
|
||
async function retrySync(syncId, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const syncStatus = findById(data.syncStatuses, syncId, "sync status");
|
||
|
||
syncStatus.state = "pending";
|
||
syncStatus.error = null;
|
||
syncStatus.updatedAt = isoNow();
|
||
addAuditEvent(data, actor, {
|
||
action: "Повтор sync",
|
||
objectType: syncStatus.objectType,
|
||
objectName: syncStatus.objectName,
|
||
result: "success",
|
||
details: `Target: ${syncStatus.target}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { syncStatus, data };
|
||
}
|
||
|
||
async function markUserAuthentikProvisioned(userId, provisioning, identity) {
|
||
const data = readData();
|
||
const actor = resolveActor(data, identity);
|
||
const user = findById(data.users, userId, "user");
|
||
const now = isoNow();
|
||
|
||
user.authentikUserId = provisioning.authentikUserId ?? user.authentikUserId ?? null;
|
||
user.email = optionalString(provisioning.email, user.email).toLowerCase();
|
||
user.name = optionalString(provisioning.name, user.name);
|
||
user.updatedAt = now;
|
||
clearRevokedAccount(data, user.email);
|
||
|
||
const syncStatus = data.syncStatuses.find(
|
||
(status) => status.target === "authentik" && status.objectType === "user" && status.objectId === user.id
|
||
);
|
||
const objectName = user.email;
|
||
|
||
if (syncStatus) {
|
||
syncStatus.objectName = objectName;
|
||
syncStatus.state = "synced";
|
||
syncStatus.lastSyncAt = now;
|
||
syncStatus.error = null;
|
||
syncStatus.updatedAt = now;
|
||
} else {
|
||
data.syncStatuses.push({
|
||
id: uniqueId(data.syncStatuses, "sync", `user-${user.id}`),
|
||
objectId: user.id,
|
||
objectName,
|
||
objectType: "user",
|
||
target: "authentik",
|
||
state: "synced",
|
||
lastSyncAt: now,
|
||
error: null,
|
||
updatedAt: now,
|
||
});
|
||
}
|
||
|
||
addAuditEvent(data, actor, {
|
||
action: "Пользователь синхронизирован в Authentik",
|
||
objectType: "user",
|
||
objectName,
|
||
result: "success",
|
||
details: `Groups: ${(provisioning.groups ?? []).join(", ") || "none"}`,
|
||
});
|
||
|
||
await writeData(data);
|
||
return { user, data };
|
||
}
|
||
|
||
function buildAuthentikSyncPlan() {
|
||
const data = readData();
|
||
|
||
return {
|
||
mode: "dry-run",
|
||
source: "launcher-control-plane",
|
||
target: "authentik",
|
||
users: data.users.map((user) => ({
|
||
id: user.id,
|
||
authentikUserId: user.authentikUserId ?? null,
|
||
email: user.email,
|
||
name: user.name,
|
||
avatarUrl: user.avatarUrl ?? null,
|
||
active: user.globalStatus === "active",
|
||
})),
|
||
groups: [
|
||
"nodedc:superadmin",
|
||
"nodedc:launcher:admin",
|
||
"nodedc:launcher:user",
|
||
...data.services.flatMap((service) => (service.authentikGroupName ? [service.authentikGroupName] : [])),
|
||
...data.groups.map((group) => `client:${group.clientId}:group:${slugify(group.name)}`),
|
||
],
|
||
accessProjection: {
|
||
services: data.services.length,
|
||
grants: data.grants.filter((grant) => grant.status === "active").length,
|
||
exceptions: data.exceptions.length,
|
||
pendingSyncObjects: data.syncStatuses.filter((syncStatus) => syncStatus.target === "authentik" && syncStatus.state === "pending").length,
|
||
},
|
||
};
|
||
}
|
||
|
||
function getWritableStorageRoots() {
|
||
const roots = [publicStorageRoot];
|
||
|
||
if (existsSync(join(projectRoot, "dist"))) {
|
||
roots.push(distStorageRoot);
|
||
}
|
||
|
||
return roots;
|
||
}
|
||
|
||
function getLoginAccountStatus(email) {
|
||
const data = readData();
|
||
const normalizedEmail = normalizeEmail(email);
|
||
|
||
if (!normalizedEmail) {
|
||
return { status: "unknown" };
|
||
}
|
||
|
||
const existingUser = data.users.find((user) => normalizeEmail(user.email) === normalizedEmail);
|
||
if (existingUser) {
|
||
return { status: "unknown" };
|
||
}
|
||
|
||
const revokedAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === normalizedEmail);
|
||
if (revokedAccount) {
|
||
return { status: "revoked", revokedAt: revokedAccount.revokedAt ?? null };
|
||
}
|
||
|
||
const hardDeleteAuditEvent = data.auditEvents.find(
|
||
(event) =>
|
||
normalizeEmail(event.objectName) === normalizedEmail &&
|
||
event.objectType === "user" &&
|
||
event.action === "Пользователь удалён полностью"
|
||
);
|
||
|
||
return hardDeleteAuditEvent ? { status: "revoked", revokedAt: hardDeleteAuditEvent.at ?? null } : { status: "unknown" };
|
||
}
|
||
|
||
return {
|
||
approveAccessRequest,
|
||
approveTaskerInviteRequest,
|
||
buildAuthentikSyncPlan,
|
||
cancelTaskerInviteRequest,
|
||
createAccessRequest,
|
||
createTaskerInviteRequest,
|
||
createClient,
|
||
createGroup,
|
||
createInvite,
|
||
createService,
|
||
createUser,
|
||
deleteClient,
|
||
deleteGroup,
|
||
deleteInvite,
|
||
deleteMembership,
|
||
deleteService,
|
||
deleteUser,
|
||
rejectAccessRequest,
|
||
rejectTaskerInviteRequest,
|
||
acceptInvite,
|
||
commitInviteRegistration,
|
||
getInviteByToken,
|
||
getLoginAccountStatus,
|
||
getSnapshot,
|
||
ensureTaskerInvitePlatformInvite,
|
||
prepareInviteRegistration,
|
||
readData,
|
||
replaceData,
|
||
reorderServices,
|
||
retrySync,
|
||
markUserAuthentikProvisioned,
|
||
recordTaskManagerProjectMembership,
|
||
recordTaskManagerWorkspaceMembership,
|
||
removeTaskManagerProjectMembership,
|
||
removeTaskManagerWorkspaceMembership,
|
||
setUserServiceAccess,
|
||
updateAccessRequest,
|
||
updateClient,
|
||
updateGroup,
|
||
updateInvite,
|
||
updateMembership,
|
||
updateService,
|
||
updateSettings,
|
||
updateUserProfile,
|
||
upsertException,
|
||
upsertGrant,
|
||
writeData,
|
||
};
|
||
}
|
||
|
||
function normalizeData(payload) {
|
||
const data = typeof payload === "object" && payload !== null ? { ...payload } : {};
|
||
|
||
for (const key of collectionKeys) {
|
||
if (!Array.isArray(data[key])) {
|
||
data[key] = [];
|
||
}
|
||
}
|
||
|
||
data.settings = normalizeSettings(data.settings);
|
||
data.clients = data.clients.map((client) => ({
|
||
...client,
|
||
integrations: normalizeClientIntegrations(client.integrations),
|
||
}));
|
||
data.accessRequests = data.accessRequests.map(normalizeAccessRequest).filter(Boolean);
|
||
data.revokedAccounts = data.revokedAccounts.map(normalizeRevokedAccount).filter(Boolean);
|
||
data.taskerInviteRequests = data.taskerInviteRequests.map(normalizeTaskerInviteRequest).filter(Boolean);
|
||
return data;
|
||
}
|
||
|
||
function normalizeRevokedAccount(payload) {
|
||
if (typeof payload !== "object" || payload === null) return null;
|
||
const email = normalizeEmail(payload.email);
|
||
if (!email) return null;
|
||
const now = isoNow();
|
||
|
||
return {
|
||
id: optionalString(payload.id, `revoked_account_${slugify(email)}`),
|
||
email,
|
||
name: nullableStringWithFallback(payload.name, null),
|
||
sourceUserId: nullableStringWithFallback(payload.sourceUserId, null),
|
||
authentikUserId: nullableStringWithFallback(payload.authentikUserId, null),
|
||
reason: optionalString(payload.reason, "hard_deleted"),
|
||
revokedByUserId: nullableStringWithFallback(payload.revokedByUserId, null),
|
||
revokedByUserEmail: nullableStringWithFallback(payload.revokedByUserEmail, null),
|
||
revokedByUserName: nullableStringWithFallback(payload.revokedByUserName, null),
|
||
revokedAt: optionalString(payload.revokedAt, now),
|
||
createdAt: optionalString(payload.createdAt, now),
|
||
updatedAt: optionalString(payload.updatedAt, now),
|
||
};
|
||
}
|
||
|
||
function normalizeAccessRequest(payload) {
|
||
if (typeof payload !== "object" || payload === null) return null;
|
||
const now = isoNow();
|
||
const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||
const firstName = optionalString(payload.firstName, "");
|
||
const lastName = optionalString(payload.lastName, "");
|
||
const middleName = optionalString(payload.middleName, "");
|
||
const phone = optionalString(payload.phone, "");
|
||
const company = optionalString(payload.company, "");
|
||
|
||
if (!email || !firstName || !lastName || !middleName || !phone || !company) return null;
|
||
|
||
return {
|
||
id: optionalString(payload.id, `access_request_${slugify(email)}`),
|
||
email,
|
||
firstName,
|
||
lastName,
|
||
middleName,
|
||
phone,
|
||
company,
|
||
status: pickEnum(payload.status, accessRequestStatuses, "new"),
|
||
targetClientId: typeof payload.targetClientId === "string" && payload.targetClientId.trim() ? payload.targetClientId.trim() : publicPoolClientId,
|
||
role: pickEnum(payload.role, membershipRoles, "member"),
|
||
approvedInviteId: nullableStringWithFallback(payload.approvedInviteId, null),
|
||
reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null),
|
||
reviewedAt: nullableStringWithFallback(payload.reviewedAt, null),
|
||
comment: nullableStringWithFallback(payload.comment, null),
|
||
createdAt: optionalString(payload.createdAt, now),
|
||
updatedAt: optionalString(payload.updatedAt, now),
|
||
};
|
||
}
|
||
|
||
function normalizeTaskerInviteRequest(payload) {
|
||
if (typeof payload !== "object" || payload === null) return null;
|
||
const now = isoNow();
|
||
const taskerInviteId = typeof payload.taskerInviteId === "string" ? payload.taskerInviteId.trim() : "";
|
||
const workspaceSlug = typeof payload.workspaceSlug === "string" ? payload.workspaceSlug.trim() : "";
|
||
const inviteeEmail = typeof payload.inviteeEmail === "string" ? payload.inviteeEmail.trim().toLowerCase() : "";
|
||
const inviterEmail = typeof payload.inviterEmail === "string" ? payload.inviterEmail.trim().toLowerCase() : "";
|
||
|
||
if (!taskerInviteId || !workspaceSlug || !inviteeEmail || !inviterEmail) return null;
|
||
|
||
return {
|
||
id: optionalString(payload.id, `tasker_invite_request_${slugify(`${workspaceSlug}-${inviteeEmail}`)}`),
|
||
taskerInviteId,
|
||
workspaceId: nullableStringWithFallback(payload.workspaceId, null),
|
||
workspaceSlug,
|
||
workspaceName: optionalString(payload.workspaceName, workspaceSlug),
|
||
inviteeEmail,
|
||
role: normalizeTaskManagerInviteRole(payload.role),
|
||
inviterUserId: nullableStringWithFallback(payload.inviterUserId, null),
|
||
inviterPlaneUserId: nullableStringWithFallback(payload.inviterPlaneUserId, null),
|
||
inviterEmail,
|
||
inviterName: optionalString(payload.inviterName, inviterEmail),
|
||
status: pickEnum(payload.status, taskerInviteRequestStatuses, "new"),
|
||
taskerInviteLink: nullableStringWithFallback(payload.taskerInviteLink, null),
|
||
platformInviteId: nullableStringWithFallback(payload.platformInviteId, null),
|
||
platformInviteToken: nullableStringWithFallback(payload.platformInviteToken, null),
|
||
reviewedByUserId: nullableStringWithFallback(payload.reviewedByUserId, null),
|
||
reviewedAt: nullableStringWithFallback(payload.reviewedAt, null),
|
||
comment: nullableStringWithFallback(payload.comment, null),
|
||
createdAt: optionalString(payload.createdAt, now),
|
||
updatedAt: optionalString(payload.updatedAt, now),
|
||
};
|
||
}
|
||
|
||
function normalizeSettings(payload) {
|
||
const settings = typeof payload === "object" && payload !== null ? payload : {};
|
||
const brand = typeof settings.brand === "object" && settings.brand !== null ? settings.brand : {};
|
||
const taskManager = typeof settings.taskManager === "object" && settings.taskManager !== null ? settings.taskManager : {};
|
||
|
||
return {
|
||
brand: {
|
||
logoLinkUrl: optionalString(brand.logoLinkUrl, defaultSettings.brand.logoLinkUrl),
|
||
},
|
||
taskManager: {
|
||
workspaceCreationPolicy: pickEnum(
|
||
taskManager.workspaceCreationPolicy,
|
||
new Set(["any_authorized_user", "task_admins_only", "disabled"]),
|
||
defaultSettings.taskManager.workspaceCreationPolicy
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeClientIntegrations(payload, fallback = {}) {
|
||
const integrations = typeof payload === "object" && payload !== null ? payload : {};
|
||
const fallbackIntegrations = typeof fallback === "object" && fallback !== null ? fallback : {};
|
||
const taskManager = typeof integrations.taskManager === "object" && integrations.taskManager !== null ? integrations.taskManager : {};
|
||
const fallbackTaskManager =
|
||
typeof fallbackIntegrations.taskManager === "object" && fallbackIntegrations.taskManager !== null ? fallbackIntegrations.taskManager : {};
|
||
const workspaces = normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager);
|
||
const primaryWorkspace = workspaces.find((workspace) => workspace.isPrimary) ?? workspaces[0] ?? null;
|
||
|
||
return {
|
||
taskManager: {
|
||
workspaceSlug: primaryWorkspace?.slug ?? null,
|
||
workspaceName: primaryWorkspace?.name ?? null,
|
||
workspaces,
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeTaskManagerWorkspaces(taskManager, fallbackTaskManager = {}) {
|
||
const sourceWorkspaces = Array.isArray(taskManager.workspaces)
|
||
? taskManager.workspaces
|
||
: Array.isArray(fallbackTaskManager.workspaces)
|
||
? fallbackTaskManager.workspaces
|
||
: [];
|
||
const legacySlug = nullableStringWithFallback(taskManager.workspaceSlug, fallbackTaskManager.workspaceSlug ?? null);
|
||
const legacyName = nullableStringWithFallback(taskManager.workspaceName, fallbackTaskManager.workspaceName ?? null);
|
||
const bySlug = new Map();
|
||
|
||
for (const item of sourceWorkspaces) {
|
||
if (typeof item !== "object" || item === null) continue;
|
||
const slug = nullableString(item.slug);
|
||
if (!slug) continue;
|
||
bySlug.set(slug, {
|
||
slug,
|
||
name: nullableStringWithFallback(item.name, null),
|
||
isPrimary: item.isPrimary === true,
|
||
managedBy: normalizeTaskManagerWorkspaceManagedBy(item.managedBy),
|
||
});
|
||
}
|
||
|
||
if (legacySlug && !bySlug.has(legacySlug)) {
|
||
bySlug.set(legacySlug, { slug: legacySlug, name: legacyName, isPrimary: true, managedBy: "launcher" });
|
||
}
|
||
|
||
const workspaces = [...bySlug.values()];
|
||
if (!workspaces.length) return [];
|
||
|
||
if (!workspaces.some((workspace) => workspace.isPrimary)) {
|
||
workspaces[0].isPrimary = true;
|
||
}
|
||
|
||
let primarySeen = false;
|
||
return workspaces.map((workspace) => {
|
||
const isPrimary = workspace.isPrimary && !primarySeen;
|
||
if (isPrimary) primarySeen = true;
|
||
return {
|
||
slug: workspace.slug,
|
||
name: workspace.name ?? null,
|
||
isPrimary,
|
||
managedBy: normalizeTaskManagerWorkspaceManagedBy(workspace.managedBy),
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeTaskManagerWorkspaceManagedBy(value) {
|
||
return taskManagerWorkspaceManagedByValues.has(value) ? value : "launcher";
|
||
}
|
||
|
||
function normalizeTaskManagerMembershipRole(value) {
|
||
return value === "guest" || value === "admin" || value === "member" ? value : "member";
|
||
}
|
||
|
||
function upsertTaskManagerMembership(data, payload) {
|
||
const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug");
|
||
const existingMembership = data.taskManagerMemberships.find(
|
||
(membership) => membership.clientId === payload.clientId && membership.userId === payload.userId && membership.workspaceSlug === workspaceSlug
|
||
);
|
||
const nextMembership = {
|
||
id: existingMembership?.id ?? uniqueId(data.taskManagerMemberships, "tasker_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}`),
|
||
clientId: payload.clientId,
|
||
userId: payload.userId,
|
||
workspaceSlug,
|
||
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
|
||
role: normalizeTaskManagerMembershipRole(payload.role),
|
||
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
|
||
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
|
||
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
|
||
updatedAt: isoNow(),
|
||
};
|
||
|
||
if (existingMembership) {
|
||
Object.assign(existingMembership, nextMembership);
|
||
return existingMembership;
|
||
}
|
||
|
||
data.taskManagerMemberships.push(nextMembership);
|
||
return nextMembership;
|
||
}
|
||
|
||
function upsertTaskManagerProjectMembership(data, payload) {
|
||
const workspaceSlug = requireString(payload.workspaceSlug, "workspaceSlug");
|
||
const projectId = requireString(payload.projectId, "projectId");
|
||
const existingMembership = data.taskManagerProjectMemberships.find(
|
||
(membership) =>
|
||
membership.clientId === payload.clientId &&
|
||
membership.userId === payload.userId &&
|
||
membership.workspaceSlug === workspaceSlug &&
|
||
membership.projectId === projectId
|
||
);
|
||
const nextMembership = {
|
||
id:
|
||
existingMembership?.id ??
|
||
uniqueId(data.taskManagerProjectMemberships, "tasker_project_mem", `${payload.clientId}-${payload.userId}-${workspaceSlug}-${projectId}`),
|
||
clientId: payload.clientId,
|
||
userId: payload.userId,
|
||
workspaceSlug,
|
||
workspaceName: nullableStringWithFallback(payload.workspaceName, existingMembership?.workspaceName ?? null),
|
||
projectId,
|
||
projectIdentifier: nullableStringWithFallback(payload.projectIdentifier, existingMembership?.projectIdentifier ?? null),
|
||
projectName: nullableStringWithFallback(payload.projectName, existingMembership?.projectName ?? null),
|
||
role: normalizeTaskManagerMembershipRole(payload.role),
|
||
managedBy: normalizeTaskManagerWorkspaceManagedBy(payload.managedBy ?? existingMembership?.managedBy),
|
||
planeUserId: nullableStringWithFallback(payload.planeUserId, existingMembership?.planeUserId ?? null),
|
||
planeRole: typeof payload.planeRole === "number" ? payload.planeRole : (existingMembership?.planeRole ?? null),
|
||
updatedAt: isoNow(),
|
||
};
|
||
|
||
if (existingMembership) {
|
||
Object.assign(existingMembership, nextMembership);
|
||
return existingMembership;
|
||
}
|
||
|
||
data.taskManagerProjectMemberships.push(nextMembership);
|
||
return nextMembership;
|
||
}
|
||
|
||
function resolveActor(data, identity) {
|
||
const user = data.users.find(
|
||
(item) =>
|
||
(identity?.sub && item.authentikUserId === identity.sub) ||
|
||
(identity?.email && item.email.toLowerCase() === identity.email.toLowerCase())
|
||
);
|
||
|
||
if (user) {
|
||
return {
|
||
id: user.id,
|
||
name: user.name,
|
||
email: user.email,
|
||
source: "launcher",
|
||
};
|
||
}
|
||
|
||
return {
|
||
id: identity?.sub ? `oidc:${identity.sub}` : "system",
|
||
name: identity?.name || identity?.email || "System",
|
||
email: identity?.email || null,
|
||
source: "oidc",
|
||
};
|
||
}
|
||
|
||
function addAuditEvent(data, actor, event) {
|
||
data.auditEvents.push({
|
||
id: uniqueId(data.auditEvents, "audit", event.objectName ?? event.objectType ?? "event"),
|
||
at: isoNow(),
|
||
actorUserId: actor.id,
|
||
actorName: actor.name,
|
||
action: event.action,
|
||
objectType: event.objectType,
|
||
objectName: event.objectName,
|
||
clientId: event.clientId ?? null,
|
||
result: event.result ?? "success",
|
||
details: event.details ?? null,
|
||
});
|
||
}
|
||
|
||
function upsertRevokedAccount(data, user, actor) {
|
||
const email = normalizeEmail(user.email);
|
||
if (!email) return null;
|
||
const now = isoNow();
|
||
const existingAccount = data.revokedAccounts.find((account) => normalizeEmail(account.email) === email);
|
||
const patch = {
|
||
email,
|
||
name: user.name ?? null,
|
||
sourceUserId: user.id,
|
||
authentikUserId: user.authentikUserId ?? null,
|
||
reason: "hard_deleted",
|
||
revokedByUserId: actor.id ?? null,
|
||
revokedByUserEmail: actor.email ?? null,
|
||
revokedByUserName: actor.name ?? null,
|
||
revokedAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
if (existingAccount) {
|
||
Object.assign(existingAccount, patch);
|
||
return existingAccount;
|
||
}
|
||
|
||
const revokedAccount = {
|
||
id: uniqueId(data.revokedAccounts, "revoked_account", email),
|
||
...patch,
|
||
createdAt: now,
|
||
};
|
||
data.revokedAccounts.push(revokedAccount);
|
||
return revokedAccount;
|
||
}
|
||
|
||
function clearRevokedAccount(data, email) {
|
||
const normalizedEmail = normalizeEmail(email);
|
||
if (!normalizedEmail) return;
|
||
data.revokedAccounts = data.revokedAccounts.filter((account) => normalizeEmail(account.email) !== normalizedEmail);
|
||
}
|
||
|
||
function markPendingSync(data, object, objectType, objectName = object.name ?? object.email ?? object.id) {
|
||
const now = isoNow();
|
||
const existingStatus = data.syncStatuses.find(
|
||
(status) => status.target === "authentik" && status.objectType === objectType && status.objectId === object.id
|
||
);
|
||
|
||
if (existingStatus) {
|
||
existingStatus.objectName = objectName;
|
||
existingStatus.state = "pending";
|
||
existingStatus.error = null;
|
||
existingStatus.updatedAt = now;
|
||
return;
|
||
}
|
||
|
||
data.syncStatuses.push({
|
||
id: uniqueId(data.syncStatuses, "sync", `${objectType}-${object.id}`),
|
||
objectId: object.id,
|
||
objectName,
|
||
objectType,
|
||
target: "authentik",
|
||
state: "pending",
|
||
lastSyncAt: null,
|
||
error: null,
|
||
updatedAt: now,
|
||
});
|
||
}
|
||
|
||
async function writeJsonAtomically(filePath, payload) {
|
||
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||
|
||
await writeFile(tempPath, payload, "utf8");
|
||
await rename(tempPath, filePath);
|
||
}
|
||
|
||
function assertGrantTargetExists(data, targetType, targetId) {
|
||
if (targetType === "client") {
|
||
findClientById(data, targetId);
|
||
} else if (targetType === "group") {
|
||
findById(data.groups, targetId, "group");
|
||
} else {
|
||
findById(data.users, targetId, "user");
|
||
}
|
||
}
|
||
|
||
function findInviteByToken(data, token) {
|
||
const normalizedToken = requireString(token, "token");
|
||
const invite = data.invites.find((candidate) => candidate.token === normalizedToken);
|
||
|
||
if (!invite) {
|
||
throw new Error("Инвайт не найден");
|
||
}
|
||
|
||
return invite;
|
||
}
|
||
|
||
function requireInviteIdentityEmail(identity) {
|
||
const email = typeof identity?.email === "string" ? identity.email.trim().toLowerCase() : "";
|
||
|
||
if (!email) {
|
||
throw new Error("Для принятия инвайта нужна подтверждённая почта");
|
||
}
|
||
|
||
return email;
|
||
}
|
||
|
||
function isInviteExpired(invite) {
|
||
if (!invite.expiresAt) return false;
|
||
return Number.isFinite(Date.parse(invite.expiresAt)) && Date.parse(invite.expiresAt) <= Date.now();
|
||
}
|
||
|
||
function toPublicInvite(invite) {
|
||
return {
|
||
id: invite.id,
|
||
email: invite.email,
|
||
role: invite.role,
|
||
expiresAt: invite.expiresAt,
|
||
status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status,
|
||
source: invite.source ?? "launcher",
|
||
sourceWorkspaceName: invite.sourceWorkspaceName ?? null,
|
||
sourceWorkspaceSlug: invite.sourceWorkspaceSlug ?? null,
|
||
};
|
||
}
|
||
|
||
function resolvePublicInviteRedirectUrl(invite) {
|
||
if (invite?.source === "tasker_workspace_invite" && invite.sourceTaskerInviteRequestId) {
|
||
return `/tasker-workspace-invite/${encodeURIComponent(invite.sourceTaskerInviteRequestId)}`;
|
||
}
|
||
|
||
return "/";
|
||
}
|
||
|
||
function toPublicClient(client) {
|
||
return {
|
||
id: client.id,
|
||
name: client.name,
|
||
status: client.status,
|
||
};
|
||
}
|
||
|
||
function ensureTaskerInviteServiceAccess(data, invite, user, now) {
|
||
if (invite.source !== "tasker_workspace_invite") {
|
||
return null;
|
||
}
|
||
|
||
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
||
if (!service) {
|
||
return null;
|
||
}
|
||
|
||
const taskerInviteRequest = invite.sourceTaskerInviteRequestId
|
||
? data.taskerInviteRequests.find((request) => request.id === invite.sourceTaskerInviteRequestId)
|
||
: null;
|
||
const requestedAppRole = taskerInviteRequest?.role === "guest" ? "viewer" : "member";
|
||
const existingGrant = data.grants.find(
|
||
(grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id
|
||
);
|
||
const existingException = data.exceptions.find((exception) => exception.serviceId === service.id && exception.userId === user.id);
|
||
|
||
if (existingException?.type === "deny") {
|
||
return null;
|
||
}
|
||
|
||
if (existingGrant) {
|
||
existingGrant.status = "active";
|
||
existingGrant.appRole =
|
||
existingGrant.appRole === "admin" || existingGrant.appRole === "owner" ? existingGrant.appRole : requestedAppRole;
|
||
existingGrant.updatedAt = now;
|
||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||
return existingGrant;
|
||
}
|
||
|
||
const grant = {
|
||
id: uniqueId(data.grants, "grant", `${service.slug}-user-${user.email}`),
|
||
serviceId: service.id,
|
||
targetType: "user",
|
||
targetId: user.id,
|
||
appRole: requestedAppRole,
|
||
status: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.grants.push(grant);
|
||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||
return grant;
|
||
}
|
||
|
||
function findTaskerInviteRequestForCancellation(data, payload) {
|
||
const requestId = nullableString(payload?.requestId);
|
||
const taskerInviteId = nullableString(payload?.taskerInviteId);
|
||
const workspaceSlug = nullableString(payload?.workspaceSlug);
|
||
const inviteeEmail = nullableString(payload?.inviteeEmail)?.toLowerCase() ?? null;
|
||
|
||
if (requestId || taskerInviteId) {
|
||
const request = data.taskerInviteRequests.find(
|
||
(candidate) => (requestId && candidate.id === requestId) || (taskerInviteId && candidate.taskerInviteId === taskerInviteId)
|
||
);
|
||
if (request) return request;
|
||
}
|
||
|
||
if (!workspaceSlug || !inviteeEmail) {
|
||
return null;
|
||
}
|
||
|
||
return data.taskerInviteRequests
|
||
.filter(
|
||
(candidate) =>
|
||
candidate.workspaceSlug === workspaceSlug &&
|
||
candidate.inviteeEmail.toLowerCase() === inviteeEmail
|
||
)
|
||
.sort((left, right) => {
|
||
if (left.status !== "cancelled" && right.status === "cancelled") return -1;
|
||
if (left.status === "cancelled" && right.status !== "cancelled") return 1;
|
||
return Date.parse(right.updatedAt ?? right.createdAt ?? 0) - Date.parse(left.updatedAt ?? left.createdAt ?? 0);
|
||
})[0] ?? null;
|
||
}
|
||
|
||
function revokeTaskerInviteServiceAccessIfOrphaned(data, request, now) {
|
||
const user = data.users.find((candidate) => candidate.email.toLowerCase() === request.inviteeEmail.toLowerCase());
|
||
const service = data.services.find((candidate) => candidate.slug === "task-manager");
|
||
|
||
if (!user || !service) {
|
||
return [];
|
||
}
|
||
|
||
const hasAnotherAcceptedWorkspaceInvite = data.taskerInviteRequests.some((candidate) => {
|
||
if (candidate.id === request.id) return false;
|
||
if (candidate.status !== "approved") return false;
|
||
if (candidate.inviteeEmail.toLowerCase() !== request.inviteeEmail.toLowerCase()) return false;
|
||
|
||
const platformInvite = candidate.platformInviteId
|
||
? data.invites.find((invite) => invite.id === candidate.platformInviteId)
|
||
: null;
|
||
return platformInvite?.status === "accepted";
|
||
});
|
||
|
||
if (hasAnotherAcceptedWorkspaceInvite) {
|
||
return [];
|
||
}
|
||
|
||
const directGrant = data.grants.find(
|
||
(grant) => grant.serviceId === service.id && grant.targetType === "user" && grant.targetId === user.id && grant.status === "active"
|
||
);
|
||
|
||
if (!directGrant || directGrant.appRole === "admin" || directGrant.appRole === "owner") {
|
||
return [];
|
||
}
|
||
|
||
data.grants = data.grants.filter((grant) => grant.id !== directGrant.id);
|
||
markPendingSync(data, { id: `${service.id}:${user.id}` }, "grant", `${service.slug}:${user.email}`);
|
||
markPendingSync(data, user, "user", user.email);
|
||
|
||
addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "tasker" }, {
|
||
action: "Снят доступ Operational Core по workspace-инвайту",
|
||
objectType: "grant",
|
||
objectName: `${user.email} / ${service.slug}`,
|
||
result: "warning",
|
||
details: `Workspace invite: ${request.workspaceSlug}; cancelled at: ${now}`,
|
||
});
|
||
|
||
return [user.id];
|
||
}
|
||
|
||
function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) {
|
||
const invite = findInviteByToken(data, token);
|
||
const client = findClientById(data, invite.clientId);
|
||
const now = isoNow();
|
||
const requestedEmail = normalizeInviteRegistrationEmail(payload?.email);
|
||
const email = invite.email.toLowerCase();
|
||
const name = optionalString(payload?.name, requestedEmail.split("@")[0]);
|
||
|
||
validateInviteCanBeRegistered(invite);
|
||
validateTaskerInviteSourceCanBeAccepted(data, invite);
|
||
|
||
if (!requestedEmail || requestedEmail !== email) {
|
||
throw new Error("Для этой почты нет активного инвайта");
|
||
}
|
||
|
||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||
|
||
if (user?.globalStatus === "blocked") {
|
||
throw new Error("Аккаунт заблокирован. Обратитесь к администратору NODE.DC.");
|
||
}
|
||
|
||
if (user?.authentikUserId && !provisioning) {
|
||
throw new Error("Аккаунт уже существует. Войдите под почтой инвайта.");
|
||
}
|
||
|
||
if (user) {
|
||
user.name = name;
|
||
user.globalStatus = "active";
|
||
user.authentikUserId = provisioning?.authentikUserId ?? user.authentikUserId ?? null;
|
||
user.updatedAt = now;
|
||
} else {
|
||
user = {
|
||
id: uniqueId(data.users, "user", email),
|
||
authentikUserId: provisioning?.authentikUserId ?? null,
|
||
name,
|
||
email,
|
||
phone: null,
|
||
position: null,
|
||
notes: `Создан через публичную регистрацию по инвайту клиента ${client.name}.`,
|
||
avatarUrl: null,
|
||
globalStatus: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.users.push(user);
|
||
}
|
||
|
||
let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id);
|
||
|
||
if (membership) {
|
||
membership.role = invite.role;
|
||
membership.status = "active";
|
||
membership.invitedByUserId = invite.invitedByUserId ?? membership.invitedByUserId ?? null;
|
||
membership.inviteId = invite.id;
|
||
membership.source = invite.source ?? membership.source ?? "launcher";
|
||
membership.sourceTaskerInviteRequestId = invite.sourceTaskerInviteRequestId ?? membership.sourceTaskerInviteRequestId ?? null;
|
||
membership.updatedAt = now;
|
||
} else {
|
||
membership = {
|
||
id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`),
|
||
clientId: invite.clientId,
|
||
userId: user.id,
|
||
role: invite.role,
|
||
status: "active",
|
||
invitedByUserId: invite.invitedByUserId ?? null,
|
||
inviteId: invite.id,
|
||
source: invite.source ?? "launcher",
|
||
sourceTaskerInviteRequestId: invite.sourceTaskerInviteRequestId ?? null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
data.memberships.push(membership);
|
||
}
|
||
|
||
ensureTaskerInviteServiceAccess(data, invite, user, now);
|
||
clearRevokedAccount(data, email);
|
||
invite.status = "accepted";
|
||
invite.updatedAt = now;
|
||
markPendingSync(data, user, "user", email);
|
||
|
||
if (commit) {
|
||
addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "invite" }, {
|
||
action: "Регистрация по инвайту",
|
||
objectType: "invite",
|
||
objectName: invite.email,
|
||
clientId: client.id,
|
||
result: "success",
|
||
details: `Role: ${invite.role}`,
|
||
});
|
||
}
|
||
|
||
return { invite, client, user, membership, data };
|
||
}
|
||
|
||
function normalizeInviteRegistrationEmail(value) {
|
||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||
}
|
||
|
||
function validateInviteCanBeRegistered(invite) {
|
||
if (invite.status === "accepted") {
|
||
throw new Error("Инвайт уже принят");
|
||
}
|
||
|
||
if (invite.status === "revoked") {
|
||
throw new Error("Инвайт отозван");
|
||
}
|
||
|
||
if (invite.status === "expired" || isInviteExpired(invite)) {
|
||
throw new Error("Срок действия инвайта истёк");
|
||
}
|
||
}
|
||
|
||
function validateTaskerInviteSourceCanBeAccepted(data, invite) {
|
||
if (invite.source !== "tasker_workspace_invite") {
|
||
return;
|
||
}
|
||
|
||
const request = invite.sourceTaskerInviteRequestId
|
||
? data.taskerInviteRequests.find((candidate) => candidate.id === invite.sourceTaskerInviteRequestId)
|
||
: null;
|
||
|
||
if (!request || request.status !== "approved") {
|
||
throw new Error("Workspace-инвайт больше не активен");
|
||
}
|
||
}
|
||
|
||
function findById(items, id, label) {
|
||
const item = items.find((candidate) => candidate.id === id);
|
||
|
||
if (!item) {
|
||
throw new Error(`Unknown ${label}: ${id}`);
|
||
}
|
||
|
||
return item;
|
||
}
|
||
|
||
function findClientById(data, clientId) {
|
||
if (clientId === publicPoolClientId) {
|
||
return publicPoolClient;
|
||
}
|
||
|
||
return findById(data.clients, clientId, "client");
|
||
}
|
||
|
||
function findAccessRequestById(data, accessRequestId) {
|
||
return findById(data.accessRequests, accessRequestId, "access_request");
|
||
}
|
||
|
||
function findTaskerInviteRequestById(data, taskerInviteRequestId) {
|
||
return findById(data.taskerInviteRequests, taskerInviteRequestId, "tasker_invite_request");
|
||
}
|
||
|
||
function resolveAccessRequestTargetClientId(data, value, fallback = publicPoolClientId) {
|
||
const clientId = optionalString(value, fallback || publicPoolClientId);
|
||
findClientById(data, clientId);
|
||
return clientId;
|
||
}
|
||
|
||
function sanitizeAccessRequestPayload(payload) {
|
||
const email = requireString(payload?.email, "email").toLowerCase();
|
||
|
||
if (!isValidEmail(email)) {
|
||
throw new Error("Введите корректную электронную почту");
|
||
}
|
||
|
||
return {
|
||
email,
|
||
firstName: requireString(payload?.firstName, "firstName").slice(0, 80),
|
||
lastName: requireString(payload?.lastName, "lastName").slice(0, 80),
|
||
middleName: requireString(payload?.middleName, "middleName").slice(0, 80),
|
||
phone: requireString(payload?.phone, "phone").slice(0, 80),
|
||
company: requireString(payload?.company, "company").slice(0, 160),
|
||
};
|
||
}
|
||
|
||
function upsertAccessRequestUser(data, requestPayload, now) {
|
||
const email = requestPayload.email.toLowerCase();
|
||
const existingUser = data.users.find((candidate) => candidate.email.toLowerCase() === email);
|
||
const userName = buildAccessRequestUserName(requestPayload);
|
||
|
||
if (existingUser) {
|
||
existingUser.name = userName;
|
||
existingUser.phone = requestPayload.phone;
|
||
existingUser.notes = `Public access request: ${requestPayload.company}`;
|
||
existingUser.globalStatus = existingUser.globalStatus === "blocked" ? "blocked" : "active";
|
||
existingUser.updatedAt = now;
|
||
return existingUser;
|
||
}
|
||
|
||
const user = {
|
||
id: uniqueId(data.users, "user", email),
|
||
authentikUserId: null,
|
||
email,
|
||
name: userName,
|
||
phone: requestPayload.phone,
|
||
position: null,
|
||
notes: `Public access request: ${requestPayload.company}`,
|
||
avatarUrl: null,
|
||
globalStatus: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.users.push(user);
|
||
return user;
|
||
}
|
||
|
||
function upsertAccessRequestMembership(data, user, requestPayload, options = {}) {
|
||
const now = options.now ?? isoNow();
|
||
const clientId = options.clientId ?? publicPoolClientId;
|
||
const role = pickEnum(requestPayload.role, membershipRoles, "member");
|
||
const existingMembership = data.memberships.find(
|
||
(membership) => membership.userId === user.id && membership.clientId === clientId
|
||
);
|
||
|
||
if (existingMembership) {
|
||
existingMembership.role = role;
|
||
existingMembership.status = options.status ?? existingMembership.status;
|
||
existingMembership.invitedByUserId = options.invitedByUserId ?? existingMembership.invitedByUserId ?? null;
|
||
existingMembership.source = existingMembership.source ?? "access_request";
|
||
existingMembership.updatedAt = now;
|
||
return existingMembership;
|
||
}
|
||
|
||
const membership = {
|
||
id: uniqueId(data.memberships, "mem", `${clientId}-${user.id}`),
|
||
clientId,
|
||
userId: user.id,
|
||
role,
|
||
status: options.status ?? "disabled",
|
||
invitedByUserId: options.invitedByUserId ?? null,
|
||
inviteId: null,
|
||
source: "access_request",
|
||
sourceTaskerInviteRequestId: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
};
|
||
|
||
data.memberships.push(membership);
|
||
return membership;
|
||
}
|
||
|
||
function buildAccessRequestUserName(requestPayload) {
|
||
return [requestPayload.lastName, requestPayload.firstName, requestPayload.middleName].filter(Boolean).join(" ");
|
||
}
|
||
|
||
function normalizeTaskManagerInviteRole(value) {
|
||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : value;
|
||
|
||
if (normalized === "viewer") return "guest";
|
||
if (normalized === "owner") return "admin";
|
||
if (normalized === 5) return "guest";
|
||
if (normalized === 15) return "member";
|
||
if (normalized === 20) return "admin";
|
||
return taskManagerInviteRoles.has(normalized) ? normalized : "member";
|
||
}
|
||
|
||
function isValidEmail(email) {
|
||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||
}
|
||
|
||
function requireString(value, fieldName) {
|
||
if (typeof value !== "string" || !value.trim()) {
|
||
throw new Error(`Field ${fieldName} is required`);
|
||
}
|
||
|
||
return value.trim();
|
||
}
|
||
|
||
function optionalString(value, fallback) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||
}
|
||
|
||
function nullableString(value) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function nullableStringWithFallback(value, fallback) {
|
||
return value === undefined ? fallback : nullableString(value);
|
||
}
|
||
|
||
function normalizeEmail(value) {
|
||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||
}
|
||
|
||
function pickEnum(value, allowedValues, fallback) {
|
||
return typeof value === "string" && allowedValues.has(value) ? value : fallback;
|
||
}
|
||
|
||
function uniqueId(items, prefix, seed) {
|
||
const base = `${prefix}_${slugify(seed)}`;
|
||
let candidate = base;
|
||
let counter = 1;
|
||
const ids = new Set(items.map((item) => item.id));
|
||
|
||
while (ids.has(candidate)) {
|
||
counter += 1;
|
||
candidate = `${base}_${counter}`;
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
function sanitizeServicePatch(payload, service) {
|
||
const patch = {};
|
||
const stringFields = [
|
||
"slug",
|
||
"title",
|
||
"subtitle",
|
||
"description",
|
||
"fullDescription",
|
||
"url",
|
||
"launchUrl",
|
||
"iconUrl",
|
||
"coverImageUrl",
|
||
"coverMediaKind",
|
||
"coverMediaSource",
|
||
"coverMediaFileName",
|
||
"previewVideoUrl",
|
||
"ambientVideoUrl",
|
||
"ambientMediaKind",
|
||
"ambientMediaSource",
|
||
"ambientMediaFileName",
|
||
"accentColor",
|
||
"fallbackGradient",
|
||
"authentikApplicationSlug",
|
||
"authentikGroupName",
|
||
];
|
||
|
||
for (const field of stringFields) {
|
||
if (field in (payload ?? {})) {
|
||
patch[field] = nullableStringWithFallback(payload[field], service[field] ?? null);
|
||
}
|
||
}
|
||
|
||
if (typeof payload?.order === "number") {
|
||
patch.order = payload.order;
|
||
}
|
||
|
||
if ("isAvailableForAllNewClients" in (payload ?? {})) {
|
||
patch.isAvailableForAllNewClients = Boolean(payload.isAvailableForAllNewClients);
|
||
}
|
||
|
||
patch.status = pickEnum(payload?.status, serviceStatuses, service.status);
|
||
return patch;
|
||
}
|
||
|
||
function syncServiceLaunchLink(service) {
|
||
const launchLink = String(service.launchUrl || service.url || "").trim();
|
||
|
||
return {
|
||
...service,
|
||
url: launchLink,
|
||
launchUrl: launchLink || null,
|
||
};
|
||
}
|
||
|
||
function slugify(value) {
|
||
const slug =
|
||
String(value)
|
||
.normalize("NFKD")
|
||
.replace(/[^\w]+/g, "_")
|
||
.replace(/^_+|_+$/g, "")
|
||
.toLowerCase()
|
||
.slice(0, 80) || randomUUID().slice(0, 8);
|
||
|
||
return slug;
|
||
}
|
||
|
||
function isoNow() {
|
||
return new Date().toISOString();
|
||
}
|