feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
+486 -11
View File
@@ -16,17 +16,481 @@ import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import { logger } from "../lib/logger.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import type { TRPCContext } from "../trpc.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
const PROJECT_SUMMARY_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
client: { select: { name: true } },
} as const;
const PROJECT_SUMMARY_DETAIL_SELECT = {
...PROJECT_SUMMARY_SELECT,
budgetCents: true,
winProbability: true,
_count: { select: { assignments: true, estimates: true } },
} as const;
const PROJECT_IDENTIFIER_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
} as const;
const PROJECT_DETAIL_SELECT = {
...PROJECT_IDENTIFIER_SELECT,
id: true,
shortCode: true,
name: true,
status: true,
orderType: true,
allocationType: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
responsiblePerson: true,
client: { select: { name: true } },
utilizationCategory: { select: { code: true, name: true } },
_count: { select: { assignments: true, estimates: true } },
} as const;
function runProjectBackgroundEffect(
effectName: string,
execute: () => unknown,
metadata: Record<string, unknown> = {},
): void {
void Promise.resolve()
.then(execute)
.catch((error) => {
logger.error(
{ err: error, effectName, ...metadata },
"Project background side effect failed",
);
});
}
function invalidateDashboardCacheInBackground(): void {
runProjectBackgroundEffect("invalidateDashboardCache", () => invalidateDashboardCache());
}
function dispatchProjectWebhookInBackground(
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
): void {
runProjectBackgroundEffect(
"dispatchWebhooks",
() => dispatchWebhooks(db, event, payload),
{ event },
);
}
function formatDate(value: Date | null): string | null {
return value ? value.toISOString().slice(0, 10) : null;
}
function mapProjectSummary(project: {
id: string;
shortCode: string;
name: string;
status: string;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
};
}
function mapProjectSummaryDetail(project: {
id: string;
shortCode: string;
name: string;
status: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
client: { name: string } | null;
_count: { assignments: number; estimates: number };
}) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
client: project.client?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
};
}
function mapProjectDetail(
project: {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
allocationType: string;
budgetCents: number | null;
winProbability: number;
startDate: Date | null;
endDate: Date | null;
responsiblePerson: string | null;
client: { name: string } | null;
utilizationCategory: { code: string; name: string } | null;
_count: { assignments: number; estimates: number };
},
topAssignments: Array<{
resource: { displayName: string; eid: string };
role: string | null;
status: string;
hoursPerDay: number;
startDate: Date;
endDate: Date;
}>,
) {
return {
id: project.id,
code: project.shortCode,
name: project.name,
status: project.status,
orderType: project.orderType,
allocationType: project.allocationType,
budget: project.budgetCents && project.budgetCents > 0 ? fmtEur(project.budgetCents) : "Not set",
budgetCents: project.budgetCents,
winProbability: `${project.winProbability}%`,
start: formatDate(project.startDate),
end: formatDate(project.endDate),
responsible: project.responsiblePerson,
client: project.client?.name ?? null,
category: project.utilizationCategory?.name ?? null,
assignmentCount: project._count.assignments,
estimateCount: project._count.estimates,
topAllocations: topAssignments.map((assignment) => ({
resource: assignment.resource.displayName,
eid: assignment.resource.eid,
role: assignment.role ?? null,
status: assignment.status,
hoursPerDay: assignment.hoursPerDay,
start: formatDate(assignment.startDate),
end: formatDate(assignment.endDate),
})),
};
}
async function readProjectSummariesSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function readProjectSummaryDetailsSnapshot(
ctx: Pick<TRPCContext, "db">,
input: {
search?: string | undefined;
status?: ProjectStatus | undefined;
limit: number;
},
) {
const buildWhere = (search: string | undefined) => ({
...(input.status ? { status: input.status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
});
let projects = await ctx.db.project.findMany({
where: buildWhere(input.search),
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit,
orderBy: { name: "asc" },
});
if (projects.length === 0 && input.search) {
const words = input.search.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
if (words.length > 1) {
const candidates = await ctx.db.project.findMany({
where: {
...(input.status ? { status: input.status } : {}),
OR: words.flatMap((word) => ([
{ name: { contains: word, mode: "insensitive" as const } },
{ shortCode: { contains: word, mode: "insensitive" as const } },
])),
},
select: PROJECT_SUMMARY_DETAIL_SELECT,
take: input.limit * 2,
orderBy: { name: "asc" },
});
projects = candidates
.map((project) => {
const haystack = `${project.name} ${project.shortCode}`.toLowerCase();
const matchCount = words.filter((word) => haystack.includes(word.toLowerCase())).length;
return { project, matchCount };
})
.sort((left, right) => right.matchCount - left.matchCount)
.slice(0, input.limit)
.map((entry) => entry.project);
}
}
return {
items: projects,
exactMatch: input.search
? projects.some((project) =>
project.name.toLowerCase().includes(input.search!.toLowerCase())
|| project.shortCode.toLowerCase().includes(input.search!.toLowerCase()))
: true,
};
}
async function resolveProjectIdentifierSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
let project = await ctx.db.project.findUnique({
where: { id: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: identifier },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: identifier, mode: "insensitive" } },
select: PROJECT_IDENTIFIER_SELECT,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}
async function readProjectByIdentifierDetailSnapshot(
ctx: Pick<TRPCContext, "db">,
identifier: string,
) {
const projectIdentity = await resolveProjectIdentifierSnapshot(ctx, identifier);
const project = await ctx.db.project.findUnique({
where: { id: projectIdentity.id },
select: PROJECT_DETAIL_SELECT,
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const topAssignments = await ctx.db.assignment.findMany({
where: {
projectId: project.id,
status: { not: "CANCELLED" },
},
select: {
resource: { select: { displayName: true, eid: true } },
role: true,
status: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
take: 10,
orderBy: { startDate: "desc" },
});
return {
...project,
topAssignments,
};
}
export const projectRouter = createTRPCRouter({
list: protectedProcedure
resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const select = {
id: true,
shortCode: true,
name: true,
status: true,
responsiblePerson: true,
startDate: true,
endDate: true,
} as const;
let project = await ctx.db.project.findUnique({
where: { id: input.identifier },
select,
});
if (!project) {
project = await ctx.db.project.findUnique({
where: { shortCode: input.identifier },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { equals: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
project = await ctx.db.project.findFirst({
where: { name: { contains: input.identifier, mode: "insensitive" } },
select,
});
}
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return project;
}),
searchSummaries: protectedProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummariesSnapshot(ctx, input);
const formatted = items.map(mapProjectSummary);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
searchSummariesDetail: controllerProcedure
.input(z.object({
search: z.string().optional(),
status: z.nativeEnum(ProjectStatus).optional(),
limit: z.number().int().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const { items, exactMatch } = await readProjectSummaryDetailsSnapshot(ctx, input);
const formatted = items.map(mapProjectSummaryDetail);
if (items.length > 0 && input.search && !exactMatch) {
return {
suggestions: formatted,
note: `No exact match for "${input.search}". These projects match some of the search terms:`,
};
}
return formatted;
}),
list: controllerProcedure
.input(
PaginationInputSchema.extend({
status: z.nativeEnum(ProjectStatus).optional(),
@@ -90,7 +554,7 @@ export const projectRouter = createTRPCRouter({
};
}),
getById: protectedProcedure
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
@@ -113,7 +577,18 @@ export const projectRouter = createTRPCRouter({
};
}),
getShoringRatio: protectedProcedure
getByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => resolveProjectIdentifierSnapshot(ctx, input.identifier)),
getByIdentifierDetail: controllerProcedure
.input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => {
const project = await readProjectByIdentifierDetailSnapshot(ctx, input.identifier);
return mapProjectDetail(project, project.topAssignments);
}),
getShoringRatio: controllerProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
@@ -241,8 +716,8 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.created", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.created", {
id: project.id,
shortCode: project.shortCode,
name: project.name,
@@ -302,7 +777,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return updated;
}),
@@ -314,8 +789,8 @@ export const projectRouter = createTRPCRouter({
where: { id: input.id },
data: { status: input.status },
});
void invalidateDashboardCache();
void dispatchWebhooks(ctx.db, "project.status_changed", {
invalidateDashboardCacheInBackground();
dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", {
id: result.id,
shortCode: result.shortCode,
name: result.name,
@@ -348,7 +823,7 @@ export const projectRouter = createTRPCRouter({
},
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: updated.length };
}),
@@ -454,7 +929,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { id: input.id, name: project.name };
}),
@@ -494,7 +969,7 @@ export const projectRouter = createTRPCRouter({
});
});
void invalidateDashboardCache();
invalidateDashboardCacheInBackground();
return { count: projects.length };
}),