Шаг 0.12 плана production-готовности: - accessibleTasksCache (45с) per userId:orgId внутри storage-метода - инвалидация в storage-слое: роли, делегирования, form access, assignees, createTask/updateTask, appRole пользователя - 9 новых юнит-тестов (65/65 зелёные)
564 lines
23 KiB
TypeScript
564 lines
23 KiB
TypeScript
import { users, forms } from "@shared/schema";
|
||
import { ragSettings, type RagSetting } from "@shared/schema";
|
||
import { embeddingQueue } from "@shared/schema";
|
||
import { llmProviders, type LlmProvider, type InsertLlmProvider } 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 { eq, and, asc, isNull, sql, inArray } from "drizzle-orm";
|
||
import {
|
||
accessibleTasksCache,
|
||
accessibleTasksKey,
|
||
invalidateAccessibleTasksForUser,
|
||
invalidateAccessibleTasksForOrg,
|
||
} from "../utils/cache";
|
||
import { RolesStorage } from "./roles.storage";
|
||
export class SystemStorage extends RolesStorage {
|
||
// =====================
|
||
// Task Roles
|
||
// =====================
|
||
|
||
async getTaskRoles(taskId: number, organizationId: number): Promise<Array<TaskRole & { role: { id: number; name: string; parentId: number | null } }>> {
|
||
const rows = await db
|
||
.select({
|
||
id: taskRoles.id,
|
||
taskId: taskRoles.taskId,
|
||
roleId: taskRoles.roleId,
|
||
organizationId: taskRoles.organizationId,
|
||
createdAt: taskRoles.createdAt,
|
||
role: {
|
||
id: roles.id,
|
||
name: roles.name,
|
||
parentId: roles.parentId,
|
||
},
|
||
})
|
||
.from(taskRoles)
|
||
.innerJoin(roles, eq(taskRoles.roleId, roles.id))
|
||
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
|
||
return rows;
|
||
}
|
||
|
||
async addTaskRole(taskId: number, roleId: number, organizationId: number): Promise<TaskRole> {
|
||
const existing = await db.select().from(taskRoles)
|
||
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.roleId, roleId)))
|
||
.limit(1);
|
||
if (existing.length > 0) return existing[0];
|
||
const [row] = await db.insert(taskRoles).values({ taskId, roleId, organizationId }).returning();
|
||
// Роль задачи меняет доступ всех членов роли (и ветку «нет ролей — доступ всем»)
|
||
invalidateAccessibleTasksForOrg(organizationId);
|
||
return row;
|
||
}
|
||
|
||
async removeTaskRole(taskId: number, roleId: number, organizationId: number): Promise<void> {
|
||
await db.delete(taskRoles).where(and(
|
||
eq(taskRoles.taskId, taskId),
|
||
eq(taskRoles.roleId, roleId),
|
||
eq(taskRoles.organizationId, organizationId),
|
||
));
|
||
invalidateAccessibleTasksForOrg(organizationId);
|
||
}
|
||
|
||
async addTaskAssignee(
|
||
taskId: number,
|
||
userId: number,
|
||
organizationId: number,
|
||
options?: { delegatedFromUserId?: number | null; pendingReview?: boolean }
|
||
): Promise<TaskAssignee> {
|
||
const existing = await db.select().from(taskAssignees)
|
||
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId)))
|
||
.limit(1);
|
||
if (existing.length > 0) {
|
||
// Явный запрос «на ознакомление» — проставляем флаг на существующей строке
|
||
if (options?.pendingReview && !existing[0].pendingReview) {
|
||
const [updated] = await db.update(taskAssignees)
|
||
.set({ pendingReview: true })
|
||
.where(eq(taskAssignees.id, existing[0].id))
|
||
.returning();
|
||
return updated;
|
||
}
|
||
return existing[0];
|
||
}
|
||
const [row] = await db.insert(taskAssignees).values({
|
||
taskId,
|
||
userId,
|
||
organizationId,
|
||
delegatedFromUserId: options?.delegatedFromUserId ?? null,
|
||
pendingReview: options?.pendingReview ?? false,
|
||
}).returning();
|
||
// Новый исполнитель получает доступ к задаче — сбрасываем его кэш доступа
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
// Хук замещения: прямое назначение резолвит активного заместителя.
|
||
// Пропускаем строки заместителей/ознакомления — иначе получили бы рекурсию.
|
||
// Динамический import разрывает цикл storage → delegation.service → storage.
|
||
if (!options?.delegatedFromUserId && !options?.pendingReview) {
|
||
import("../services/delegation.service")
|
||
.then((m) => m.applyDelegationsOnAssign(taskId, userId, organizationId))
|
||
.catch((err) => console.error("[Delegation] applyDelegationsOnAssign hook error:", err));
|
||
}
|
||
return row;
|
||
}
|
||
|
||
async removeTaskAssignee(taskId: number, userId: number, organizationId: number): Promise<void> {
|
||
await db.delete(taskAssignees).where(and(
|
||
eq(taskAssignees.taskId, taskId),
|
||
eq(taskAssignees.userId, userId),
|
||
eq(taskAssignees.organizationId, organizationId),
|
||
));
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
}
|
||
|
||
// =====================
|
||
// Task User Access
|
||
// =====================
|
||
|
||
async getTaskUserAccess(taskId: number, organizationId: number): Promise<Array<TaskUserAccess & { user: { id: number; firstName: string | null; lastName: string | null; email: string } }>> {
|
||
const rows = await db
|
||
.select({
|
||
id: taskUserAccess.id,
|
||
taskId: taskUserAccess.taskId,
|
||
userId: taskUserAccess.userId,
|
||
organizationId: taskUserAccess.organizationId,
|
||
grantedVia: taskUserAccess.grantedVia,
|
||
roleId: taskUserAccess.roleId,
|
||
statusId: taskUserAccess.statusId,
|
||
firstInteractionAt: taskUserAccess.firstInteractionAt,
|
||
revokedAt: taskUserAccess.revokedAt,
|
||
user: {
|
||
id: users.id,
|
||
firstName: users.firstName,
|
||
lastName: users.lastName,
|
||
email: users.email,
|
||
},
|
||
})
|
||
.from(taskUserAccess)
|
||
.innerJoin(users, eq(taskUserAccess.userId, users.id))
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.organizationId, organizationId)))
|
||
.orderBy(asc(taskUserAccess.firstInteractionAt));
|
||
return rows;
|
||
}
|
||
|
||
async recordTaskInteraction(taskId: number, userId: number, organizationId: number, grantedVia: string, roleId?: number | null): Promise<void> {
|
||
const existing = await db.select().from(taskUserAccess)
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
|
||
.limit(1);
|
||
if (existing.length > 0) return;
|
||
await db.insert(taskUserAccess).values({
|
||
taskId,
|
||
userId,
|
||
organizationId,
|
||
grantedVia,
|
||
roleId: roleId ?? null,
|
||
}).onConflictDoNothing();
|
||
// Первая фиксация доступа к задаче — сбрасываем кэш пользователя
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
}
|
||
|
||
async upsertTaskUserAccess(taskId: number, userId: number, organizationId: number, grantedVia: string): Promise<void> {
|
||
const existing = await db.select().from(taskUserAccess)
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
|
||
.limit(1);
|
||
if (existing.length > 0) {
|
||
if (!existing[0].revokedAt) return;
|
||
await db.update(taskUserAccess)
|
||
.set({ revokedAt: null, grantedVia })
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
} else {
|
||
await db.insert(taskUserAccess).values({
|
||
taskId,
|
||
userId,
|
||
organizationId,
|
||
grantedVia,
|
||
roleId: null,
|
||
}).onConflictDoNothing();
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
}
|
||
}
|
||
|
||
async revokeTaskUserAccess(taskId: number, userId: number, organizationId: number): Promise<void> {
|
||
await db.update(taskUserAccess)
|
||
.set({ revokedAt: new Date() })
|
||
.where(and(
|
||
eq(taskUserAccess.taskId, taskId),
|
||
eq(taskUserAccess.userId, userId),
|
||
eq(taskUserAccess.organizationId, organizationId),
|
||
isNull(taskUserAccess.revokedAt),
|
||
));
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
}
|
||
|
||
async resolveTaskAccessGrant(taskId: number, userId: number, organizationId: number): Promise<{ grantedVia: string; roleId: number | null }> {
|
||
const { roles: rolesTable, roleMembers } = await import('@shared/schema');
|
||
const assigned = await db.select({ roleId: taskRoles.roleId })
|
||
.from(taskRoles)
|
||
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
|
||
if (assigned.length === 0) return { grantedVia: 'direct', roleId: null };
|
||
const assignedIds = assigned.map(r => r.roleId);
|
||
const direct = await db.select({ roleId: roleMembers.roleId })
|
||
.from(roleMembers)
|
||
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, assignedIds)))
|
||
.limit(1);
|
||
if (direct.length > 0) return { grantedVia: 'role', roleId: direct[0].roleId };
|
||
const rolesWithParents = await db.select({ id: rolesTable.id, parentId: rolesTable.parentId })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, assignedIds));
|
||
for (const role of rolesWithParents) {
|
||
if (role.parentId !== null) {
|
||
const sup = await db.select().from(roleMembers)
|
||
.where(and(eq(roleMembers.userId, userId), eq(roleMembers.roleId, role.parentId)))
|
||
.limit(1);
|
||
if (sup.length > 0) return { grantedVia: 'supervisor', roleId: role.id };
|
||
}
|
||
}
|
||
return { grantedVia: 'direct', roleId: null };
|
||
}
|
||
|
||
async recordTaskInteractionAuto(taskId: number, userId: number, organizationId: number): Promise<void> {
|
||
const grant = await this.resolveTaskAccessGrant(taskId, userId, organizationId);
|
||
await this.recordTaskInteraction(taskId, userId, organizationId, grant.grantedVia, grant.roleId);
|
||
}
|
||
|
||
async recordTransitionAccess(taskId: number, userId: number, organizationId: number, roleId: number | null, statusId: number): Promise<void> {
|
||
const existing = await db.select().from(taskUserAccess)
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
|
||
.limit(1);
|
||
if (existing.length > 0) {
|
||
await db.update(taskUserAccess)
|
||
.set({ grantedVia: 'transition', roleId: roleId ?? null, statusId })
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)));
|
||
} else {
|
||
await db.insert(taskUserAccess).values({
|
||
taskId,
|
||
userId,
|
||
organizationId,
|
||
grantedVia: 'transition',
|
||
roleId: roleId ?? null,
|
||
statusId,
|
||
}).onConflictDoUpdate({
|
||
target: [taskUserAccess.taskId, taskUserAccess.userId],
|
||
set: { grantedVia: 'transition', roleId: roleId ?? null, statusId },
|
||
});
|
||
invalidateAccessibleTasksForUser(userId, organizationId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns the set of task IDs accessible to the given user within the organization.
|
||
* Returns null for admins (all tasks accessible — skip filtering).
|
||
*/
|
||
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']);
|
||
if (hasViewAll) {
|
||
accessibleTasksCache.set(cacheKey, null);
|
||
return null;
|
||
}
|
||
const result = await db.execute(sql`
|
||
SELECT DISTINCT t.id
|
||
FROM tasks t
|
||
INNER JOIN forms f ON t.form_id = f.id
|
||
WHERE f.organization_id = ${organizationId}
|
||
-- Форма доступна пользователю
|
||
AND (
|
||
f.visibility = 'organization'
|
||
OR f.created_by = ${userId}
|
||
OR EXISTS (
|
||
SELECT 1 FROM form_access_rules far
|
||
WHERE far.form_id = f.id
|
||
AND far.organization_id = ${organizationId}
|
||
AND (
|
||
(far.target_type = 'user' AND far.target_id = ${userId})
|
||
OR (far.target_type = 'role' AND EXISTS (
|
||
SELECT 1 FROM role_members rm_far
|
||
WHERE rm_far.role_id = far.target_id AND rm_far.user_id = ${userId}
|
||
))
|
||
)
|
||
)
|
||
)
|
||
AND (
|
||
-- Задача доступна напрямую
|
||
t.created_by = ${userId}
|
||
OR t.assigned_to = ${userId}
|
||
OR EXISTS (
|
||
SELECT 1 FROM task_assignees ta
|
||
WHERE ta.task_id = t.id AND ta.user_id = ${userId}
|
||
)
|
||
OR (
|
||
NOT EXISTS (
|
||
SELECT 1 FROM task_user_access tua_rev
|
||
WHERE tua_rev.task_id = t.id
|
||
AND tua_rev.user_id = ${userId}
|
||
AND tua_rev.revoked_at IS NOT NULL
|
||
)
|
||
AND (
|
||
EXISTS (
|
||
SELECT 1 FROM task_user_access tua
|
||
WHERE tua.task_id = t.id
|
||
AND tua.user_id = ${userId}
|
||
AND tua.revoked_at IS NULL
|
||
)
|
||
OR
|
||
EXISTS (
|
||
SELECT 1 FROM task_roles tr2
|
||
INNER JOIN role_members rm ON rm.role_id = tr2.role_id
|
||
WHERE tr2.task_id = t.id
|
||
AND tr2.organization_id = ${organizationId}
|
||
AND rm.user_id = ${userId}
|
||
)
|
||
OR
|
||
EXISTS (
|
||
SELECT 1 FROM task_roles tr3
|
||
INNER JOIN roles r ON r.id = tr3.role_id AND r.parent_id IS NOT NULL
|
||
INNER JOIN role_members rm2 ON rm2.role_id = r.parent_id
|
||
WHERE tr3.task_id = t.id
|
||
AND tr3.organization_id = ${organizationId}
|
||
AND rm2.user_id = ${userId}
|
||
)
|
||
)
|
||
)
|
||
-- Если у задачи нет ролей — доступна всем, кто имеет доступ к форме
|
||
OR NOT EXISTS (
|
||
SELECT 1 FROM task_roles tr
|
||
WHERE tr.task_id = t.id AND tr.organization_id = ${organizationId}
|
||
)
|
||
)
|
||
`);
|
||
const ids = new Set<number>();
|
||
for (const row of result.rows as { id: number }[]) {
|
||
ids.add(Number(row.id));
|
||
}
|
||
accessibleTasksCache.set(cacheKey, ids);
|
||
return ids;
|
||
}
|
||
|
||
// LLM Providers
|
||
async getLlmProviders(organizationId: number): Promise<LlmProvider[]> {
|
||
return db.select().from(llmProviders).where(eq(llmProviders.organizationId, organizationId)).orderBy(asc(llmProviders.createdAt));
|
||
}
|
||
|
||
async getLlmProvider(id: number, organizationId: number): Promise<LlmProvider | undefined> {
|
||
const [row] = await db.select().from(llmProviders).where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)));
|
||
return row || undefined;
|
||
}
|
||
|
||
async createLlmProvider(data: InsertLlmProvider): Promise<LlmProvider> {
|
||
const [row] = await db.insert(llmProviders).values(data).returning();
|
||
return row;
|
||
}
|
||
|
||
async updateLlmProvider(id: number, organizationId: number, updates: Partial<Omit<LlmProvider, 'id' | 'organizationId' | 'createdAt'>>): Promise<LlmProvider> {
|
||
const [row] = await db
|
||
.update(llmProviders)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)))
|
||
.returning();
|
||
return row;
|
||
}
|
||
|
||
async deleteLlmProvider(id: number, organizationId: number): Promise<void> {
|
||
await db.delete(llmProviders).where(and(eq(llmProviders.id, id), eq(llmProviders.organizationId, organizationId)));
|
||
}
|
||
|
||
// RAG Settings
|
||
async getRagSettings(organizationId: number): Promise<RagSetting | undefined> {
|
||
const [row] = await db.select().from(ragSettings).where(eq(ragSettings.organizationId, organizationId));
|
||
return row || undefined;
|
||
}
|
||
|
||
async upsertRagSettings(organizationId: number, updates: Partial<Omit<RagSetting, 'organizationId' | 'updatedAt'>>): Promise<RagSetting> {
|
||
const [row] = await db
|
||
.insert(ragSettings)
|
||
.values({ organizationId, ...updates })
|
||
.onConflictDoUpdate({
|
||
target: ragSettings.organizationId,
|
||
set: { ...updates, updatedAt: new Date() },
|
||
})
|
||
.returning();
|
||
return row;
|
||
}
|
||
|
||
async canUserAccessTask(taskId: number, userId: number, organizationId: number, appRole: string): Promise<boolean> {
|
||
const hasViewAll = await this.hasAppRolePermission(appRole, ['tasks.view_all']);
|
||
if (hasViewAll) return true;
|
||
const task = await this.getTask(taskId, organizationId);
|
||
if (!task) return false;
|
||
if (task.createdBy === userId || task.assignedTo === userId) return true;
|
||
|
||
// task_assignees
|
||
const { taskAssignees } = await import('@shared/schema');
|
||
const assigneeRecord = await db.select()
|
||
.from(taskAssignees)
|
||
.where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.userId, userId)))
|
||
.limit(1);
|
||
if (assigneeRecord.length > 0) return true;
|
||
|
||
const accessRecord = await db.select()
|
||
.from(taskUserAccess)
|
||
.where(and(eq(taskUserAccess.taskId, taskId), eq(taskUserAccess.userId, userId)))
|
||
.limit(1);
|
||
if (accessRecord.length > 0 && accessRecord[0].revokedAt !== null) return false;
|
||
if (accessRecord.length > 0 && accessRecord[0].revokedAt === null) return true;
|
||
|
||
const { roleMembers, roles: rolesTable } = await import('@shared/schema');
|
||
const assigned = await db.select({ roleId: taskRoles.roleId })
|
||
.from(taskRoles)
|
||
.where(and(eq(taskRoles.taskId, taskId), eq(taskRoles.organizationId, organizationId)));
|
||
const assignedIds = assigned.map(r => r.roleId);
|
||
if (assignedIds.length === 0) {
|
||
// Если у задачи нет ролей — доступ через форму
|
||
return await this.canUserAccessForm(userId, task.formId, organizationId, 'participate');
|
||
}
|
||
const direct = await db.select().from(roleMembers)
|
||
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, assignedIds)))
|
||
.limit(1);
|
||
if (direct.length > 0) return true;
|
||
const rolesWithParents = await db.select({ id: rolesTable.id, parentId: rolesTable.parentId })
|
||
.from(rolesTable)
|
||
.where(inArray(rolesTable.id, assignedIds));
|
||
const parentIds = rolesWithParents.filter(r => r.parentId !== null).map(r => r.parentId as number);
|
||
if (parentIds.length > 0) {
|
||
const sup = await db.select().from(roleMembers)
|
||
.where(and(eq(roleMembers.userId, userId), inArray(roleMembers.roleId, parentIds)))
|
||
.limit(1);
|
||
if (sup.length > 0) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// =====================
|
||
// Form Access Rules
|
||
// =====================
|
||
|
||
async getFormAccessRules(formId: number, organizationId: number) {
|
||
return db.select().from(formAccessRules)
|
||
.where(and(eq(formAccessRules.formId, formId), eq(formAccessRules.organizationId, organizationId)))
|
||
.orderBy(formAccessRules.createdAt);
|
||
}
|
||
|
||
async createFormAccessRule(data: InsertFormAccessRule) {
|
||
const [row] = await db.insert(formAccessRules).values(data).returning();
|
||
invalidateAccessibleTasksForOrg(data.organizationId);
|
||
return row;
|
||
}
|
||
|
||
async updateFormAccessRule(id: number, organizationId: number, updates: { accessLevel?: string }) {
|
||
const [row] = await db.update(formAccessRules)
|
||
.set(updates)
|
||
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)))
|
||
.returning();
|
||
invalidateAccessibleTasksForOrg(organizationId);
|
||
return row;
|
||
}
|
||
|
||
async deleteFormAccessRule(id: number, organizationId: number) {
|
||
await db.delete(formAccessRules)
|
||
.where(and(eq(formAccessRules.id, id), eq(formAccessRules.organizationId, organizationId)));
|
||
invalidateAccessibleTasksForOrg(organizationId);
|
||
}
|
||
|
||
async transferFormOwnership(formId: number, organizationId: number, newAuthorId: number) {
|
||
await db.update(forms)
|
||
.set({ createdBy: newAuthorId })
|
||
.where(and(eq(forms.id, formId), eq(forms.organizationId, organizationId)));
|
||
// Автор формы всегда имеет доступ — смена автора меняет доступ обоих пользователей
|
||
invalidateAccessibleTasksForOrg(organizationId);
|
||
}
|
||
|
||
async canUserAccessForm(
|
||
userId: number,
|
||
formId: number,
|
||
organizationId: number,
|
||
requiredLevel: 'participate' | 'view_all' | 'admin' = 'participate'
|
||
): Promise<boolean> {
|
||
const form = await this.getForm(formId, organizationId);
|
||
if (!form) return false;
|
||
// Автор всегда имеет полный доступ
|
||
if (form.createdBy === userId) return true;
|
||
// Если форма открыта для всей организации — доступ есть (для view)
|
||
if (form.visibility === 'organization' && requiredLevel !== 'admin') return true;
|
||
// Проверяем правила доступа
|
||
const rules = await db.select().from(formAccessRules)
|
||
.where(and(eq(formAccessRules.formId, formId), eq(formAccessRules.organizationId, organizationId)));
|
||
const levelRank: Record<string, number> = { participate: 1, view_all: 2, admin: 3 };
|
||
const requiredRank = levelRank[requiredLevel] ?? 1;
|
||
for (const rule of rules) {
|
||
let matches = false;
|
||
if (rule.targetType === 'user' && rule.targetId === userId) {
|
||
matches = true;
|
||
} else if (rule.targetType === 'role') {
|
||
const { roleMembers } = await import('@shared/schema');
|
||
const member = await db.select().from(roleMembers)
|
||
.where(and(eq(roleMembers.userId, userId), eq(roleMembers.roleId, rule.targetId)))
|
||
.limit(1);
|
||
if (member.length > 0) matches = true;
|
||
}
|
||
if (matches && (levelRank[rule.accessLevel] ?? 0) >= requiredRank) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// =====================
|
||
// Embedding Queue
|
||
// =====================
|
||
|
||
async enqueueEmbedding(
|
||
organizationId: number,
|
||
entityType: 'form' | 'task' | 'task_message',
|
||
entityId: number,
|
||
operation: 'upsert' | 'delete' = 'upsert'
|
||
): Promise<void> {
|
||
await db
|
||
.insert(embeddingQueue)
|
||
.values({
|
||
organizationId,
|
||
entityType,
|
||
entityId,
|
||
operation,
|
||
createdAt: new Date(),
|
||
})
|
||
.onConflictDoNothing({
|
||
target: [embeddingQueue.organizationId, embeddingQueue.entityType, embeddingQueue.entityId],
|
||
// Уникальный индекс embedding_queue_pending_entity_idx частичный
|
||
// (WHERE processed_at IS NULL) — без этого предиката Postgres не находит
|
||
// подходящий constraint и весь INSERT падает с ошибкой.
|
||
where: isNull(embeddingQueue.processedAt),
|
||
});
|
||
}
|
||
|
||
async getPendingEmbeddingQueue(
|
||
limit: number = 1000
|
||
): Promise<Array<{ id: number; organizationId: number; entityType: 'form' | 'task' | 'task_message'; entityId: number; operation: 'upsert' | 'delete' }>> {
|
||
const rows = await db
|
||
.select({
|
||
id: embeddingQueue.id,
|
||
organizationId: embeddingQueue.organizationId,
|
||
entityType: embeddingQueue.entityType,
|
||
entityId: embeddingQueue.entityId,
|
||
operation: embeddingQueue.operation,
|
||
})
|
||
.from(embeddingQueue)
|
||
.where(isNull(embeddingQueue.processedAt))
|
||
.orderBy(asc(embeddingQueue.createdAt))
|
||
.limit(limit);
|
||
return rows as Array<{ id: number; organizationId: number; entityType: 'form' | 'task' | 'task_message'; entityId: number; operation: 'upsert' | 'delete' }>;
|
||
}
|
||
|
||
async markEmbeddingQueueProcessed(ids: number[]): Promise<void> {
|
||
if (ids.length === 0) return;
|
||
await db
|
||
.update(embeddingQueue)
|
||
.set({ processedAt: new Date() })
|
||
.where(inArray(embeddingQueue.id, ids));
|
||
}
|
||
|
||
async clearEmbeddingQueue(): Promise<void> {
|
||
await db.delete(embeddingQueue);
|
||
}
|
||
}
|