import { FieldType, ResourceType } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { anonymizeResources, anonymizeSearchMatches, getAnonymizationDirectory, resolveResourceIdsByDisplayedEids, } from "../lib/anonymization.js"; import { fmtEur } from "../lib/format-utils.js"; import type { TRPCContext } from "../trpc.js"; import { assertCanReadResource, canReadAllResources, type ResourceReadContext, } from "../lib/resource-access.js"; function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null { if (!cursor) return null; try { const decoded = JSON.parse(cursor) as { displayName?: string; id?: string }; if (typeof decoded.displayName === "string" && typeof decoded.id === "string") { return { displayName: decoded.displayName, id: decoded.id }; } } catch { return null; } return null; } const RESOURCE_SUMMARY_SELECT = { id: true, eid: true, displayName: true, chapter: true, isActive: true, areaRole: { select: { name: true } }, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, orgUnit: { select: { name: true } }, } as const; const RESOURCE_SUMMARY_DETAIL_SELECT = { ...RESOURCE_SUMMARY_SELECT, fte: true, lcrCents: true, chargeabilityTarget: true, } as const; const RESOURCE_IDENTIFIER_SELECT = { id: true, eid: true, displayName: true, chapter: true, isActive: true, } as const; const RESOURCE_IDENTIFIER_DETAIL_SELECT = { ...RESOURCE_IDENTIFIER_SELECT, id: true, eid: true, displayName: true, email: true, chapter: true, fte: true, lcrCents: true, ucrCents: true, chargeabilityTarget: true, isActive: true, availability: true, skills: true, postalCode: true, federalState: true, areaRole: { select: { name: true, color: true } }, country: { select: { code: true, name: true, dailyWorkingHours: true } }, metroCity: { select: { name: true } }, managementLevelGroup: { select: { name: true, targetPercentage: true } }, orgUnit: { select: { name: true, level: true } }, _count: { select: { assignments: true, vacations: true } }, } as const; export function mapResourceSummary(resource: { id: string; eid: string; displayName: string; chapter: string | null; isActive: boolean; areaRole: { name: string } | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; orgUnit: { name: string } | null; }) { return { id: resource.id, eid: resource.eid, name: resource.displayName, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, metroCity: resource.metroCity?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, active: resource.isActive, }; } export function mapResourceSummaryDetail(resource: { id: string; eid: string; displayName: string; chapter: string | null; fte: number | null; lcrCents: number | null; chargeabilityTarget: number | null; isActive: boolean; areaRole: { name: string } | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; orgUnit: { name: string } | null; }) { return { id: resource.id, eid: resource.eid, name: resource.displayName, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, metroCity: resource.metroCity?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, fte: resource.fte, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, active: resource.isActive, }; } export function mapResourceDetail(resource: { id: string; eid: string; displayName: string; email: string | null; chapter: string | null; fte: number | null; lcrCents: number | null; ucrCents: number | null; chargeabilityTarget: number | null; isActive: boolean; skills: unknown; postalCode: string | null; federalState: string | null; areaRole: { name: string; color: string | null } | null; country: { code: string; name: string; dailyWorkingHours: number | null } | null; metroCity: { name: string } | null; managementLevelGroup: { name: string; targetPercentage: number | null } | null; orgUnit: { name: string; level: number } | null; _count: { assignments: number; vacations: number }; }) { const skills = Array.isArray(resource.skills) ? resource.skills as { name?: string; level?: number }[] : []; return { id: resource.id, eid: resource.eid, name: resource.displayName, email: resource.email, chapter: resource.chapter, role: resource.areaRole?.name ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, countryHours: resource.country?.dailyWorkingHours ?? 8, metroCity: resource.metroCity?.name ?? null, fte: resource.fte, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, ucr: resource.ucrCents != null ? fmtEur(resource.ucrCents) : null, chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null, managementLevel: resource.managementLevelGroup?.name ?? null, orgUnit: resource.orgUnit?.name ?? null, postalCode: resource.postalCode, federalState: resource.federalState, active: resource.isActive, totalAssignments: resource._count.assignments, totalVacations: resource._count.vacations, skillCount: skills.length, topSkills: skills.slice(0, 10).map((skill) => `${skill.name ?? "?"} (${skill.level ?? "?"})`), }; } function isBroadResourceLookupAllowed(ctx: Pick): boolean { return canReadAllResources(ctx); } export const ResourceDirectoryQuerySchema = z.object({ chapter: z.string().optional(), chapters: z.array(z.string()).optional(), isActive: z.boolean().optional().default(true), search: z.string().optional(), eids: z.array(z.string()).optional(), countryIds: z.array(z.string()).optional(), excludedCountryIds: z.array(z.string()).optional(), includeWithoutCountry: z.boolean().optional().default(true), resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(), includeWithoutResourceType: z.boolean().optional().default(true), rolledOff: z.boolean().optional(), departed: z.boolean().optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(500).default(50), cursor: z.string().optional(), }); export const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({ includeRoles: z.boolean().optional().default(false), customFieldFilters: z.array(z.object({ key: z.string(), value: z.string(), type: z.nativeEnum(FieldType), })).optional(), }); export async function listStaffResources( ctx: Pick, input: z.infer, ) { const { chapter, chapters, isActive, search, eids, countryIds, excludedCountryIds, includeWithoutCountry, resourceTypes, excludedResourceTypes, includeWithoutResourceType, rolledOff, departed, page, limit, includeRoles, cursor, customFieldFilters, } = input; const parsedCursor = parseResourceCursor(cursor); const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields })); type WhereClause = Record; const andClauses: WhereClause[] = []; const chapterFilters = Array.from( new Set([ ...(chapter ? [chapter] : []), ...(chapters ?? []), ]), ); const directory = await getAnonymizationDirectory(ctx.db); if (!eids) { andClauses.push({ isActive }); } if (eids && !directory) { andClauses.push({ eid: { in: eids } }); } if (chapterFilters.length === 1) { andClauses.push({ chapter: chapterFilters[0] }); } else if (chapterFilters.length > 1) { andClauses.push({ chapter: { in: chapterFilters } }); } if (search && !directory) { andClauses.push({ OR: [ { displayName: { contains: search, mode: "insensitive" as const } }, { eid: { contains: search, mode: "insensitive" as const } }, { email: { contains: search, mode: "insensitive" as const } }, ], }); } if (countryIds && countryIds.length > 0) { const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }]; if (includeWithoutCountry) { countryClauses.push({ countryId: null }); } andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses }); } if (excludedCountryIds && excludedCountryIds.length > 0) { andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } }); } if (!includeWithoutCountry) { andClauses.push({ NOT: { countryId: null } }); } if (resourceTypes && resourceTypes.length > 0) { const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }]; if (includeWithoutResourceType) { resourceTypeClauses.push({ resourceType: null }); } andClauses.push( resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses }, ); } if (excludedResourceTypes && excludedResourceTypes.length > 0) { andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } }); } if (!includeWithoutResourceType) { andClauses.push({ NOT: { resourceType: null } }); } if (rolledOff !== undefined) { andClauses.push({ rolledOff }); } if (departed !== undefined) { andClauses.push({ departed }); } andClauses.push(...cfConditions); const where = andClauses.length > 0 ? { AND: andClauses } : {}; if (directory) { const rawResources = await (includeRoles ? ctx.db.resource.findMany({ where, include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } }, }, }, orderBy: [{ displayName: "asc" }, { id: "asc" }], }) : ctx.db.resource.findMany({ where, orderBy: [{ displayName: "asc" }, { id: "asc" }], })); const directoryResources = rawResources.map((resource) => ({ id: resource.id, eid: resource.eid, displayName: resource.displayName, email: resource.email, })); const requestedIds = eids ? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids) : []; const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; const filteredResources = rawResources.filter((resource) => { const alias = directory.byResourceId.get(resource.id); if (requestedIdSet && !requestedIdSet.has(resource.id)) { return false; } if (eids && eids.length > 0 && requestedIds.length === 0) { return false; } if (search && !anonymizeSearchMatches( { id: resource.id, eid: resource.eid, displayName: resource.displayName, email: resource.email, }, alias, search, )) { return false; } return true; }); const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => { const displayNameCompare = left.displayName.localeCompare(right.displayName); if (displayNameCompare !== 0) { return displayNameCompare; } return left.id.localeCompare(right.id); }); const total = anonymizedResources.length; const afterCursor = parsedCursor ? anonymizedResources.filter( (resource) => resource.displayName > parsedCursor.displayName || (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id), ) : anonymizedResources; const skip = cursor ? 0 : (page - 1) * limit; const paged = afterCursor.slice(skip, skip + limit + 1); const hasMore = paged.length > limit; const resources = hasMore ? paged.slice(0, limit) : paged; const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; } const skip = cursor ? 0 : (page - 1) * limit; const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; const whereWithCursor = parsedCursor ? { AND: [ ...((where as { AND?: WhereClause[] }).AND ?? []), { OR: [ { displayName: { gt: parsedCursor.displayName } }, { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, ], }, ], } : where; const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy }; const [rawResources, total] = await Promise.all([ includeRoles ? ctx.db.resource.findMany({ ...baseQuery, include: { resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } }, }, }, }) : ctx.db.resource.findMany(baseQuery), ctx.db.resource.count({ where }), ]); const hasMore = rawResources.length > limit; const resources = hasMore ? rawResources.slice(0, limit) : rawResources; const nextCursor = hasMore ? JSON.stringify({ displayName: resources[resources.length - 1]!.displayName, id: resources[resources.length - 1]!.id, }) : null; return { resources, total, page, limit, nextCursor }; } function buildResourceSummaryWhere(input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; }) { const where: Record = {}; if (input.isActive !== false) { where.isActive = true; } if (input.search) { where.OR = [ { displayName: { contains: input.search, mode: "insensitive" as const } }, { eid: { contains: input.search, mode: "insensitive" as const } }, { chapter: { contains: input.search, mode: "insensitive" as const } }, ]; } if (input.country) { where.country = { OR: [ { code: { equals: input.country, mode: "insensitive" as const } }, { name: { contains: input.country, mode: "insensitive" as const } }, ], }; } if (input.metroCity) { where.metroCity = { name: { contains: input.metroCity, mode: "insensitive" as const } }; } if (input.orgUnit) { where.orgUnit = { name: { contains: input.orgUnit, mode: "insensitive" as const } }; } if (input.roleName) { where.areaRole = { name: { contains: input.roleName, mode: "insensitive" as const } }; } return where; } function assertCanSearchResourceSummaries(ctx: Pick) { if (!canReadAllResources(ctx)) { throw new TRPCError({ code: "FORBIDDEN", message: "You need resource overview access to search resource summaries", }); } } export async function readResourceSummariesSnapshot( ctx: Pick, input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; limit: number; }, ) { assertCanSearchResourceSummaries(ctx); return ctx.db.resource.findMany({ where: buildResourceSummaryWhere(input), select: RESOURCE_SUMMARY_SELECT, take: input.limit, orderBy: { displayName: "asc" }, }); } export async function readResourceSummaryDetailsSnapshot( ctx: Pick, input: { search?: string; country?: string; metroCity?: string; orgUnit?: string; roleName?: string; isActive: boolean; limit: number; }, ) { assertCanSearchResourceSummaries(ctx); return ctx.db.resource.findMany({ where: buildResourceSummaryWhere(input), select: RESOURCE_SUMMARY_DETAIL_SELECT, take: input.limit, orderBy: { displayName: "asc" }, }); } export async function resolveResourceIdentifierSnapshot( ctx: ResourceReadContext, identifier: string, forbiddenMessage = "You can only view your own resource unless you have staff access", ) { let resource = await ctx.db.resource.findUnique({ where: { id: identifier }, select: RESOURCE_IDENTIFIER_SELECT, }); if (!resource) { resource = await ctx.db.resource.findUnique({ where: { eid: identifier }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource) { resource = await ctx.db.resource.findFirst({ where: { displayName: { equals: identifier, mode: "insensitive" } }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource && isBroadResourceLookupAllowed(ctx)) { resource = await ctx.db.resource.findFirst({ where: { displayName: { contains: identifier, mode: "insensitive" } }, select: RESOURCE_IDENTIFIER_SELECT, }); } if (!resource && isBroadResourceLookupAllowed(ctx)) { const words = identifier.split(/[\s,._\-/]+/).filter((word) => word.length >= 2); if (words.length > 0) { const candidates = await ctx.db.resource.findMany({ where: { OR: words.map((word) => ({ displayName: { contains: word, mode: "insensitive" as const }, })), }, select: RESOURCE_IDENTIFIER_SELECT, take: 5, }); if (candidates.length === 1) { resource = candidates[0]!; } else if (candidates.length > 1) { return { error: `Resource not found: "${identifier}". Did you mean one of these?`, suggestions: candidates.map((candidate: (typeof candidates)[number]) => ({ id: candidate.id, eid: candidate.eid, name: candidate.displayName, })), } as const; } } } if (!resource) { return { error: `Resource not found: ${identifier}` } as const; } await assertCanReadResource( ctx, resource.id, forbiddenMessage, ); return resource; } export async function readResourceByIdentifierDetailSnapshot( ctx: ResourceReadContext, identifier: string, ) { const resource = await resolveResourceIdentifierSnapshot(ctx, identifier); if ("error" in resource) { return resource; } const detail = await ctx.db.resource.findUnique({ where: { id: resource.id }, select: RESOURCE_IDENTIFIER_DETAIL_SELECT, }); if (!detail) { return { error: `Resource not found: ${identifier}` } as const; } return detail; }