Files
iistwin/server/storage/social.storage.ts
Ильяс Султанов cf6f937108 security(users): SafeUser — passwordHash/токены не уходят клиенту
Шаг 0.8 плана production-готовности:
- shared/schema.ts: тип SafeUser + safeUserColumns
- getUsersByOrganization, listUsers, searchUsers, getUsersByRole,
  getUsersByOrgRoleId, documents getUser — без чувствительных колонок
- sync (initial/delta), автоматизации ctx.users.list, MCP list_users — sanitized
2026-09-07 21:03:00 +03:00

386 lines
15 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, userNotifications, bookmarkFolders, bookmarks, safeUserColumns, type User, type SafeUser, type UserNotification, type BookmarkFolder, type Bookmark, type InsertUserNotification, type InsertBookmarkFolder, type InsertBookmark } from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, isNull, sql } from "drizzle-orm";
import crypto from "crypto";
import { TasksStorage } from "./tasks.storage";
export class SocialStorage extends TasksStorage {
// Bookmark Folders
async getBookmarkFolder(id: number, organizationId: number, userId: number): Promise<BookmarkFolder | undefined> {
const [folder] = await db
.select()
.from(bookmarkFolders)
.where(and(eq(bookmarkFolders.id, id), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)));
return folder || undefined;
}
async getBookmarkFoldersByOrganization(organizationId: number, userId: number): Promise<BookmarkFolder[]> {
return await db
.select()
.from(bookmarkFolders)
.where(and(eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)))
.orderBy(bookmarkFolders.position);
}
async getBookmarkFoldersByParent(parentId: number | null, organizationId: number, userId: number): Promise<BookmarkFolder[]> {
const whereCondition = parentId
? and(eq(bookmarkFolders.parentId, parentId), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId))
: and(isNull(bookmarkFolders.parentId), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId));
return await db
.select()
.from(bookmarkFolders)
.where(whereCondition)
.orderBy(bookmarkFolders.position);
}
async createBookmarkFolder(insertFolder: InsertBookmarkFolder): Promise<BookmarkFolder> {
const whereCondition = insertFolder.parentId != null
? and(eq(bookmarkFolders.parentId, insertFolder.parentId), eq(bookmarkFolders.organizationId, insertFolder.organizationId), eq(bookmarkFolders.userId, insertFolder.userId))
: and(isNull(bookmarkFolders.parentId), eq(bookmarkFolders.organizationId, insertFolder.organizationId), eq(bookmarkFolders.userId, insertFolder.userId));
const [maxRow] = await db
.select({ maxPos: sql<number>`coalesce(max(${bookmarkFolders.position}), -1)` })
.from(bookmarkFolders)
.where(whereCondition);
const nextPosition = (maxRow?.maxPos ?? -1) + 1;
const [folder] = await db
.insert(bookmarkFolders)
.values({ ...insertFolder, position: nextPosition })
.returning();
return folder;
}
async updateBookmarkFolder(id: number, organizationId: number, updates: Partial<BookmarkFolder>, userId: number): Promise<BookmarkFolder> {
const [folder] = await db
.update(bookmarkFolders)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(bookmarkFolders.id, id), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)))
.returning();
return folder;
}
async deleteBookmarkFolder(id: number, organizationId: number, userId: number): Promise<void> {
await db.transaction(async (tx) => {
const deleted = await tx
.delete(bookmarkFolders)
.where(and(eq(bookmarkFolders.id, id), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)))
.returning({ parentId: bookmarkFolders.parentId });
if (deleted.length === 0) return;
const parentId = deleted[0].parentId;
const whereCondition = parentId != null
? and(eq(bookmarkFolders.parentId, parentId), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId))
: and(isNull(bookmarkFolders.parentId), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId));
const remaining = await tx
.select({ id: bookmarkFolders.id, position: bookmarkFolders.position })
.from(bookmarkFolders)
.where(whereCondition)
.orderBy(bookmarkFolders.position, bookmarkFolders.id);
for (let idx = 0; idx < remaining.length; idx++) {
if (remaining[idx].position !== idx) {
await tx
.update(bookmarkFolders)
.set({ position: idx, updatedAt: new Date() })
.where(and(eq(bookmarkFolders.id, remaining[idx].id), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)));
}
}
});
}
// Bookmarks
async getBookmark(id: number, organizationId: number, userId: number): Promise<Bookmark | undefined> {
const [bookmark] = await db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.id, id), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)));
return bookmark || undefined;
}
async getBookmarksByFolder(folderId: number | null, organizationId: number, userId: number): Promise<Bookmark[]> {
const whereCondition = folderId
? and(eq(bookmarks.folderId, folderId), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId))
: and(isNull(bookmarks.folderId), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId));
return await db
.select()
.from(bookmarks)
.where(whereCondition)
.orderBy(bookmarks.position, bookmarks.id);
}
async getBookmarksByOrganization(organizationId: number, userId: number): Promise<Bookmark[]> {
return await db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)))
.orderBy(bookmarks.position, bookmarks.id);
}
async createBookmark(insertBookmark: InsertBookmark): Promise<Bookmark> {
const whereCondition = insertBookmark.folderId != null
? and(eq(bookmarks.folderId, insertBookmark.folderId), eq(bookmarks.organizationId, insertBookmark.organizationId), eq(bookmarks.userId, insertBookmark.userId))
: and(isNull(bookmarks.folderId), eq(bookmarks.organizationId, insertBookmark.organizationId), eq(bookmarks.userId, insertBookmark.userId));
const [maxRow] = await db
.select({ maxPos: sql<number>`coalesce(max(${bookmarks.position}), -1)` })
.from(bookmarks)
.where(whereCondition);
const nextPosition = (maxRow?.maxPos ?? -1) + 1;
const [bookmark] = await db
.insert(bookmarks)
.values({ ...insertBookmark, position: nextPosition })
.returning();
return bookmark;
}
async updateBookmark(id: number, organizationId: number, updates: Partial<Bookmark>, userId: number): Promise<Bookmark> {
const [bookmark] = await db
.update(bookmarks)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(bookmarks.id, id), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)))
.returning();
return bookmark;
}
async deleteBookmark(id: number, organizationId: number, userId: number): Promise<void> {
await db.transaction(async (tx) => {
const deleted = await tx
.delete(bookmarks)
.where(and(eq(bookmarks.id, id), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)))
.returning({ folderId: bookmarks.folderId });
if (deleted.length === 0) return;
const folderId = deleted[0].folderId;
const whereCondition = folderId != null
? and(eq(bookmarks.folderId, folderId), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId))
: and(isNull(bookmarks.folderId), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId));
const remaining = await tx
.select({ id: bookmarks.id, position: bookmarks.position })
.from(bookmarks)
.where(whereCondition)
.orderBy(bookmarks.position, bookmarks.id);
for (let idx = 0; idx < remaining.length; idx++) {
if (remaining[idx].position !== idx) {
await tx
.update(bookmarks)
.set({ position: idx, updatedAt: new Date() })
.where(and(eq(bookmarks.id, remaining[idx].id), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)));
}
}
});
}
async normalizeBookmarkPositions(organizationId: number, userId: number): Promise<void> {
const allBookmarks = await db
.select()
.from(bookmarks)
.where(and(eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)))
.orderBy(bookmarks.folderId, bookmarks.position, bookmarks.id);
const groups = new Map<string, typeof allBookmarks>();
for (const bm of allBookmarks) {
const key = bm.folderId == null ? 'null' : String(bm.folderId);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bm);
}
const updates: { id: number; position: number }[] = [];
for (const group of groups.values()) {
group.forEach((bm, idx) => {
if (bm.position !== idx) {
updates.push({ id: bm.id, position: idx });
}
});
}
if (updates.length > 0) {
await this.batchUpdateBookmarkPositions(updates, organizationId, userId);
}
}
async batchUpdateBookmarkPositions(
items: { id: number; position: number }[],
organizationId: number,
userId: number
): Promise<void> {
await db.transaction(async (tx) => {
for (const item of items) {
await tx
.update(bookmarks)
.set({ position: item.position, updatedAt: new Date() })
.where(and(eq(bookmarks.id, item.id), eq(bookmarks.organizationId, organizationId), eq(bookmarks.userId, userId)));
}
});
}
async batchUpdateBookmarkFolderPositions(
items: { id: number; position: number }[],
organizationId: number,
userId: number
): Promise<void> {
await db.transaction(async (tx) => {
for (const item of items) {
await tx
.update(bookmarkFolders)
.set({ position: item.position, updatedAt: new Date() })
.where(and(eq(bookmarkFolders.id, item.id), eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)));
}
});
}
async normalizeBookmarkFolderPositions(organizationId: number, userId: number): Promise<void> {
const allFolders = await db
.select()
.from(bookmarkFolders)
.where(and(eq(bookmarkFolders.organizationId, organizationId), eq(bookmarkFolders.userId, userId)))
.orderBy(bookmarkFolders.parentId, bookmarkFolders.position, bookmarkFolders.id);
const groups = new Map<string, typeof allFolders>();
for (const folder of allFolders) {
const key = folder.parentId == null ? 'null' : String(folder.parentId);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(folder);
}
const updates: { id: number; position: number }[] = [];
for (const group of groups.values()) {
group.forEach((folder, idx) => {
if (folder.position !== idx) {
updates.push({ id: folder.id, position: idx });
}
});
}
if (updates.length > 0) {
await this.batchUpdateBookmarkFolderPositions(updates, organizationId, userId);
}
}
// User Notifications
async getUserNotifications(userId: number, organizationId: number, isRead?: boolean): Promise<UserNotification[]> {
const conditions = [
eq(userNotifications.userId, userId),
eq(userNotifications.organizationId, organizationId)
];
if (isRead !== undefined) {
conditions.push(eq(userNotifications.isRead, isRead));
}
const result = await db.query.userNotifications.findMany({
where: and(...conditions),
orderBy: [desc(userNotifications.createdAt)],
with: {
task: true,
taskMessage: {
with: {
author: true
}
}
}
});
return result as UserNotification[];
}
async createUserNotification(insertNotification: InsertUserNotification): Promise<UserNotification> {
const [notification] = await db
.insert(userNotifications)
.values(insertNotification)
.returning();
return notification;
}
async markNotificationAsRead(id: number, userId: number, organizationId: number): Promise<UserNotification> {
const [notification] = await db
.update(userNotifications)
.set({ isRead: true })
.where(and(
eq(userNotifications.id, id),
eq(userNotifications.userId, userId),
eq(userNotifications.organizationId, organizationId)
))
.returning();
return notification;
}
async markAllNotificationsAsRead(userId: number, organizationId: number): Promise<void> {
await db
.update(userNotifications)
.set({ isRead: true })
.where(and(
eq(userNotifications.userId, userId),
eq(userNotifications.organizationId, organizationId)
));
}
async markTaskNotificationsAsRead(taskId: number, userId: number, organizationId: number): Promise<void> {
await db
.update(userNotifications)
.set({ isRead: true })
.where(and(
eq(userNotifications.taskId, taskId),
eq(userNotifications.userId, userId),
eq(userNotifications.organizationId, organizationId),
eq(userNotifications.isRead, false)
));
}
async deleteNotification(id: number, userId: number, organizationId: number): Promise<void> {
await db
.delete(userNotifications)
.where(and(
eq(userNotifications.id, id),
eq(userNotifications.userId, userId),
eq(userNotifications.organizationId, organizationId)
));
}
// User Search
// Поиск уходит клиенту — выбираем только безопасные колонки (без хэша пароля и токенов)
async searchUsers(query: string, organizationId: number, limit = 10): Promise<SafeUser[]> {
// Поддержка похожих символов: і/и для корректного поиска
const normalizedQuery = query
.replace(/і/g, 'и')
.replace(/ї/g, 'и')
.trim();
const result = await db
.select(safeUserColumns)
.from(users)
.where(and(
eq(users.organizationId, organizationId),
sql`(
${users.firstName} || ' ' || ${users.middleName} || ' ' || ${users.lastName} ILIKE ${'%' + query + '%'}
OR
${users.firstName} || ' ' || ${users.middleName} || ' ' || ${users.lastName} ILIKE ${'%' + normalizedQuery + '%'}
OR
${users.firstName} ILIKE ${'%' + query + '%'}
OR
${users.middleName} ILIKE ${'%' + query + '%'}
OR
${users.lastName} ILIKE ${'%' + query + '%'}
)`
))
.limit(limit)
.orderBy(users.firstName, users.middleName, users.lastName);
return result;
}
// =====================================================
// BOTS
// =====================================================
}