Files
iistwin/server/storage/users.storage.ts
Ильяс Султанов a27766a38b perf(tasks): лимиты/пагинация /api/tasks и дельта-синк в SQL
Шаги 0.10 и 0.9 плана production-готовности:
- дефолт limit 50, кап 200; списковые ответы без description
- курсорная пагинация (updated_at, id) + nextCursor
- since-фильтр дельта-синка перенесён в SQL (updated_at >= since)
- embedding pre-count через COUNT(*) вместо выгрузки миллиона строк
- 11 новых юнит-тестов (56/56 зелёные)

Инвариант поиска сохранён: search-ветка /api/tasks?search= не изменена
2026-09-07 22:01:25 +03:00

593 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { users, organizations, userSessions, invitations, userCustomFields, userCustomValues, roleMembers, forms, formFields, formStatuses, tasks, taskFieldValues, userOfflineSubscriptions, safeUserColumns, type User, type SafeUser, type Organization, type UserSession, type Invitation, type InsertUser, type InsertOrganization, type InsertUserSession, type InsertInvitation, type UserWithOrganization, type UserCustomField, type UserCustomValue, type CreateUserCustomField, type UpdateUserCustomField, type Task, type FormField, type FormStatus, type UserOfflineSubscription, type InsertUserOfflineSubscription } from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, asc, sql, ilike, or, gte, lte, inArray, isNull } from "drizzle-orm";
import crypto from "crypto";
import { userStatusesStorage } from "./user-statuses.storage";
export interface ListUsersOptions {
search?: string;
roleId?: number;
appRole?: string;
isActive?: boolean;
sortBy?: 'name' | 'createdAt' | 'lastLogin';
order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export interface ListUsersResult {
users: SafeUser[];
total: number;
}
export class UsersStorage {
// Organizations
async getOrganization(id: number): Promise<Organization | undefined> {
const [organization] = await db.select().from(organizations).where(eq(organizations.id, id));
return organization || undefined;
}
async getOrganizationBySlug(slug: string): Promise<Organization | undefined> {
const [organization] = await db.select().from(organizations).where(eq(organizations.slug, slug));
return organization || undefined;
}
async createOrganization(insertOrganization: InsertOrganization): Promise<Organization> {
const [organization] = await db
.insert(organizations)
.values(insertOrganization)
.returning();
return organization;
}
// Users
async getUser(id: number): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user || undefined;
}
async getUserWithOrganization(id: number): Promise<UserWithOrganization | undefined> {
const [result] = await db
.select()
.from(users)
.innerJoin(organizations, eq(users.organizationId, organizations.id))
.where(eq(users.id, id));
if (!result) return undefined;
return {
...result.users,
organization: result.organizations,
};
}
async getUserByEmail(email: string, organizationId?: number): Promise<User | undefined> {
const conditions = organizationId
? and(ilike(users.email, email), eq(users.organizationId, organizationId))
: ilike(users.email, email);
const [user] = await db.select().from(users).where(conditions);
return user || undefined;
}
async getUserByResetPasswordToken(token: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.resetPasswordToken, token));
return user || undefined;
}
async getUserByEmailAndSlug(email: string, slug: string): Promise<UserWithOrganization | undefined> {
const [result] = await db
.select()
.from(users)
.innerJoin(organizations, eq(users.organizationId, organizations.id))
.where(and(ilike(users.email, email), eq(organizations.slug, slug)));
if (!result) return undefined;
return {
...result.users,
organization: result.organizations,
};
}
// Массовая выдача пользователей клиенту — только безопасные колонки (без хэша пароля и токенов).
// options.since — дельта-синк: фильтр по updated_at на уровне SQL
// (NULL updated_at включаем для паритета со старым JS-фильтром).
async getUsersByOrganization(organizationId: number, options?: { since?: Date }): Promise<SafeUser[]> {
const conditions = [eq(users.organizationId, organizationId)];
if (options?.since) {
conditions.push(or(
gte(users.updatedAt, options.since),
isNull(users.updatedAt)
)!);
}
return await db
.select(safeUserColumns)
.from(users)
.where(and(...conditions))
.orderBy(desc(users.createdAt));
}
async listUsers(organizationId: number, options: ListUsersOptions = {}): Promise<ListUsersResult> {
const conditions: any[] = [eq(users.organizationId, organizationId)];
if (options.search) {
const term = `%${options.search}%`;
conditions.push(
or(
ilike(users.firstName, term),
ilike(users.lastName, term),
ilike(users.middleName, term),
ilike(users.email, term),
ilike(users.position, term),
ilike(users.phone, term)
)
);
}
if (options.appRole) {
conditions.push(eq(users.appRole, options.appRole));
}
if (options.isActive !== undefined) {
conditions.push(eq(users.isActive, options.isActive));
}
const whereClause = and(...conditions);
const orderColumn =
options.sortBy === 'name'
? users.lastName
: options.sortBy === 'lastLogin'
? users.lastLogin
: users.createdAt;
const orderDir = options.order === 'asc' ? asc(orderColumn) : desc(orderColumn);
const limit = options.limit ?? 50;
const offset = options.offset ?? 0;
const [countResult] = await db
.select({ total: sql<number>`count(*)::int` })
.from(users)
.where(whereClause);
let query = db
.select(safeUserColumns)
.from(users)
.where(whereClause)
.orderBy(orderDir)
.limit(limit)
.offset(offset);
// Если фильтр по организационной роли — присоединяем roleMembers
if (options.roleId) {
query = query.innerJoin(
roleMembers,
and(eq(roleMembers.userId, users.id), eq(roleMembers.roleId, options.roleId))
) as any;
}
// При явном списке колонок drizzle возвращает плоские строки даже с join
const rows: SafeUser[] = await query;
return {
users: rows,
total: countResult?.total || 0,
};
}
async getLinkedTasksForUser(
userId: number,
organizationId: number
): Promise<
Array<{
task: Task;
form: { id: number; name: string };
status: FormStatus | null;
field: FormField;
}>
> {
// Находим поля типа user с включённой связью к профилю
const linkedFields = await db
.select({ field: formFields, formId: forms.id, formName: forms.name })
.from(formFields)
.innerJoin(forms, eq(formFields.formId, forms.id))
.where(
and(
eq(forms.organizationId, organizationId),
eq(formFields.type, 'user'),
eq(formFields.linkToUserProfile, true)
)
);
if (linkedFields.length === 0) return [];
const fieldIds = linkedFields.map((f) => f.field.id);
// Значения полей, указывающие на этого пользователя
const values = await db
.select({
taskId: taskFieldValues.taskId,
fieldId: taskFieldValues.fieldId,
value: taskFieldValues.value,
})
.from(taskFieldValues)
.where(inArray(taskFieldValues.fieldId, fieldIds));
const matchingTaskIds = values
.filter((v) => {
if (v.value == null) return false;
const raw = typeof v.value === 'string' ? v.value : JSON.stringify(v.value);
return raw.includes(`"userId":${userId}`) || raw === String(userId) || raw === `"${userId}"`;
})
.map((v) => v.taskId);
if (matchingTaskIds.length === 0) return [];
const uniqueTaskIds = Array.from(new Set(matchingTaskIds));
const taskRows = await db
.select()
.from(tasks)
.where(inArray(tasks.id, uniqueTaskIds));
const statusIds = Array.from(new Set(taskRows.map((t) => t.currentStatusId).filter((id): id is number => id != null)));
const statusRows = statusIds.length > 0
? await db.select().from(formStatuses).where(inArray(formStatuses.id, statusIds))
: [];
const statusMap = new Map(statusRows.map((s) => [s.id, s]));
const fieldMap = new Map(linkedFields.map((f) => [f.field.id, { field: f.field, formId: f.formId, formName: f.formName }]));
return taskRows.map((task) => {
const fieldId = values.find((v) => v.taskId === task.id && fieldIds.includes(v.fieldId))?.fieldId ?? linkedFields[0].field.id;
const meta = fieldMap.get(fieldId)!;
return {
task,
form: { id: meta.formId, name: meta.formName },
status: task.currentStatusId ? statusMap.get(task.currentStatusId) ?? null : null,
field: meta.field,
};
});
}
async createUser(insertUser: InsertUser): Promise<User> {
let statusId = insertUser.statusId;
if (!statusId) {
const defaultStatus = await userStatusesStorage.ensureDefaultStatus(insertUser.organizationId);
statusId = defaultStatus.id;
}
const [user] = await db
.insert(users)
.values({ ...insertUser, statusId })
.returning();
return user;
}
async updateUser(id: number, updates: Partial<User>): Promise<User> {
const [user] = await db
.update(users)
.set({ ...updates, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return user;
}
async deleteUser(id: number): Promise<void> {
await db.delete(users).where(eq(users.id, id));
}
// User sessions
async createUserSession(session: InsertUserSession): Promise<UserSession> {
const [userSession] = await db
.insert(userSessions)
.values(session)
.returning();
return userSession;
}
async getUserSessionByToken(refreshToken: string): Promise<UserSession | undefined> {
const [session] = await db
.select()
.from(userSessions)
.where(eq(userSessions.refreshToken, refreshToken));
return session || undefined;
}
async getUserSessionByParentToken(parentRefreshToken: string): Promise<UserSession | undefined> {
const [session] = await db
.select()
.from(userSessions)
.where(eq(userSessions.parentRefreshToken, parentRefreshToken));
return session || undefined;
}
async getActiveSessionInFamily(familyId: string): Promise<UserSession | undefined> {
const [session] = await db
.select()
.from(userSessions)
.where(and(
eq(userSessions.familyId, familyId),
eq(userSessions.isReplaced, false),
eq(userSessions.isRevoked, false),
gte(userSessions.expiresAt, new Date())
))
.orderBy(desc(userSessions.createdAt))
.limit(1);
return session || undefined;
}
async markSessionReplaced(refreshToken: string, replacedByToken: string): Promise<void> {
await db
.update(userSessions)
.set({ isReplaced: true, replacedByToken })
.where(eq(userSessions.refreshToken, refreshToken));
}
async revokeSession(refreshToken: string): Promise<void> {
await db
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.refreshToken, refreshToken));
}
async revokeSessionFamily(familyId: string): Promise<void> {
await db
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.familyId, familyId));
}
async revokeAllUserSessions(userId: number): Promise<void> {
await db
.update(userSessions)
.set({ isRevoked: true })
.where(eq(userSessions.userId, userId));
}
/** Physical deletion kept for cleanup operations. */
async deleteUserSession(refreshToken: string): Promise<void> {
await db.delete(userSessions).where(eq(userSessions.refreshToken, refreshToken));
}
async deleteUserSessions(userId: number): Promise<void> {
await db.delete(userSessions).where(eq(userSessions.userId, userId));
}
// Invitations
async createInvitation(invitation: InsertInvitation): Promise<Invitation> {
const [result] = await db
.insert(invitations)
.values(invitation)
.returning();
return result;
}
async getInvitationByToken(token: string): Promise<Invitation | undefined> {
const [invitation] = await db
.select()
.from(invitations)
.where(eq(invitations.token, token));
return invitation || undefined;
}
async getInvitationsByOrganization(organizationId: number): Promise<Invitation[]> {
return await db
.select()
.from(invitations)
.where(eq(invitations.organizationId, organizationId))
.orderBy(desc(invitations.createdAt));
}
async markInvitationAsUsed(token: string): Promise<Invitation> {
const [invitation] = await db
.update(invitations)
.set({ usedAt: new Date() })
.where(eq(invitations.token, token))
.returning();
return invitation;
}
async deleteInvitation(id: number, organizationId: number): Promise<void> {
await db
.delete(invitations)
.where(and(eq(invitations.id, id), eq(invitations.organizationId, organizationId)));
}
// User Custom Fields
async getUserCustomFields(organizationId: number): Promise<UserCustomField[]> {
return await db
.select()
.from(userCustomFields)
.where(eq(userCustomFields.organizationId, organizationId))
.orderBy(asc(userCustomFields.position), asc(userCustomFields.id));
}
async getUserCustomField(id: number, organizationId: number): Promise<UserCustomField | undefined> {
const [field] = await db
.select()
.from(userCustomFields)
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)));
return field || undefined;
}
async createUserCustomField(organizationId: number, field: CreateUserCustomField): Promise<UserCustomField> {
const maxPositionResult = await db
.select({ maxPos: sql<number>`COALESCE(MAX(${userCustomFields.position}), 0)` })
.from(userCustomFields)
.where(eq(userCustomFields.organizationId, organizationId));
const nextPosition = (maxPositionResult[0]?.maxPos || 0) + 1;
const [created] = await db
.insert(userCustomFields)
.values({
organizationId,
name: field.name,
code: field.code,
type: field.type,
options: field.options || null,
isRequired: field.isRequired || false,
position: nextPosition,
})
.returning();
return created;
}
async updateUserCustomField(id: number, organizationId: number, updates: UpdateUserCustomField): Promise<UserCustomField> {
const [updated] = await db
.update(userCustomFields)
.set({
...updates,
updatedAt: new Date(),
})
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)))
.returning();
return updated;
}
async deleteUserCustomField(id: number, organizationId: number): Promise<void> {
await db
.delete(userCustomFields)
.where(and(eq(userCustomFields.id, id), eq(userCustomFields.organizationId, organizationId)));
}
async reorderUserCustomFields(organizationId: number, fieldIds: number[]): Promise<void> {
await db.transaction(async (tx) => {
for (let i = 0; i < fieldIds.length; i++) {
await tx
.update(userCustomFields)
.set({ position: i })
.where(and(eq(userCustomFields.id, fieldIds[i]), eq(userCustomFields.organizationId, organizationId)));
}
});
}
// User Custom Values
async getUserCustomValues(userId: number, organizationId: number): Promise<UserCustomValue[]> {
// Only return values for fields that belong to this organization
return await db
.select({
id: userCustomValues.id,
userId: userCustomValues.userId,
fieldId: userCustomValues.fieldId,
value: userCustomValues.value,
createdAt: userCustomValues.createdAt,
updatedAt: userCustomValues.updatedAt,
})
.from(userCustomValues)
.innerJoin(userCustomFields, eq(userCustomValues.fieldId, userCustomFields.id))
.where(and(
eq(userCustomValues.userId, userId),
eq(userCustomFields.organizationId, organizationId)
));
}
async setUserCustomValue(userId: number, fieldId: number, value: string | null, organizationId: number): Promise<UserCustomValue> {
// Verify field belongs to organization
const field = await this.getUserCustomField(fieldId, organizationId);
if (!field) {
throw new Error('Поле не найдено или не принадлежит организации');
}
const [existing] = await db
.select()
.from(userCustomValues)
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
if (existing) {
const [updated] = await db
.update(userCustomValues)
.set({ value, updatedAt: new Date() })
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)))
.returning();
return updated;
} else {
const [created] = await db
.insert(userCustomValues)
.values({ userId, fieldId, value })
.returning();
return created;
}
}
async setUserCustomValues(userId: number, values: Array<{ fieldId: number; value: string | null }>, organizationId: number): Promise<void> {
await db.transaction(async (tx) => {
for (const { fieldId, value } of values) {
const [field] = await tx
.select()
.from(userCustomFields)
.where(and(eq(userCustomFields.id, fieldId), eq(userCustomFields.organizationId, organizationId)));
if (!field) throw new Error('Поле не найдено или не принадлежит организации');
const [existing] = await tx
.select()
.from(userCustomValues)
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
if (existing) {
await tx
.update(userCustomValues)
.set({ value, updatedAt: new Date() })
.where(and(eq(userCustomValues.userId, userId), eq(userCustomValues.fieldId, fieldId)));
} else {
await tx
.insert(userCustomValues)
.values({ userId, fieldId, value });
}
}
});
}
// User offline subscriptions
async getUserOfflineSubscriptions(userId: number, organizationId: number): Promise<UserOfflineSubscription[]> {
return await db
.select()
.from(userOfflineSubscriptions)
.where(and(
eq(userOfflineSubscriptions.userId, userId),
eq(userOfflineSubscriptions.organizationId, organizationId)
))
.orderBy(desc(userOfflineSubscriptions.createdAt));
}
async getUserOfflineSubscription(userId: number, formId: number): Promise<UserOfflineSubscription | undefined> {
const [sub] = await db
.select()
.from(userOfflineSubscriptions)
.where(and(
eq(userOfflineSubscriptions.userId, userId),
eq(userOfflineSubscriptions.formId, formId)
));
return sub ?? undefined;
}
async upsertUserOfflineSubscription(subscription: InsertUserOfflineSubscription): Promise<UserOfflineSubscription> {
const { userId, formId } = subscription;
const existing = await this.getUserOfflineSubscription(userId, formId);
if (existing) {
const [updated] = await db
.update(userOfflineSubscriptions)
.set({ ...subscription, updatedAt: new Date() })
.where(eq(userOfflineSubscriptions.id, existing.id))
.returning();
return updated;
}
const [created] = await db
.insert(userOfflineSubscriptions)
.values({ ...subscription, createdAt: new Date(), updatedAt: new Date() })
.returning();
return created;
}
async deleteUserOfflineSubscription(userId: number, formId: number): Promise<void> {
await db
.delete(userOfflineSubscriptions)
.where(and(
eq(userOfflineSubscriptions.userId, userId),
eq(userOfflineSubscriptions.formId, formId)
));
}
}