/** * 最近打开(ADR-0031)。 * * 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露); * upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型, * 非权限敏感写)。 * 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目 * 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。 */ import type { PrismaClient } from "@prisma/client"; import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js"; export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient }; export interface RecentEntryDto { readonly nodeId: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; /** "" = 节点本身;非空 = 项目内文件路径。 */ readonly filePath: string; readonly openedAt: Date; } const RECENT_LIMIT = 20; export async function recordVisit( deps: RecentDeps, actor: FileLibActor, nodeId: string, filePath: string | undefined, ): Promise { const path = filePath ?? ""; await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW")); await deps.prisma.fileLibRecentVisit.upsert({ where: { organizationId_userId_nodeId_filePath: { organizationId: deps.organizationId, userId: actor.userId, nodeId, filePath: path, }, }, update: { openedAt: new Date() }, create: { organizationId: deps.organizationId, userId: actor.userId, nodeId, filePath: path, openedAt: new Date(), }, }); } export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise { // 可见性过滤会丢弃一部分,超取再截断。 const rows = await deps.prisma.fileLibRecentVisit.findMany({ where: { organizationId: deps.organizationId, userId: actor.userId }, orderBy: { openedAt: "desc" }, take: RECENT_LIMIT * 3, }); if (rows.length === 0) return []; const nodeIds = [...new Set(rows.map((r) => r.nodeId))]; const nodes = await deps.prisma.fileLibNode.findMany({ where: { id: { in: nodeIds } }, select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true }, }); const byId = new Map(nodes.map((n) => [n.id, n])); // 祖先活跃性:收集所有节点的祖先段,查已删集合。 const ancestorIds = new Set(); for (const n of nodes) { for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s); } const deletedAncestorIds = new Set( ancestorIds.size === 0 ? [] : ( await deps.prisma.fileLibNode.findMany({ where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } }, select: { id: true }, }) ).map((r) => r.id), ); const out: RecentEntryDto[] = []; for (const row of rows) { if (out.length >= RECENT_LIMIT) break; const node = byId.get(row.nodeId); if (node === undefined || node.deletedAt !== null) continue; const hidden = node.pathIds .split("/") .filter((s) => s !== "" && s !== node.id) .some((s) => deletedAncestorIds.has(s)); if (hidden) continue; out.push({ nodeId: node.id, kind: node.kind, name: node.name, filePath: row.filePath, openedAt: row.openedAt, }); } return out; }