diff --git a/packages/api/src/router/resource-identifier-read.ts b/packages/api/src/router/resource-identifier-read.ts new file mode 100644 index 0000000..0bdd18a --- /dev/null +++ b/packages/api/src/router/resource-identifier-read.ts @@ -0,0 +1,148 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { ROLE_BRIEF_SELECT } from "../db/selects.js"; +import { + anonymizeResource, + getAnonymizationDirectory, +} from "../lib/anonymization.js"; +import { assertCanReadResource } from "../lib/resource-access.js"; +import { protectedProcedure } from "../trpc.js"; +import { + mapResourceDetail, + readResourceByIdentifierDetailSnapshot, + resolveResourceIdentifierSnapshot, +} from "./resource-read-shared.js"; + +export const resourceIdentifierReadProcedures = { + resolveByIdentifier: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await resolveResourceIdentifierSnapshot( + ctx, + input.identifier, + "You can only resolve your own resource unless you have staff access", + ); + if ("error" in resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + return resource; + }), + + getHoverCard: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.id }, + select: { + id: true, + displayName: true, + eid: true, + email: true, + chapter: true, + lcrCents: true, + ucrCents: true, + currency: true, + chargeabilityTarget: true, + skills: true, + availability: true, + isActive: true, + areaRole: { select: ROLE_BRIEF_SELECT }, + country: { select: { name: true, code: true } }, + managementLevel: { select: { name: true } }, + resourceType: true, + }, + }), + "Resource", + ); + await assertCanReadResource( + ctx, + resource.id, + "You can only view hover details for your own resource unless you have staff access", + ); + const directory = await getAnonymizationDirectory(ctx.db); + const anon = anonymizeResource(resource, directory); + return { + id: anon.id, + displayName: anon.displayName ?? "", + eid: anon.eid ?? "", + chapter: resource.chapter, + lcrCents: resource.lcrCents, + ucrCents: resource.ucrCents, + currency: resource.currency, + chargeabilityTarget: resource.chargeabilityTarget, + skills: resource.skills as Record[], + isActive: resource.isActive, + resourceType: resource.resourceType, + areaRole: resource.areaRole, + country: resource.country, + managementLevel: resource.managementLevel, + }; + }), + + getByIdentifier: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)), + + getByIdentifierDetail: protectedProcedure + .input(z.object({ identifier: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier); + if ("error" in resource) { + return resource; + } + return mapResourceDetail(resource); + }), + + getById: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.id }, + include: { + blueprint: true, + resourceRoles: { + include: { role: { select: ROLE_BRIEF_SELECT } }, + }, + areaRole: { select: { id: true, name: true } }, + }, + }), + "Resource", + ); + await assertCanReadResource( + ctx, + resource.id, + "You can only view your own resource unless you have staff access", + ); + + const directory = await getAnonymizationDirectory(ctx.db); + return { + ...anonymizeResource(resource, directory), + isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id), + }; + }), + + getByEid: protectedProcedure + .input(z.object({ eid: z.string() })) + .query(async ({ ctx, input }) => { + const directory = await getAnonymizationDirectory(ctx.db); + let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } }); + if (!resource && directory) { + const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase()); + if (resourceId) { + resource = await ctx.db.resource.findUnique({ where: { id: resourceId } }); + } + } + if (!resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + await assertCanReadResource( + ctx, + resource.id, + "You can only view your own resource unless you have staff access", + ); + return anonymizeResource(resource, directory); + }), +}; diff --git a/packages/api/src/router/resource-read-shared.ts b/packages/api/src/router/resource-read-shared.ts new file mode 100644 index 0000000..cb5c3f8 --- /dev/null +++ b/packages/api/src/router/resource-read-shared.ts @@ -0,0 +1,618 @@ +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; +} diff --git a/packages/api/src/router/resource-read.ts b/packages/api/src/router/resource-read.ts new file mode 100644 index 0000000..f54cbca --- /dev/null +++ b/packages/api/src/router/resource-read.ts @@ -0,0 +1,7 @@ +import { resourceIdentifierReadProcedures } from "./resource-identifier-read.js"; +import { resourceSummaryReadProcedures } from "./resource-summary-read.js"; + +export const resourceReadProcedures = { + ...resourceSummaryReadProcedures, + ...resourceIdentifierReadProcedures, +}; diff --git a/packages/api/src/router/resource-summary-read.ts b/packages/api/src/router/resource-summary-read.ts new file mode 100644 index 0000000..6cc6a89 --- /dev/null +++ b/packages/api/src/router/resource-summary-read.ts @@ -0,0 +1,555 @@ +import { + calculateAllocation, + deriveResourceForecast, + getMonthRange, + DEFAULT_CALCULATION_RULES, + type AssignmentSlice, +} from "@capakraken/engine"; +import { VacationStatus } from "@capakraken/db"; +import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; +import { z } from "zod"; +import { + asHolidayResolverDb, + collectHolidayAvailability, + getResolvedCalendarHolidays, +} from "../lib/holiday-availability.js"; +import { + calculateEffectiveAvailableHours, + countEffectiveWorkingDays, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { assertCanReadResource } from "../lib/resource-access.js"; +import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js"; +import { + ResourceDirectoryQuerySchema, + ResourceListQuerySchema, + listStaffResources, + mapResourceSummary, + mapResourceSummaryDetail, + readResourceSummariesSnapshot, + readResourceSummaryDetailsSnapshot, +} from "./resource-read-shared.js"; + +function getAvailabilityHoursForDate( + availability: WeekdayAvailability, + date: Date, +): number { + const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; + return availability[dayKey] ?? 0; +} + +function sumAvailabilityHoursForDates( + availability: WeekdayAvailability, + dates: Date[], +): number { + return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); +} + +function formatResolvedHolidaySummary(holiday: { + date: string; + name: string; + scope: string; + calendarName: string | null; + sourceType?: string | null; +}) { + return { + date: holiday.date, + name: holiday.name, + scope: holiday.scope, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + }; +} + +function summarizeResolvedHolidaySummary(holidays: Array>) { + const byScope = new Map(); + const bySourceType = new Map(); + const byCalendar = new Map(); + + for (const holiday of holidays) { + byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); + bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); + byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); + } + + return { + byScope: [...byScope.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([scope, count]) => ({ scope, count })), + bySourceType: [...bySourceType.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sourceType, count]) => ({ sourceType, count })), + byCalendar: [...byCalendar.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([calendarName, count]) => ({ calendarName, count })), + }; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function averagePerWorkingDay(totalHours: number, workingDays: number): number { + return workingDays > 0 ? round1(totalHours / workingDays) : 0; +} + +export const resourceSummaryReadProcedures = { + resolveResponsiblePersonName: resourceOverviewProcedure + .input(z.object({ name: z.string() })) + .query(async ({ ctx, input }) => { + const exact = await ctx.db.resource.findFirst({ + where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true }, + }); + if (exact) { + return { + status: "resolved" as const, + displayName: exact.displayName, + }; + } + + const candidates = await ctx.db.resource.findMany({ + where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, + select: { displayName: true, eid: true }, + take: 5, + }); + if (candidates.length === 1) { + return { + status: "resolved" as const, + displayName: candidates[0]!.displayName, + }; + } + if (candidates.length > 1) { + return { + status: "ambiguous" as const, + message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, + candidates, + }; + } + + return { + status: "missing" as const, + message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, + candidates: [], + }; + }), + + getChargeabilitySummary: protectedProcedure + .input(z.object({ + resourceId: z.string(), + month: z.string().regex(/^\d{4}-\d{2}$/), + })) + .query(async ({ ctx, input }) => { + await assertCanReadResource( + ctx, + input.resourceId, + "You can only view chargeability details for your own resource unless you have staff access", + ); + + const [year, month] = input.month.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + + const resource = await ctx.db.resource.findUniqueOrThrow({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + eid: true, + fte: true, + lcrCents: true, + chargeabilityTarget: true, + countryId: true, + federalState: true, + metroCityId: true, + availability: true, + country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, + metroCity: { select: { id: true, name: true } }, + managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, + }, + }); + + const dailyHours = resource.country?.dailyWorkingHours ?? 8; + const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; + const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); + + const availability = resource.availability as WeekdayAvailability | null; + const weeklyAvailability: WeekdayAvailability = availability ?? { + monday: dailyHours, + tuesday: dailyHours, + wednesday: dailyHours, + thursday: dailyHours, + friday: dailyHours, + saturday: 0, + sunday: 0, + }; + + const assignments = await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, + }, + select: { + id: true, + hoursPerDay: true, + startDate: true, + endDate: true, + dailyCostCents: true, + status: true, + project: { + select: { + id: true, + name: true, + shortCode: true, + orderType: true, + utilizationCategory: { select: { code: true } }, + }, + }, + }, + }); + + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: VacationStatus.APPROVED, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + }); + + const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: monthStart, + periodEnd: monthEnd, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }); + const holidayAvailability = collectHolidayAvailability({ + vacations, + periodStart: monthStart, + periodEnd: monthEnd, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityName: resource.metroCity?.name, + resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), + }); + const absenceDays = holidayAvailability.absenceDays; + + const contexts = await loadResourceDailyAvailabilityContexts( + ctx.db, + [{ + id: resource.id, + availability: weeklyAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }], + monthStart, + monthEnd, + ); + const availabilityContext = contexts.get(resource.id); + + let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; + try { + const dbRules = await ctx.db.calculationRule.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + if (dbRules.length > 0) { + calcRules = dbRules as unknown as CalculationRule[]; + } + } catch { + // table may not exist yet + } + + const baseWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveWorkingDays = countEffectiveWorkingDays({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const effectiveAvailableHours = calculateEffectiveAvailableHours({ + availability: weeklyAvailability, + periodStart: monthStart, + periodEnd: monthEnd, + context: availabilityContext, + }); + + const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); + const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( + count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( + weeklyAvailability, + publicHolidayDates, + ); + const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + return sum + (absence.isHalfDay ? 0.5 : 1); + }, 0); + const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { + if (absence.type === "PUBLIC_HOLIDAY") { + return sum; + } + const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); + return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); + }, 0); + + const slices: AssignmentSlice[] = []; + const assignmentBreakdown: Array<{ + project: string; + code: string; + hours: number; + status: string; + }> = []; + let totalBookedHours = 0; + + for (const assignment of assignments) { + const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); + const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); + const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; + + const calcResult = calculateAllocation({ + lcrCents: resource.lcrCents, + hoursPerDay: assignment.hoursPerDay, + startDate: overlapStart, + endDate: overlapEnd, + availability: weeklyAvailability, + absenceDays, + calculationRules: calcRules, + orderType: assignment.project.orderType, + projectId: assignment.project.id, + }); + if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { + continue; + } + + totalBookedHours += calcResult.totalHours; + assignmentBreakdown.push({ + project: assignment.project.name, + code: assignment.project.shortCode, + hours: round1(calcResult.totalHours), + status: assignment.status, + }); + slices.push({ + hoursPerDay: assignment.hoursPerDay, + workingDays: calcResult.workingDays, + categoryCode, + ...(calcResult.totalChargeableHours !== undefined + ? { totalChargeableHours: calcResult.totalChargeableHours } + : {}), + }); + } + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetRatio, + assignments: slices, + sah: effectiveAvailableHours, + }); + + const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ + ...holiday, + calendarName: holiday.calendarName ?? "Built-in", + sourceType: holiday.sourceType ?? "system", + })); + const workingDays = round1(effectiveWorkingDays); + const baseWorkingDaysRounded = round1(baseWorkingDays); + const baseAvailableHoursRounded = round1(baseAvailableHours); + const availableHours = round1(effectiveAvailableHours); + const bookedHours = round1(totalBookedHours); + const targetPct = round1(targetRatio * 100); + const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; + const chargeabilityPct = round1(forecast.chg * 100); + const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); + + return { + resource: resource.displayName, + eid: resource.eid, + month: input.month, + periodStart: monthStart.toISOString().slice(0, 10), + periodEnd: monthEnd.toISOString().slice(0, 10), + fte: round1(resource.fte), + target: `${targetPct}%`, + targetPct, + targetHours, + workingDays, + baseWorkingDays: baseWorkingDaysRounded, + locationContext: { + countryCode: resource.country?.code ?? null, + country: resource.country?.name ?? resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCity: resource.metroCity?.name ?? null, + }, + baseAvailableHours: baseAvailableHoursRounded, + availableHours, + bookedHours, + unassignedHours, + chargeability: `${chargeabilityPct}%`, + chargeabilityPct, + onTarget: chargeabilityPct >= targetPct, + holidaySummary: { + count: formattedHolidays.length, + workdayCount: round1(publicHolidayWorkdayCount), + hoursDeduction: round1(publicHolidayHoursDeduction), + holidays: formattedHolidays, + breakdown: summarizeResolvedHolidaySummary(formattedHolidays), + }, + absenceSummary: { + dayEquivalent: round1(absenceDayEquivalent), + hoursDeduction: round1(absenceHoursDeduction), + }, + capacityBreakdown: { + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + baseAvailableHours: baseAvailableHoursRounded, + holidayHoursDeduction: round1(publicHolidayHoursDeduction), + absenceHoursDeduction: round1(absenceHoursDeduction), + availableHours, + }, + averages: { + availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), + bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), + remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), + }, + allocations: assignmentBreakdown, + scheduleContext: { + dailyWorkingHours: dailyHours, + hasScheduleRules: Boolean(scheduleRules), + }, + }; + }), + + listSummaries: resourceOverviewProcedure + .input(z.object({ + search: z.string().optional(), + country: z.string().optional(), + metroCity: z.string().optional(), + orgUnit: z.string().optional(), + roleName: z.string().optional(), + isActive: z.boolean().optional().default(true), + limit: z.number().int().min(1).max(100).default(50), + })) + .query(async ({ ctx, input }) => { + const resources = await readResourceSummariesSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummary); + }), + + listSummariesDetail: resourceOverviewProcedure + .input(z.object({ + search: z.string().optional(), + country: z.string().optional(), + metroCity: z.string().optional(), + orgUnit: z.string().optional(), + roleName: z.string().optional(), + isActive: z.boolean().optional().default(true), + limit: z.number().int().min(1).max(100).default(50), + })) + .query(async ({ ctx, input }) => { + const resources = await readResourceSummaryDetailsSnapshot(ctx, { + isActive: input.isActive, + limit: input.limit, + ...(input.search ? { search: input.search } : {}), + ...(input.country ? { country: input.country } : {}), + ...(input.metroCity ? { metroCity: input.metroCity } : {}), + ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), + ...(input.roleName ? { roleName: input.roleName } : {}), + }); + return resources.map(mapResourceSummaryDetail); + }), + + directory: protectedProcedure + .input(ResourceDirectoryQuerySchema) + .query(async ({ ctx, input }) => { + const { + chapter, + chapters, + isActive, + search, + eids, + countryIds, + excludedCountryIds, + includeWithoutCountry, + resourceTypes, + excludedResourceTypes, + includeWithoutResourceType, + rolledOff, + departed, + page, + limit, + cursor, + } = input; + + const resources = await listStaffResources(ctx, { + chapter, + chapters, + isActive, + search, + eids, + countryIds, + excludedCountryIds, + includeWithoutCountry, + resourceTypes, + excludedResourceTypes, + includeWithoutResourceType, + rolledOff, + departed, + page, + limit, + cursor, + includeRoles: false, + }); + + return { + ...resources, + resources: resources.resources.map((resource) => ({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + chapter: resource.chapter, + isActive: resource.isActive, + })), + }; + }), + + listStaff: resourceOverviewProcedure + .input(ResourceListQuerySchema) + .query(async ({ ctx, input }) => listStaffResources(ctx, input)), + + chapters: protectedProcedure.query(async ({ ctx }) => { + const resources = await ctx.db.resource.findMany({ + where: { isActive: true, chapter: { not: null } }, + select: { chapter: true }, + distinct: ["chapter"], + orderBy: { chapter: "asc" }, + }); + return resources.map((resource) => resource.chapter as string); + }), +}; diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index bf64662..da0c23b 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -7,37 +7,22 @@ import { } from "@capakraken/application"; import { calculateAllocation, - deriveResourceForecast, - getMonthRange, - DEFAULT_CALCULATION_RULES, - type AssignmentSlice, } from "@capakraken/engine"; -import { VacationStatus } from "@capakraken/db"; -import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, SystemRole, UpdateResourceSchema, inferStateFromPostalCode, resolvePermissions, type PermissionOverrides } from "@capakraken/shared"; -import type { CalculationRule, SpainScheduleRule } from "@capakraken/shared"; +import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; -import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { anonymizeResource, anonymizeResources, - anonymizeSearchMatches, getAnonymizationDirectory, - resolveResourceIdsByDisplayedEids, } from "../lib/anonymization.js"; -import { - asHolidayResolverDb, - collectHolidayAvailability, - getResolvedCalendarHolidays, -} from "../lib/holiday-availability.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, calculateEffectiveDayAvailability, - countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; -import { fmtEur } from "../lib/format-utils.js"; +import { logger } from "../lib/logger.js"; export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool. @@ -51,22 +36,10 @@ Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff. import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; -import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission, resourceOverviewProcedure } from "../trpc.js"; +import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; -import type { TRPCContext } from "../trpc.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; -} +import { resolveResourcePermissions } from "../lib/resource-access.js"; +import { resourceReadProcedures } from "./resource-read.js"; type BookingForCapacity = { startDate: Date; @@ -112,1448 +85,8 @@ function buildDailyBookedHoursMap( return dailyBookedHours; } -function getAvailabilityHoursForDate( - availability: WeekdayAvailability, - date: Date, -): number { - const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; - return availability[dayKey] ?? 0; -} - -function sumAvailabilityHoursForDates( - availability: WeekdayAvailability, - dates: Date[], -): number { - return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); -} - -function formatResolvedHolidaySummary(holiday: { - date: string; - name: string; - scope: string; - calendarName: string | null; - sourceType?: string | null; -}) { - return { - date: holiday.date, - name: holiday.name, - scope: holiday.scope, - calendarName: holiday.calendarName ?? "Built-in", - sourceType: holiday.sourceType ?? "system", - }; -} - -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; - -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, - }; -} - -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, - }; -} - -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 summarizeResolvedHolidaySummary(holidays: Array>) { - const byScope = new Map(); - const bySourceType = new Map(); - const byCalendar = new Map(); - - for (const holiday of holidays) { - byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); - bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); - byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); - } - - return { - byScope: [...byScope.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([scope, count]) => ({ scope, count })), - bySourceType: [...bySourceType.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([sourceType, count]) => ({ sourceType, count })), - byCalendar: [...byCalendar.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([calendarName, count]) => ({ calendarName, count })), - }; -} - -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - -function averagePerWorkingDay(totalHours: number, workingDays: number): number { - return workingDays > 0 ? round1(totalHours / workingDays) : 0; -} - -type ResourceReadContext = Pick; - -function resolveResourcePermissions(ctx: Pick): Set { - if (!ctx.dbUser) { - return new Set(); - } - - return resolvePermissions( - ctx.dbUser.systemRole as SystemRole, - ctx.dbUser.permissionOverrides as PermissionOverrides | null, - ctx.roleDefaults ?? undefined, - ); -} - -function canReadAllResources(ctx: Pick): boolean { - const permissions = resolveResourcePermissions(ctx); - return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES); -} - -async function findOwnedResourceId(ctx: ResourceReadContext): Promise { - if (!ctx.dbUser?.id) { - return null; - } - - if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { - return null; - } - - const resource = await ctx.db.resource.findFirst({ - where: { userId: ctx.dbUser.id }, - select: { id: true }, - }); - - return resource?.id ?? null; -} - -async function assertCanReadResource( - ctx: ResourceReadContext, - resourceId: string, - message = "You can only view your own resource data", -): Promise { - if (canReadAllResources(ctx)) { - return; - } - - const ownedResourceId = await findOwnedResourceId(ctx); - if (!ownedResourceId || ownedResourceId !== resourceId) { - throw new TRPCError({ - code: "FORBIDDEN", - message, - }); - } -} - -function isBroadResourceLookupAllowed(ctx: Pick): boolean { - return canReadAllResources(ctx); -} - -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(), -}); - -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(), -}); - -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", - }); - } -} - -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" }, - }); -} - -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" }, - }); -} - -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) => ({ - 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; -} - -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; -} - export const resourceRouter = createTRPCRouter({ - resolveByIdentifier: protectedProcedure - .input(z.object({ identifier: z.string() })) - .query(async ({ ctx, input }) => { - const resource = await resolveResourceIdentifierSnapshot( - ctx, - input.identifier, - "You can only resolve your own resource unless you have staff access", - ); - if ("error" in resource) { - throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - } - return resource; - }), - - resolveResponsiblePersonName: resourceOverviewProcedure - .input(z.object({ name: z.string() })) - .query(async ({ ctx, input }) => { - const exact = await ctx.db.resource.findFirst({ - where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, - select: { displayName: true }, - }); - if (exact) { - return { - status: "resolved" as const, - displayName: exact.displayName, - }; - } - - const candidates = await ctx.db.resource.findMany({ - where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, - select: { displayName: true, eid: true }, - take: 5, - }); - if (candidates.length === 1) { - return { - status: "resolved" as const, - displayName: candidates[0]!.displayName, - }; - } - if (candidates.length > 1) { - return { - status: "ambiguous" as const, - message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, - candidates, - }; - } - - return { - status: "missing" as const, - message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, - candidates: [], - }; - }), - - getChargeabilitySummary: protectedProcedure - .input(z.object({ - resourceId: z.string(), - month: z.string().regex(/^\d{4}-\d{2}$/), - })) - .query(async ({ ctx, input }) => { - await assertCanReadResource( - ctx, - input.resourceId, - "You can only view chargeability details for your own resource unless you have staff access", - ); - - const [year, month] = input.month.split("-").map(Number) as [number, number]; - const { start: monthStart, end: monthEnd } = getMonthRange(year, month); - - const resource = await ctx.db.resource.findUniqueOrThrow({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - eid: true, - fte: true, - lcrCents: true, - chargeabilityTarget: true, - countryId: true, - federalState: true, - metroCityId: true, - availability: true, - country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, - metroCity: { select: { id: true, name: true } }, - managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, - }, - }); - - const dailyHours = resource.country?.dailyWorkingHours ?? 8; - const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; - const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); - - const availability = resource.availability as WeekdayAvailability | null; - const weeklyAvailability: WeekdayAvailability = availability ?? { - monday: dailyHours, - tuesday: dailyHours, - wednesday: dailyHours, - thursday: dailyHours, - friday: dailyHours, - saturday: 0, - sunday: 0, - }; - - const assignments = await ctx.db.assignment.findMany({ - where: { - resourceId: input.resourceId, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, - }, - select: { - id: true, - hoursPerDay: true, - startDate: true, - endDate: true, - dailyCostCents: true, - status: true, - project: { - select: { - id: true, - name: true, - shortCode: true, - orderType: true, - utilizationCategory: { select: { code: true } }, - }, - }, - }, - }); - - const vacations = await ctx.db.vacation.findMany({ - where: { - resourceId: input.resourceId, - status: VacationStatus.APPROVED, - startDate: { lte: monthEnd }, - endDate: { gte: monthStart }, - }, - select: { startDate: true, endDate: true, type: true, isHalfDay: true }, - }); - - const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: monthStart, - periodEnd: monthEnd, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }); - const holidayAvailability = collectHolidayAvailability({ - vacations, - periodStart: monthStart, - periodEnd: monthEnd, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityName: resource.metroCity?.name, - resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), - }); - const absenceDays = holidayAvailability.absenceDays; - - const contexts = await loadResourceDailyAvailabilityContexts( - ctx.db, - [{ - id: resource.id, - availability: weeklyAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - }], - monthStart, - monthEnd, - ); - const availabilityContext = contexts.get(resource.id); - - let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; - try { - const dbRules = await ctx.db.calculationRule.findMany({ - where: { isActive: true }, - orderBy: [{ priority: "desc" }], - }); - if (dbRules.length > 0) { - calcRules = dbRules as unknown as CalculationRule[]; - } - } catch { - // table may not exist yet - } - - const baseWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveWorkingDays = countEffectiveWorkingDays({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - const baseAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: undefined, - }); - const effectiveAvailableHours = calculateEffectiveAvailableHours({ - availability: weeklyAvailability, - periodStart: monthStart, - periodEnd: monthEnd, - context: availabilityContext, - }); - - const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); - const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( - count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) - ), 0); - const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( - weeklyAvailability, - publicHolidayDates, - ); - const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - return sum + (absence.isHalfDay ? 0.5 : 1); - }, 0); - const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { - if (absence.type === "PUBLIC_HOLIDAY") { - return sum; - } - const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); - return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); - }, 0); - - const slices: AssignmentSlice[] = []; - const assignmentBreakdown: Array<{ - project: string; - code: string; - hours: number; - status: string; - }> = []; - let totalBookedHours = 0; - - for (const assignment of assignments) { - const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); - const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); - const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; - - const calcResult = calculateAllocation({ - lcrCents: resource.lcrCents, - hoursPerDay: assignment.hoursPerDay, - startDate: overlapStart, - endDate: overlapEnd, - availability: weeklyAvailability, - absenceDays, - calculationRules: calcRules, - orderType: assignment.project.orderType, - projectId: assignment.project.id, - }); - if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { - continue; - } - - totalBookedHours += calcResult.totalHours; - assignmentBreakdown.push({ - project: assignment.project.name, - code: assignment.project.shortCode, - hours: round1(calcResult.totalHours), - status: assignment.status, - }); - slices.push({ - hoursPerDay: assignment.hoursPerDay, - workingDays: calcResult.workingDays, - categoryCode, - ...(calcResult.totalChargeableHours !== undefined - ? { totalChargeableHours: calcResult.totalChargeableHours } - : {}), - }); - } - - const forecast = deriveResourceForecast({ - fte: resource.fte, - targetPercentage: targetRatio, - assignments: slices, - sah: effectiveAvailableHours, - }); - - const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ - ...holiday, - calendarName: holiday.calendarName ?? "Built-in", - sourceType: holiday.sourceType ?? "system", - })); - const workingDays = round1(effectiveWorkingDays); - const baseWorkingDaysRounded = round1(baseWorkingDays); - const baseAvailableHoursRounded = round1(baseAvailableHours); - const availableHours = round1(effectiveAvailableHours); - const bookedHours = round1(totalBookedHours); - const targetPct = round1(targetRatio * 100); - const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; - const chargeabilityPct = round1(forecast.chg * 100); - const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); - - return { - resource: resource.displayName, - eid: resource.eid, - month: input.month, - periodStart: monthStart.toISOString().slice(0, 10), - periodEnd: monthEnd.toISOString().slice(0, 10), - fte: round1(resource.fte), - target: `${targetPct}%`, - targetPct, - targetHours, - workingDays, - baseWorkingDays: baseWorkingDaysRounded, - locationContext: { - countryCode: resource.country?.code ?? null, - country: resource.country?.name ?? resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCity: resource.metroCity?.name ?? null, - }, - baseAvailableHours: baseAvailableHoursRounded, - availableHours, - bookedHours, - unassignedHours, - chargeability: `${chargeabilityPct}%`, - chargeabilityPct, - onTarget: chargeabilityPct >= targetPct, - holidaySummary: { - count: formattedHolidays.length, - workdayCount: round1(publicHolidayWorkdayCount), - hoursDeduction: round1(publicHolidayHoursDeduction), - holidays: formattedHolidays, - breakdown: summarizeResolvedHolidaySummary(formattedHolidays), - }, - absenceSummary: { - dayEquivalent: round1(absenceDayEquivalent), - hoursDeduction: round1(absenceHoursDeduction), - }, - capacityBreakdown: { - formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", - baseAvailableHours: baseAvailableHoursRounded, - holidayHoursDeduction: round1(publicHolidayHoursDeduction), - absenceHoursDeduction: round1(absenceHoursDeduction), - availableHours, - }, - averages: { - availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), - bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), - remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), - }, - allocations: assignmentBreakdown, - scheduleContext: { - dailyWorkingHours: dailyHours, - hasScheduleRules: Boolean(scheduleRules), - }, - }; - }), - - listSummaries: resourceOverviewProcedure - .input(z.object({ - search: z.string().optional(), - country: z.string().optional(), - metroCity: z.string().optional(), - orgUnit: z.string().optional(), - roleName: z.string().optional(), - isActive: z.boolean().optional().default(true), - limit: z.number().int().min(1).max(100).default(50), - })) - .query(async ({ ctx, input }) => { - const resources = await readResourceSummariesSnapshot(ctx, { - isActive: input.isActive, - limit: input.limit, - ...(input.search ? { search: input.search } : {}), - ...(input.country ? { country: input.country } : {}), - ...(input.metroCity ? { metroCity: input.metroCity } : {}), - ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), - ...(input.roleName ? { roleName: input.roleName } : {}), - }); - return resources.map(mapResourceSummary); - }), - - listSummariesDetail: resourceOverviewProcedure - .input(z.object({ - search: z.string().optional(), - country: z.string().optional(), - metroCity: z.string().optional(), - orgUnit: z.string().optional(), - roleName: z.string().optional(), - isActive: z.boolean().optional().default(true), - limit: z.number().int().min(1).max(100).default(50), - })) - .query(async ({ ctx, input }) => { - const resources = await readResourceSummaryDetailsSnapshot(ctx, { - isActive: input.isActive, - limit: input.limit, - ...(input.search ? { search: input.search } : {}), - ...(input.country ? { country: input.country } : {}), - ...(input.metroCity ? { metroCity: input.metroCity } : {}), - ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), - ...(input.roleName ? { roleName: input.roleName } : {}), - }); - return resources.map(mapResourceSummaryDetail); - }), - - directory: protectedProcedure - .input(ResourceDirectoryQuerySchema) - .query(async ({ ctx, input }) => { - const { - chapter, - chapters, - isActive, - search, - eids, - countryIds, - excludedCountryIds, - includeWithoutCountry, - resourceTypes, - excludedResourceTypes, - includeWithoutResourceType, - rolledOff, - departed, - page, - limit, - cursor, - } = input; - - const parsedCursor = parseResourceCursor(cursor); - 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 } }, - ], - }); - } - 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 }); - } - - const where = andClauses.length > 0 ? { AND: andClauses } : {}; - const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }]; - - if (directory) { - const rawResources = await ctx.db.resource.findMany({ - where, - select: { - id: true, - eid: true, - displayName: true, - chapter: true, - isActive: true, - email: true, - }, - orderBy, - }); - const requestedIds = eids - ? resolveResourceIdsByDisplayedEids(rawResources, 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).map((resource) => ({ - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - chapter: resource.chapter, - isActive: resource.isActive, - })); - 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 whereWithCursor = parsedCursor - ? { - AND: [ - ...((where as { AND?: WhereClause[] }).AND ?? []), - { - OR: [ - { displayName: { gt: parsedCursor.displayName } }, - { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } }, - ], - }, - ], - } - : where; - - const [rawResources, total] = await Promise.all([ - ctx.db.resource.findMany({ - where: whereWithCursor, - skip, - take: limit + 1, - orderBy, - select: { - id: true, - eid: true, - displayName: true, - chapter: true, - isActive: true, - }, - }), - 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 }; - }), - - listStaff: resourceOverviewProcedure - .input(ResourceListQuerySchema) - .query(async ({ ctx, input }) => listStaffResources(ctx, input)), - - /** Lightweight resource card for hover tooltips on the timeline. */ - getHoverCard: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.id }, - select: { - id: true, - displayName: true, - eid: true, - email: true, - chapter: true, - lcrCents: true, - ucrCents: true, - currency: true, - chargeabilityTarget: true, - skills: true, - availability: true, - isActive: true, - areaRole: { select: ROLE_BRIEF_SELECT }, - country: { select: { name: true, code: true } }, - managementLevel: { select: { name: true } }, - resourceType: true, - }, - }), - "Resource", - ); - await assertCanReadResource( - ctx, - resource.id, - "You can only view hover details for your own resource unless you have staff access", - ); - const directory = await getAnonymizationDirectory(ctx.db); - const anon = anonymizeResource(resource, directory); - return { - id: anon.id, - displayName: anon.displayName ?? "", - eid: anon.eid ?? "", - chapter: resource.chapter, - lcrCents: resource.lcrCents, - ucrCents: resource.ucrCents, - currency: resource.currency, - chargeabilityTarget: resource.chargeabilityTarget, - skills: resource.skills as Record[], - isActive: resource.isActive, - resourceType: resource.resourceType, - areaRole: resource.areaRole, - country: resource.country, - managementLevel: resource.managementLevel, - }; - }), - - getByIdentifier: protectedProcedure - .input(z.object({ identifier: z.string() })) - .query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)), - - getByIdentifierDetail: protectedProcedure - .input(z.object({ identifier: z.string() })) - .query(async ({ ctx, input }) => { - const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier); - if ("error" in resource) { - return resource; - } - return mapResourceDetail(resource); - }), - - getById: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.id }, - include: { - blueprint: true, - resourceRoles: { - include: { role: { select: ROLE_BRIEF_SELECT } }, - }, - areaRole: { select: { id: true, name: true } }, - }, - }), - "Resource", - ); - await assertCanReadResource( - ctx, - resource.id, - "You can only view your own resource unless you have staff access", - ); - - const directory = await getAnonymizationDirectory(ctx.db); - return { - ...anonymizeResource(resource, directory), - isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id), - }; - }), - - getByEid: protectedProcedure - .input(z.object({ eid: z.string() })) - .query(async ({ ctx, input }) => { - const directory = await getAnonymizationDirectory(ctx.db); - let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } }); - if (!resource && directory) { - const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase()); - if (resourceId) { - resource = await ctx.db.resource.findUnique({ where: { id: resourceId } }); - } - } - if (!resource) { - throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - } - await assertCanReadResource( - ctx, - resource.id, - "You can only view your own resource unless you have staff access", - ); - return anonymizeResource(resource, directory); - }), + ...resourceReadProcedures, create: managerProcedure .input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() })) @@ -1781,16 +314,6 @@ export const resourceRouter = createTRPCRouter({ return { count: updated.length }; }), - chapters: protectedProcedure.query(async ({ ctx }) => { - const resources = await ctx.db.resource.findMany({ - where: { isActive: true, chapter: { not: null } }, - select: { chapter: true }, - distinct: ["chapter"], - orderBy: { chapter: "asc" }, - }); - return resources.map((r) => r.chapter as string); - }), - // ─── Skill Matrix Import ──────────────────────────────────────────────────── importSkillMatrix: protectedProcedure @@ -1810,7 +333,7 @@ export const resourceRouter = createTRPCRouter({ // Find the resource linked to this user const user = await findUniqueOrThrow( ctx.db.user.findUnique({ - where: { email: ctx.session.user?.email ?? "" }, + where: { id: ctx.dbUser!.id }, include: { resource: true }, }), "User", @@ -1987,19 +510,37 @@ export const resourceRouter = createTRPCRouter({ let completion; try { completion = await callChatCompletions(true); - console.log("[generateAiSummary] chat.completions response:", JSON.stringify({ - choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })), - })); + logger.debug( + { + provider, + model, + choiceCount: completion.choices?.length ?? 0, + }, + "AI summary chat completion succeeded", + ); } catch (tempErr) { const status = (tempErr as { status?: number }).status; const msg = (tempErr as Error).message ?? ""; - console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200)); if (status === 400 && msg.includes("temperature")) { + logger.info( + { provider, model, status }, + "Retrying AI summary generation without temperature override", + ); completion = await callChatCompletions(false); } else if (status === 404) { - console.log("[generateAiSummary] falling back to responses API"); + logger.info( + { provider, model, status }, + "Falling back to AI responses API for summary generation", + ); const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens }); - console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100)); + logger.debug( + { + provider, + model, + summaryLength: resp.output_text?.trim().length ?? 0, + }, + "AI summary responses API call succeeded", + ); summary = resp.output_text?.trim() ?? ""; completion = null; } else { @@ -2139,10 +680,8 @@ export const resourceRouter = createTRPCRouter({ /** Get the resource linked to the current user (for self-service pages). */ getMyResource: protectedProcedure.query(async ({ ctx }) => { - const email = ctx.session.user?.email; - if (!email) return null; const user = await ctx.db.user.findUnique({ - where: { email }, + where: { id: ctx.dbUser!.id }, select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } }, }); const directory = await getAnonymizationDirectory(ctx.db);