perf(access): TTL-кэш getAccessibleTaskIds с точечной инвалидацией
Шаг 0.12 плана production-готовности: - accessibleTasksCache (45с) per userId:orgId внутри storage-метода - инвалидация в storage-слое: роли, делегирования, form access, assignees, createTask/updateTask, appRole пользователя - 9 новых юнит-тестов (65/65 зелёные)
This commit is contained in:
@@ -148,3 +148,17 @@
|
|||||||
- Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch).
|
- Подстрока внутри слова на проде: «емон» → находит «Ремонт…», «отельн» → находит «Котельная…» (через API, тот же путь, что HomeGlobalSearch).
|
||||||
- EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом.
|
- EXPLAIN ANALYZE на проде (1002 задачи): Index Scan по `tasks_org_updated_idx`, Execution Time 0.806 мс — мгновенность сохранена с запасом.
|
||||||
- Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает.
|
- Формат search-ответа не изменён; списковые ответы без description — MCP list_tasks подтверждает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.12] getAccessibleTaskIds: кэш + инвалидация
|
||||||
|
|
||||||
|
- Статус: ✅ done
|
||||||
|
- Зачем: самый горячий эндпоинт перестаёт деградировать линейно (аудит, Фаза 0). Переписывание EXISTS-цепочки на чистый SQL отложено до замеров после кэша (по плану).
|
||||||
|
- Что изменено:
|
||||||
|
- `server/utils/cache.ts` — `accessibleTasksCache` (TTL 45 сек), ключ `accessible:<userId>:<orgId>`, метод `invalidateSuffix`, хелперы точечной (per-user) и org-wide инвалидации.
|
||||||
|
- `server/storage/system.storage.ts` — кэш внутри `getAccessibleTaskIds` (все caller'ы получают его автоматически, код роутов не тронут); null (admin/view_all) кэшируется отдельно от miss.
|
||||||
|
- Инвалидация проставлена в storage-слое (покрывает роуты и MCP): assignees/roles/formAccess/delegation/form visibility/updateUser appRole/createTask/updateTask assignedTo.
|
||||||
|
- Как проверялось: `npm run check` чисто; `npx vitest run` 65/65 (новый tests/accessible-tasks-cache.test.ts — TTL, null vs miss, invalidateSuffix, LRU, изоляция per-user).
|
||||||
|
- Влияние на поиск/UX: нет (форматы ответов не тронуты).
|
||||||
|
- Подводные камни: stale-доступ до 45 сек теоретически возможен только при обходе storage-методов (прямые UPDATE в БД) — все кодовые пути покрыты инвалидацией; кэш in-memory — при multi-instance нужен Redis (та же оговорка, что у существующих кэшей).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { eq, and, or, isNull, isNotNull, sql, desc } from "drizzle-orm";
|
|||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { notifyTaskAssigned } from "../utils/notifyAssignee";
|
import { notifyTaskAssigned } from "../utils/notifyAssignee";
|
||||||
import { logAudit } from "../utils/audit";
|
import { logAudit } from "../utils/audit";
|
||||||
|
import { invalidateAccessibleTasksForUser } from "../utils/cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис замещения пользователей (delegation/substitution, референс — Pyrus).
|
* Сервис замещения пользователей (delegation/substitution, референс — Pyrus).
|
||||||
@@ -279,6 +280,7 @@ export async function cleanupStaleDelegates(taskId: number, organizationId: numb
|
|||||||
const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId);
|
const activeDelegateId = await getActiveDelegate(row.delegatedFromUserId as number, task.formId, organizationId);
|
||||||
if (activeDelegateId !== row.userId) {
|
if (activeDelegateId !== row.userId) {
|
||||||
await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id));
|
await db.delete(taskAssignees).where(eq(taskAssignees.id, row.id));
|
||||||
|
invalidateAccessibleTasksForUser(row.userId, organizationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -360,6 +362,9 @@ export async function createDelegation(data: {
|
|||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
// Замещение меняет доступ заместителя (и потенциально отсутствующего)
|
||||||
|
invalidateAccessibleTasksForUser(data.userId, data.organizationId);
|
||||||
|
invalidateAccessibleTasksForUser(data.delegateUserId, data.organizationId);
|
||||||
return { conflict: false, delegation };
|
return { conflict: false, delegation };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +385,10 @@ export async function updateDelegation(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
if (updated) {
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
|
invalidateAccessibleTasksForUser(updated.delegateUserId, organizationId);
|
||||||
|
}
|
||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +406,11 @@ export async function deleteDelegation(
|
|||||||
eq(userDelegations.organizationId, organizationId)
|
eq(userDelegations.organizationId, organizationId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning({ id: userDelegations.id });
|
.returning({ id: userDelegations.id, delegateUserId: userDelegations.delegateUserId });
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
|
invalidateAccessibleTasksForUser(deleted[0].delegateUserId, organizationId);
|
||||||
|
}
|
||||||
return deleted.length > 0;
|
return deleted.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../db";
|
|||||||
import { eq, and, desc, exists, sql } from "drizzle-orm";
|
import { eq, and, desc, exists, sql } from "drizzle-orm";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { UserProfileStorage } from "./user-profile.storage";
|
import { UserProfileStorage } from "./user-profile.storage";
|
||||||
|
import { invalidateAccessibleTasksForOrg } from "../utils/cache";
|
||||||
export class FormsStorage extends UserProfileStorage {
|
export class FormsStorage extends UserProfileStorage {
|
||||||
// Forms
|
// Forms
|
||||||
async getForm(id: number, organizationId: number): Promise<Form | undefined> {
|
async getForm(id: number, organizationId: number): Promise<Form | undefined> {
|
||||||
@@ -35,6 +36,10 @@ export class FormsStorage extends UserProfileStorage {
|
|||||||
.set({ ...updates, updatedAt: new Date() })
|
.set({ ...updates, updatedAt: new Date() })
|
||||||
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)))
|
.where(and(eq(forms.id, id), eq(forms.organizationId, organizationId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
// visibility/createdBy меняют доступ к задачам формы — сброс кэша по организации
|
||||||
|
if ('visibility' in updates || 'createdBy' in updates) {
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
|
}
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { users, appRoles, permissions, appRolePermissions, roleMembers, roles }
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { eq, and, asc, inArray } from "drizzle-orm";
|
import { eq, and, asc, inArray } from "drizzle-orm";
|
||||||
import { BillingStorage } from "./billing.storage";
|
import { BillingStorage } from "./billing.storage";
|
||||||
|
import { invalidateAccessibleTasksForUser, invalidateAccessibleTasksForOrg } from "../utils/cache";
|
||||||
|
|
||||||
// In-memory permission cache
|
// In-memory permission cache
|
||||||
let _permCache: Map<string, string[]> | null = null;
|
let _permCache: Map<string, string[]> | null = null;
|
||||||
@@ -163,6 +164,10 @@ export class RolesStorage extends BillingStorage {
|
|||||||
if (!manager) throw new Error('Руководитель не найден в этой организации');
|
if (!manager) throw new Error('Руководитель не найден в этой организации');
|
||||||
}
|
}
|
||||||
const [role] = await db.update(roles).set(updates).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))).returning();
|
const [role] = await db.update(roles).set(updates).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId))).returning();
|
||||||
|
// Смена parentId меняет наследование доступа по иерархии ролей — сброс по организации
|
||||||
|
if ('parentId' in updates) {
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
|
}
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +175,7 @@ export class RolesStorage extends BillingStorage {
|
|||||||
const { roles } = await import('@shared/schema');
|
const { roles } = await import('@shared/schema');
|
||||||
await db.update(roles).set({ parentId: null }).where(and(eq(roles.parentId, id), eq(roles.organizationId, organizationId)));
|
await db.update(roles).set({ parentId: null }).where(and(eq(roles.parentId, id), eq(roles.organizationId, organizationId)));
|
||||||
await db.delete(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId)));
|
await db.delete(roles).where(and(eq(roles.id, id), eq(roles.organizationId, organizationId)));
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRoleMembersByRole(roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> {
|
async getRoleMembersByRole(roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> {
|
||||||
@@ -217,6 +223,7 @@ export class RolesStorage extends BillingStorage {
|
|||||||
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
|
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
|
||||||
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
|
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
|
||||||
await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing();
|
await db.insert(roleMembers).values({ roleId, userId }).onConflictDoNothing();
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeRoleMember(roleId: number, userId: number, organizationId: number) {
|
async removeRoleMember(roleId: number, userId: number, organizationId: number) {
|
||||||
@@ -226,6 +233,7 @@ export class RolesStorage extends BillingStorage {
|
|||||||
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
|
const [targetUser] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.organizationId, organizationId)));
|
||||||
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
|
if (!targetUser) throw new Error('Пользователь не найден в этой организации');
|
||||||
await db.delete(roleMembers).where(and(eq(roleMembers.roleId, roleId), eq(roleMembers.userId, userId)));
|
await db.delete(roleMembers).where(and(eq(roleMembers.roleId, roleId), eq(roleMembers.userId, userId)));
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveRoleMember(sourceRoleId: number, targetRoleId: number | null, userId: number, organizationId: number) {
|
async moveRoleMember(sourceRoleId: number, targetRoleId: number | null, userId: number, organizationId: number) {
|
||||||
@@ -247,6 +255,7 @@ export class RolesStorage extends BillingStorage {
|
|||||||
if (targetRoleId !== null) {
|
if (targetRoleId !== null) {
|
||||||
await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing();
|
await db.insert(roleMembers).values({ roleId: targetRoleId, userId }).onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserRoles(userId: number, organizationId: number) {
|
async getUserRoles(userId: number, organizationId: number) {
|
||||||
@@ -343,6 +352,11 @@ export class RolesStorage extends BillingStorage {
|
|||||||
const values = roleIds.map(roleId => ({ userId, roleId }));
|
const values = roleIds.map(roleId => ({ userId, roleId }));
|
||||||
await db.insert(roleMembers).values(values).onConflictDoNothing();
|
await db.insert(roleMembers).values(values).onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
// organizationId не приходит аргументом — сбрасываем кэш по всем организациям пользователя
|
||||||
|
const userRows = await db.select({ organizationId: users.organizationId }).from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
if (userRows[0]?.organizationId) {
|
||||||
|
invalidateAccessibleTasksForUser(userId, userRows[0].organizationId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserOrganizationalRoles(userId: number, organizationId: number) {
|
async getUserOrganizationalRoles(userId: number, organizationId: number) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { llmProviders, type LlmProvider, type InsertLlmProvider } from "@shared/
|
|||||||
import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee, formAccessRules, type FormAccessRule, type InsertFormAccessRule } from "@shared/schema";
|
import { taskRoles, type TaskRole, taskUserAccess, type TaskUserAccess, roleMembers, roles, taskAssignees, type TaskAssignee, formAccessRules, type FormAccessRule, type InsertFormAccessRule } from "@shared/schema";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm";
|
import { eq, and, asc, isNull, sql, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
accessibleTasksCache,
|
||||||
|
accessibleTasksKey,
|
||||||
|
invalidateAccessibleTasksForUser,
|
||||||
|
invalidateAccessibleTasksForOrg,
|
||||||
|
} from "../utils/cache";
|
||||||
import { RolesStorage } from "./roles.storage";
|
import { RolesStorage } from "./roles.storage";
|
||||||
export class SystemStorage extends RolesStorage {
|
export class SystemStorage extends RolesStorage {
|
||||||
// =====================
|
// =====================
|
||||||
@@ -37,6 +43,8 @@ export class SystemStorage extends RolesStorage {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (existing.length > 0) return existing[0];
|
if (existing.length > 0) return existing[0];
|
||||||
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
|
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
|
||||||
|
// Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем»)
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +54,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
eq(taskRoles.roleId, roleId),
|
eq(taskRoles.roleId, roleId),
|
||||||
eq(taskRoles.organizationId, organizationId),
|
eq(taskRoles.organizationId, organizationId),
|
||||||
));
|
));
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTaskAssignee(
|
async addTaskAssignee(
|
||||||
@@ -75,6 +84,8 @@ export class SystemStorage extends RolesStorage {
|
|||||||
delegatedFromUserId: options?.delegatedFromUserId ?? null,
|
delegatedFromUserId: options?.delegatedFromUserId ?? null,
|
||||||
pendingReview: options?.pendingReview ?? false,
|
pendingReview: options?.pendingReview ?? false,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
// Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
// Хук замещения: прямое назначение резолвит активного заместителя.
|
// Хук замещения: прямое назначение резолвит активного заместителя.
|
||||||
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
|
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
|
||||||
// Динамический import разрывает цикл storage → delegation.service → storage.
|
// Динамический import разрывает цикл storage → delegation.service → storage.
|
||||||
@@ -92,6 +103,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
eq(taskAssignees.userId, userId),
|
eq(taskAssignees.userId, userId),
|
||||||
eq(taskAssignees.organizationId, organizationId),
|
eq(taskAssignees.organizationId, organizationId),
|
||||||
));
|
));
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -136,6 +148,8 @@ export class SystemStorage extends RolesStorage {
|
|||||||
grantedVia,
|
grantedVia,
|
||||||
roleId: roleId ?? null,
|
roleId: roleId ?? null,
|
||||||
}).onConflictDoNothing();
|
}).onConflictDoNothing();
|
||||||
|
// Первая фиксация доступа к задаче — сбрасываем кэш пользователя
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
|
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
|
||||||
@@ -147,6 +161,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
await db.update(taskUserAccess)
|
await db.update(taskUserAccess)
|
||||||
.set({ revokedAt: null, grantedVia })
|
.set({ revokedAt: null, grantedVia })
|
||||||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
|
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
} else {
|
} else {
|
||||||
await db.insert(taskUserAccess).values({
|
await db.insert(taskUserAccess).values({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -155,6 +170,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
grantedVia,
|
grantedVia,
|
||||||
roleId: null,
|
roleId: null,
|
||||||
}).onConflictDoNothing();
|
}).onConflictDoNothing();
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +183,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
eq(taskUserAccess.organizationId, organizationId),
|
eq(taskUserAccess.organizationId, organizationId),
|
||||||
isNull(taskUserAccess.revokedAt),
|
isNull(taskUserAccess.revokedAt),
|
||||||
));
|
));
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
|
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
|
||||||
@@ -220,6 +237,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
target: [taskUserAccess.taskId, taskUserAccess.userId],
|
target: [taskUserAccess.taskId, taskUserAccess.userId],
|
||||||
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
|
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
|
||||||
});
|
});
|
||||||
|
invalidateAccessibleTasksForUser(userId, organizationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +246,17 @@ export class SystemStorage extends RolesStorage {
|
|||||||
* Returns null for admins (all tasks accessible — skip filtering).
|
* Returns null for admins (all tasks accessible — skip filtering).
|
||||||
*/
|
*/
|
||||||
async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise<Set<number> | null> {
|
async getAccessibleTaskIds(userId: number, organizationId: number, appRole: string): Promise<Set<number> | null> {
|
||||||
|
// TTL-кэш: горячий путь списков задач. null (view_all) тоже кэшируется —
|
||||||
|
// TtlCache.get возвращает undefined только при промахе.
|
||||||
|
const cacheKey = accessibleTasksKey(userId, organizationId);
|
||||||
|
const cached = accessibleTasksCache.get<Set<number> | null>(cacheKey);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
|
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
|
||||||
if (hasViewAll) return null;
|
if (hasViewAll) {
|
||||||
|
accessibleTasksCache.set(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const result = await db.execute(sql`
|
const result = await db.execute(sql`
|
||||||
SELECT DISTINCT t.id
|
SELECT DISTINCT t.id
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
@@ -304,6 +331,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
for (const row of result.rows as { id: number }[]) {
|
for (const row of result.rows as { id: number }[]) {
|
||||||
ids.add(Number(row.id));
|
ids.add(Number(row.id));
|
||||||
}
|
}
|
||||||
|
accessibleTasksCache.set(cacheKey, ids);
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +441,7 @@ export class SystemStorage extends RolesStorage {
|
|||||||
|
|
||||||
async createFormAccessRule(data: InsertFormAccessRule) {
|
async createFormAccessRule(data: InsertFormAccessRule) {
|
||||||
const [row] = await db.insert(formAccessRules).values(data).returning();
|
const [row] = await db.insert(formAccessRules).values(data).returning();
|
||||||
|
invalidateAccessibleTasksForOrg(data.organizationId);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,18 +450,22 @@ export class SystemStorage extends RolesStorage {
|
|||||||
.set(updates)
|
.set(updates)
|
||||||
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)))
|
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFormAccessRule(id: number, organizationId: number) {
|
async deleteFormAccessRule(id: number, organizationId: number) {
|
||||||
await db.delete(formAccessRules)
|
await db.delete(formAccessRules)
|
||||||
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)));
|
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)));
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) {
|
async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) {
|
||||||
await db.update(forms)
|
await db.update(forms)
|
||||||
.set({ createdBy: newAuthorId })
|
.set({ createdBy: newAuthorId })
|
||||||
.where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)));
|
.where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)));
|
||||||
|
// Автор формы всегда имеет доступ — смена автора меняет доступ обоих пользователей
|
||||||
|
invalidateAccessibleTasksForOrg(organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async canUserAccessForm(
|
async canUserAccessForm(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, and, or, desc, asc, exists, sql, ilike, inArray, lt, gte, isNull }
|
|||||||
import { formatUserName } from "../utils/formatUserName";
|
import { formatUserName } from "../utils/formatUserName";
|
||||||
import { FormsStorage } from "./forms.storage";
|
import { FormsStorage } from "./forms.storage";
|
||||||
import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination";
|
import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination";
|
||||||
|
import { invalidateAccessibleTasksForUser } from "../utils/cache";
|
||||||
export class TasksCoreStorage extends FormsStorage {
|
export class TasksCoreStorage extends FormsStorage {
|
||||||
// Tasks
|
// Tasks
|
||||||
async getTask(id: number, organizationId: number): Promise<Task | undefined> {
|
async getTask(id: number, organizationId: number): Promise<Task | undefined> {
|
||||||
@@ -327,6 +328,11 @@ export class TasksCoreStorage extends FormsStorage {
|
|||||||
position,
|
position,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
// Создатель и ответственный получают доступ к новой задаче — сброс их кэша
|
||||||
|
if (task.createdBy) invalidateAccessibleTasksForUser(task.createdBy, task.organizationId);
|
||||||
|
if (task.assignedTo && task.assignedTo !== task.createdBy) {
|
||||||
|
invalidateAccessibleTasksForUser(task.assignedTo, task.organizationId);
|
||||||
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +375,10 @@ export class TasksCoreStorage extends FormsStorage {
|
|||||||
exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId))))
|
exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId))))
|
||||||
))
|
))
|
||||||
.returning();
|
.returning();
|
||||||
|
// Переназначение ответственного даёт доступ новому исполнителю
|
||||||
|
if ('assignedTo' in updates && updates.assignedTo != null) {
|
||||||
|
invalidateAccessibleTasksForUser(updates.assignedTo, organizationId);
|
||||||
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../db";
|
|||||||
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
|
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { userStatusesStorage } from "./user-statuses.storage";
|
import { userStatusesStorage } from "./user-statuses.storage";
|
||||||
|
import { invalidateAccessibleTasksForUser } from "../utils/cache";
|
||||||
|
|
||||||
export interface ListUsersOptions {
|
export interface ListUsersOptions {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -270,6 +271,10 @@ export class UsersStorage {
|
|||||||
.set({ ...updates, updatedAt: new Date() })
|
.set({ ...updates, updatedAt: new Date() })
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
// Смена appRole (например, получение tasks.view_all) меняет набор доступных задач
|
||||||
|
if ('appRole' in updates && user?.organizationId) {
|
||||||
|
invalidateAccessibleTasksForUser(id, user.organizationId);
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export class TtlCache {
|
|||||||
this.invalidate(prefix);
|
this.invalidate(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete all keys that end with the given suffix */
|
||||||
|
invalidateSuffix(suffix: string): void {
|
||||||
|
for (const key of Array.from(this.store.keys())) {
|
||||||
|
if (key.endsWith(suffix)) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a single key */
|
/** Remove a single key */
|
||||||
delete(key: string): void {
|
delete(key: string): void {
|
||||||
this.store.delete(key);
|
this.store.delete(key);
|
||||||
@@ -73,3 +82,26 @@ export class TtlCache {
|
|||||||
|
|
||||||
export const formsCache = new TtlCache(60);
|
export const formsCache = new TtlCache(60);
|
||||||
export const tasksMinimalCache = new TtlCache(60);
|
export const tasksMinimalCache = new TtlCache(60);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш множеств доступных задач (getAccessibleTaskIds).
|
||||||
|
* Ключ: accessible:<userId>:<organizationId>.
|
||||||
|
* Значение: Set<number> | null (null = admin/view_all, фильтрация не нужна).
|
||||||
|
* TTL 45 сек + явная инвалидация при изменении ролей, делегирований,
|
||||||
|
* правил доступа к формам и назначений задач.
|
||||||
|
*/
|
||||||
|
export const accessibleTasksCache = new TtlCache(45);
|
||||||
|
|
||||||
|
export function accessibleTasksKey(userId: number, organizationId: number): string {
|
||||||
|
return `accessible:${userId}:${organizationId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Точечная инвалидация для одного пользователя (назначение, членство в роли и т.п.) */
|
||||||
|
export function invalidateAccessibleTasksForUser(userId: number, organizationId: number): void {
|
||||||
|
accessibleTasksCache.delete(accessibleTasksKey(userId, organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Массовая инвалидация по организации (роли, правила доступа к формам и т.п.) */
|
||||||
|
export function invalidateAccessibleTasksForOrg(organizationId: number): void {
|
||||||
|
accessibleTasksCache.invalidateSuffix(`:${organizationId}`);
|
||||||
|
}
|
||||||
|
|||||||
103
tests/accessible-tasks-cache.test.ts
Normal file
103
tests/accessible-tasks-cache.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
TtlCache,
|
||||||
|
accessibleTasksCache,
|
||||||
|
accessibleTasksKey,
|
||||||
|
invalidateAccessibleTasksForUser,
|
||||||
|
invalidateAccessibleTasksForOrg,
|
||||||
|
} from '../server/utils/cache';
|
||||||
|
|
||||||
|
describe('TtlCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возвращает значение в пределах TTL и undefined после истечения', () => {
|
||||||
|
const cache = new TtlCache(30);
|
||||||
|
cache.set('k', new Set([1, 2, 3]));
|
||||||
|
expect(cache.get<Set<number>>('k')?.has(2)).toBe(true);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(29_000);
|
||||||
|
expect(cache.get('k')).toBeDefined();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2_000);
|
||||||
|
expect(cache.get('k')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('кэширует null как значение (отличая от промаха undefined)', () => {
|
||||||
|
const cache = new TtlCache(30);
|
||||||
|
cache.set('admin', null);
|
||||||
|
expect(cache.get('admin')).toBeNull();
|
||||||
|
expect(cache.get('missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidatePrefix удаляет ключи по префиксу', () => {
|
||||||
|
const cache = new TtlCache(30);
|
||||||
|
cache.set('tasks:1:minimal:a', 1);
|
||||||
|
cache.set('tasks:1:minimal:b', 2);
|
||||||
|
cache.set('tasks:2:minimal:a', 3);
|
||||||
|
cache.invalidatePrefix('tasks:1:minimal:');
|
||||||
|
expect(cache.get('tasks:1:minimal:a')).toBeUndefined();
|
||||||
|
expect(cache.get('tasks:1:minimal:b')).toBeUndefined();
|
||||||
|
expect(cache.get('tasks:2:minimal:a')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidateSuffix удаляет ключи по суффиксу', () => {
|
||||||
|
const cache = new TtlCache(30);
|
||||||
|
cache.set('accessible:5:1', 'a');
|
||||||
|
cache.set('accessible:7:1', 'b');
|
||||||
|
cache.set('accessible:5:2', 'c');
|
||||||
|
cache.invalidateSuffix(':1');
|
||||||
|
expect(cache.get('accessible:5:1')).toBeUndefined();
|
||||||
|
expect(cache.get('accessible:7:1')).toBeUndefined();
|
||||||
|
expect(cache.get('accessible:5:2')).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('вытесняет самый старый ключ при переполнении maxSize', () => {
|
||||||
|
const cache = new TtlCache(30, 2);
|
||||||
|
cache.set('a', 1);
|
||||||
|
cache.set('b', 2);
|
||||||
|
cache.set('c', 3);
|
||||||
|
expect(cache.get('a')).toBeUndefined();
|
||||||
|
expect(cache.get('b')).toBe(2);
|
||||||
|
expect(cache.get('c')).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibleTasksCache helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
accessibleTasksCache.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ключ имеет формат accessible:<userId>:<organizationId>', () => {
|
||||||
|
expect(accessibleTasksKey(12, 3)).toBe('accessible:12:3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('точечная инвалидация сбрасывает только одного пользователя', () => {
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(1, 1), new Set([10]));
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(2, 1), new Set([20]));
|
||||||
|
invalidateAccessibleTasksForUser(1, 1);
|
||||||
|
expect(accessibleTasksCache.get(accessibleTasksKey(1, 1))).toBeUndefined();
|
||||||
|
expect(accessibleTasksCache.get<Set<number>>(accessibleTasksKey(2, 1))?.has(20)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('инвалидация по организации сбрасывает всех её пользователей, не трогая другие организации', () => {
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(1, 1), new Set([10]));
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(2, 1), null);
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(1, 2), new Set([30]));
|
||||||
|
invalidateAccessibleTasksForOrg(1);
|
||||||
|
expect(accessibleTasksCache.get(accessibleTasksKey(1, 1))).toBeUndefined();
|
||||||
|
expect(accessibleTasksCache.get(accessibleTasksKey(2, 1))).toBeUndefined();
|
||||||
|
expect(accessibleTasksCache.get<Set<number>>(accessibleTasksKey(1, 2))?.has(30)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null (admin/view_all) кэшируется и корректно читается', () => {
|
||||||
|
accessibleTasksCache.set(accessibleTasksKey(9, 1), null);
|
||||||
|
expect(accessibleTasksCache.get(accessibleTasksKey(9, 1))).toBeNull();
|
||||||
|
expect(accessibleTasksCache.get(accessibleTasksKey(9, 2))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user