feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user