import { Tenant, Log, ApiKey, Prisma } from "@toolctrl/db/prisma-browser"; import { FiltersDto } from "~/application/dtos/data/FiltersDto"; import { PaginationDto } from "~/application/dtos/data/PaginationDto"; import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions"; import { db } from "../db.server"; import ApiHelper from "../helpers/ApiHelper"; import RowFiltersHelper from "../helpers/RowFiltersHelper"; import { getUserInfo } from "../session.server"; import { EntityWithDetails } from "./entities/entities.db.server"; import { callEntityWebhooks } from "./entities/entityWebhooks.db.server"; import { RowCommentWithDetails } from "./entities/rowComments.db.server"; import { RowWithDetails } from "./entities/rows.db.server"; import { UserWithoutPassword } from "./users.db.server"; import UserModelHelper from "../helpers/models/UserModelHelper"; export type LogWithDetails = Log & { user: UserWithoutPassword | null; apiKey: ApiKey | null; tenant?: Tenant | null; comment?: RowCommentWithDetails | null; }; const include = { user: true, tenant: true, apiKey: true, comment: { include: { ...UserModelHelper.includeSimpleCreatedByUser, reactions: { include: { ...UserModelHelper.includeSimpleCreatedByUser, }, }, }, }, }; export async function getAllLogs( pagination: { page: number; pageSize: number }, filters: FiltersDto ): Promise<{ items: LogWithDetails[]; pagination: PaginationDto }> { const where = RowFiltersHelper.getFiltersCondition(filters); const items = await db.log.findMany({ take: pagination.pageSize, skip: pagination.pageSize * (pagination.page - 1), include, where, }); const totalItems = await db.log.count({ where, }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function getLogs( tenantId: string, pagination: { page: number; pageSize: number }, filters: FiltersDto ): Promise<{ items: LogWithDetails[]; pagination: PaginationDto }> { const where = RowFiltersHelper.getFiltersCondition(filters); const items = await db.log.findMany({ take: pagination.pageSize, skip: pagination.pageSize * (pagination.page - 1), where: { tenantId, ...where }, include, }); const totalItems = await db.log.count({ where: { tenantId, ...where }, }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function getAllRowLogs({ entityId, rowId, tenantId, pagination, }: { entityId?: string; rowId?: string; tenantId?: string | null; pagination: { page: number; pageSize: number }; }): Promise<{ items: LogWithDetails[]; pagination: PaginationDto }> { let where: Prisma.LogWhereInput = { rowId: { not: null } }; if (entityId) { where = { ...where, row: { entityId } }; } if (rowId) { where = { ...where, rowId }; } if (tenantId !== undefined) { where = { ...where, tenantId }; } const items = await db.log.findMany({ take: pagination.pageSize, skip: pagination.pageSize * (pagination.page - 1), where, include, }); const totalItems = await db.log.count({ where, }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function getRowLogs(tenantId: string | null, rowId: string): Promise { return await db.log.findMany({ where: { tenantId, rowId, }, include, orderBy: { createdAt: "desc", }, }); } export async function getRowLogsById(rowId: string): Promise { return await db.log.findMany({ where: { rowId, }, include, orderBy: { createdAt: "desc", }, }); } export async function createLog(request: Request, tenantId: string | null, action: string, details: string) { const userInfo = await getUserInfo(request); await db.log.create({ data: { tenantId, userId: userInfo.userId, url: new URL(request.url).pathname, action, details, }, }); } export async function createManualLog(userId: string, tenantId: string | null, pathname: string, action: string, details: string) { await db.log.create({ data: { tenantId, userId, url: pathname, action, details, }, }); } export async function createRowLog( request: Request, data: { tenantId: string | null; createdByUserId?: string | null; createdByApiKey?: string | null; action: string; entity: EntityWithDetails; item: RowWithDetails | null; commentId?: string | null; details?: string; } ) { const log = await db.log.create({ data: { tenantId: data.tenantId, userId: data.createdByUserId ?? null, apiKeyId: data.createdByApiKey ?? null, url: new URL(request.url).pathname, rowId: data.item?.id ?? null, action: data.action, details: data.details ?? null, commentId: data.commentId ?? null, }, }); const apiFormat = ApiHelper.getApiFormat(data.entity, data.item); await callEntityWebhooks({ logId: log.id, entityId: data.entity.id, action: data.action, body: apiFormat, request, }); return log; } export async function createManualRowLog( data: { tenantId: string | null; createdByUserId?: string | null; createdByApiKey?: string | null; action: string; entity: EntityWithDetails; item: RowWithDetails | null; commentId?: string | null; }, request?: Request ) { let details = ""; const log = await db.log.create({ data: { tenantId: data.tenantId, userId: data.createdByUserId ?? null, apiKeyId: data.createdByApiKey ?? null, url: request ? new URL(request.url).pathname : "", rowId: data.item?.id ?? null, action: data.action, details, commentId: data.commentId ?? null, }, }); const apiFormat = ApiHelper.getApiFormat(data.entity, data.item); await callEntityWebhooks({ logId: log.id, entityId: data.entity.id, action: data.action, body: apiFormat, // request, }); return log; } export async function createLogLogin(request: Request, user: UserWithoutPassword) { // eslint-disable-next-line no-console // console.log({ clientIpAddress: getClientIPAddress(request.headers) }); await db.log.create({ data: { userId: user.id, url: "", action: DefaultLogActions.Login, details: "", }, }); } export async function createLogLogout(request: Request, userId: string) { await db.log.create({ data: { userId, url: "", action: DefaultLogActions.Logout, details: "", }, }); } export async function createAdminLog(request: Request, action: string, details: string) { const userInfo = await getUserInfo(request); await db.log.create({ data: { userId: userInfo.userId, url: new URL(request.url).pathname, action, details, }, }); } export async function getLastUserLog(userId: string): Promise { return await db.log.findFirst({ where: { userId, }, orderBy: { createdAt: "desc", }, }); } /** * Returns the most recent log entry per user for the given userIds in a * constant number of queries (1 groupBy + 1 findMany), avoiding the N+1 * pattern of calling `getLastUserLog` per user. */ export async function getLastUserLogsByUserIds(userIds: string[]): Promise> { if (userIds.length === 0) { return new Map(); } const groups = await db.log.groupBy({ by: ["userId"], where: { userId: { in: userIds } }, _max: { createdAt: true }, }); const filters = groups .map((group) => (group._max.createdAt ? { userId: group.userId, createdAt: group._max.createdAt } : null)) .filter((entry): entry is { userId: string; createdAt: Date } => entry !== null); if (filters.length === 0) { return new Map(); } const logs = await db.log.findMany({ where: { OR: filters }, }); const byUserId = new Map(); for (const log of logs) { const existing = byUserId.get(log.userId ?? ""); if (!log.userId) continue; if (!existing || existing.createdAt < log.createdAt) { byUserId.set(log.userId, log); } } return byUserId; }