security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51, PR #59)
CI / Architecture Guardrails (push) Successful in 3m38s
CI / Assistant Split Regression (push) Successful in 4m40s
CI / Lint (push) Successful in 5m17s
CI / Typecheck (push) Successful in 5m46s
CI / Build (push) Successful in 7m1s
CI / Unit Tests (push) Failing after 9m41s
CI / Release Images (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / E2E Tests (push) Has started running

Closes #51 (ESLint rule + conventions doc remain as follow-up).

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
This commit was merged in pull request #59.
This commit is contained in:
2026-04-18 13:53:28 +02:00
committed by Hartmut
parent f0251a654a
commit 17471af7f8
12 changed files with 254 additions and 148 deletions
@@ -1,6 +1,7 @@
import { renderToBuffer } from "@react-pdf/renderer"; import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react"; import { createElement } from "react";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod";
import { buildSplitAllocationReadModel } from "@capakraken/application"; import { buildSplitAllocationReadModel } from "@capakraken/application";
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api"; import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
import { prisma } from "@capakraken/db"; import { prisma } from "@capakraken/db";
@@ -11,6 +12,17 @@ import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]); const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
// certainly malformed input and would generate nonsensical SQL range scans.
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
const queryParamsSchema = z.object({
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
format: z.enum(["pdf", "xlsx"]).default("pdf"),
});
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
@@ -23,9 +35,20 @@ export async function GET(request: Request) {
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date(); const parsed = queryParamsSchema.safeParse({
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); startDate: searchParams.get("startDate") ?? undefined,
const format = searchParams.get("format") ?? "pdf"; endDate: searchParams.get("endDate") ?? undefined,
format: searchParams.get("format") ?? undefined,
});
if (!parsed.success) {
return new NextResponse("Invalid query parameters", { status: 400 });
}
const startDate = parsed.data.startDate ?? new Date();
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
if (endDate < startDate) {
return new NextResponse("endDate must be >= startDate", { status: 400 });
}
const format = parsed.data.format;
const [demandRequirements, assignments] = await Promise.all([ const [demandRequirements, assignments] = await Promise.all([
prisma.demandRequirement.findMany({ prisma.demandRequirement.findMany({
@@ -62,21 +85,25 @@ export async function GET(request: Request) {
const assignmentRows = allocationView.assignments.slice(0, 500); const assignmentRows = allocationView.assignments.slice(0, 500);
const directory = await getAnonymizationDirectory(prisma); const directory = await getAnonymizationDirectory(prisma);
const rows = assignmentRows.map((a: AllocationLike & { const rows = assignmentRows.map(
resource?: { id: string; displayName?: string | null } | null; (
project?: { shortCode: string; name: string } | null; a: AllocationLike & {
}) => { resource?: { id: string; displayName?: string | null } | null;
const resource = a.resource ? anonymizeResource(a.resource, directory) : null; project?: { shortCode: string; name: string } | null;
return { },
resourceName: resource?.displayName ?? "Unknown", ) => {
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project", const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
role: a.role ?? "", return {
startDate: new Date(a.startDate).toLocaleDateString("en-GB"), resourceName: resource?.displayName ?? "Unknown",
endDate: new Date(a.endDate).toLocaleDateString("en-GB"), projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
hoursPerDay: a.hoursPerDay, role: a.role ?? "",
dailyCostCents: a.dailyCostCents, startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
}; endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
}); hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
};
},
);
const ts = Date.now(); const ts = Date.now();
@@ -9,6 +9,11 @@ import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
// Bounded connection tracking: a single user opening 100 tabs should not be
// able to pin 100 persistent subscriptions on this node.
const MAX_SSE_CONNECTIONS_PER_USER = 8;
const sseConnectionsByUser = new Map<string, number>();
export async function GET() { export async function GET() {
// Start lazily on the first real SSE request so builds/import-time evaluation // Start lazily on the first real SSE request so builds/import-time evaluation
// never attempt reminder processing against a live database. // never attempt reminder processing against a live database.
@@ -43,6 +48,24 @@ export async function GET() {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
const currentCount = sseConnectionsByUser.get(dbUser.id) ?? 0;
if (currentCount >= MAX_SSE_CONNECTIONS_PER_USER) {
return new Response("Too many SSE connections", {
status: 429,
headers: { "Retry-After": "30" },
});
}
sseConnectionsByUser.set(dbUser.id, currentCount + 1);
const releaseSlot = () => {
const next = (sseConnectionsByUser.get(dbUser.id) ?? 1) - 1;
if (next <= 0) {
sseConnectionsByUser.delete(dbUser.id);
} else {
sseConnectionsByUser.set(dbUser.id, next);
}
};
const roleDefaults = await loadRoleDefaults(); const roleDefaults = await loadRoleDefaults();
const subscription = deriveUserSseSubscription( const subscription = deriveUserSseSubscription(
{ {
@@ -85,6 +108,7 @@ export async function GET() {
} catch { } catch {
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe();
releaseSlot();
} }
}, 30000); }, 30000);
@@ -92,8 +116,12 @@ export async function GET() {
return () => { return () => {
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe();
releaseSlot();
}; };
}, },
cancel() {
releaseSlot();
},
}); });
return new Response(stream, { return new Response(stream, {
+22
View File
@@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null {
return null; return null;
} }
// Hard cap on tRPC request body size to prevent memory/CPU amplification from
// a single oversized payload. Stream uploads (files, reports) don't go through
// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call.
const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024;
// Throttle lastActiveAt updates: max once per 60s per user // Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>(); const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000; const ACTIVITY_THROTTLE_MS = 60_000;
@@ -37,6 +42,23 @@ function trackActivity(userId: string) {
} }
const handler = async (req: NextRequest) => { const handler = async (req: NextRequest) => {
// Reject oversized bodies before we touch auth, DB, or the router. A tRPC
// mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is
// advisory — also guard against chunked requests below via length check
// on the cloned body.
if (req.method !== "GET") {
const declaredLength = req.headers.get("content-length");
if (declaredLength) {
const parsed = Number(declaredLength);
if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) {
return new Response(JSON.stringify({ error: "Request body too large" }), {
status: 413,
headers: { "Content-Type": "application/json" },
});
}
}
}
const session = await auth(); const session = await auth();
// Validate active session registry on every authenticated request. // Validate active session registry on every authenticated request.
+9 -9
View File
@@ -1,21 +1,21 @@
import { z } from "zod"; import { z } from "zod";
export const auditLogListInputSchema = z.object({ export const auditLogListInputSchema = z.object({
entityType: z.string().optional(), entityType: z.string().max(64).optional(),
entityId: z.string().optional(), entityId: z.string().max(64).optional(),
userId: z.string().optional(), userId: z.string().max(64).optional(),
action: z.string().optional(), action: z.string().max(32).optional(),
source: z.string().optional(), source: z.string().max(32).optional(),
startDate: z.date().optional(), startDate: z.date().optional(),
endDate: z.date().optional(), endDate: z.date().optional(),
search: z.string().optional(), search: z.string().max(200).optional(),
limit: z.number().min(1).max(100).default(50), limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(), cursor: z.string().max(64).optional(),
}); });
export const auditLogByEntityInputSchema = z.object({ export const auditLogByEntityInputSchema = z.object({
entityType: z.string(), entityType: z.string().max(64),
entityId: z.string(), entityId: z.string().max(64),
limit: z.number().min(1).max(200).default(50), limit: z.number().min(1).max(200).default(50),
}); });
@@ -12,9 +12,21 @@ type ImportExportMutationContext = ImportExportReadContext & {
type ImportRow = Record<string, string>; type ImportRow = Record<string, string>;
const CSV_CELL_MAX = 4000;
const CSV_COLUMNS_MAX = 100;
const CSV_ROWS_MAX = 10_000;
export const importCsvInputSchema = z.object({ export const importCsvInputSchema = z.object({
entityType: z.enum(["resources", "projects", "allocations"]), entityType: z.enum(["resources", "projects", "allocations"]),
rows: z.array(z.record(z.string(), z.string())), rows: z
.array(
z
.record(z.string().max(200), z.string().max(CSV_CELL_MAX))
.refine((row) => Object.keys(row).length <= CSV_COLUMNS_MAX, {
message: `CSV row exceeds ${CSV_COLUMNS_MAX} columns`,
}),
)
.max(CSV_ROWS_MAX),
dryRun: z.boolean().default(true), dryRun: z.boolean().default(true),
}); });
@@ -32,7 +44,10 @@ function resolveVisibleBlueprintFields(fieldDefs: unknown): BlueprintFieldDefini
} }
function buildCsv(headers: unknown[], rows: unknown[][]) { function buildCsv(headers: unknown[], rows: unknown[][]) {
return [headers.map(escapeCsvValue).join(","), ...rows.map((row) => row.map(escapeCsvValue).join(","))].join("\n"); return [
headers.map(escapeCsvValue).join(","),
...rows.map((row) => row.map(escapeCsvValue).join(",")),
].join("\n");
} }
export async function exportResourcesCsv(ctx: ImportExportReadContext) { export async function exportResourcesCsv(ctx: ImportExportReadContext) {
@@ -168,7 +183,10 @@ export async function importCsv(ctx: ImportExportMutationContext, input: ImportC
try { try {
if (input.entityType === "resources") { if (input.entityType === "resources") {
const outcome = await importResourceRow({ ...ctx, db: tx as unknown as typeof ctx.db }, row); const outcome = await importResourceRow(
{ ...ctx, db: tx as unknown as typeof ctx.db },
row,
);
if (outcome.updated) { if (outcome.updated) {
results.updated += 1; results.updated += 1;
} else if (outcome.error) { } else if (outcome.error) {
@@ -5,7 +5,10 @@ import { sendEmail } from "../lib/email.js";
import { emitTaskAssigned } from "../sse/event-bus.js"; import { emitTaskAssigned } from "../sse/event-bus.js";
import type { TRPCContext } from "../trpc.js"; import type { TRPCContext } from "../trpc.js";
export type NotificationProcedureContext = Pick<TRPCContext, "db" | "dbUser" | "roleDefaults" | "session">; export type NotificationProcedureContext = Pick<
TRPCContext,
"db" | "dbUser" | "roleDefaults" | "session"
>;
export function requireNotificationDbUser(ctx: NotificationProcedureContext) { export function requireNotificationDbUser(ctx: NotificationProcedureContext) {
if (!ctx.dbUser) { if (!ctx.dbUser) {
@@ -89,17 +92,15 @@ export function rethrowNotificationReferenceError(
recipientContext: "notification" | "task" | "broadcast" = "notification", recipientContext: "notification" | "task" | "broadcast" = "notification",
): never { ): never {
for (const candidate of getNotificationErrorCandidates(error)) { for (const candidate of getNotificationErrorCandidates(error)) {
const fieldName = typeof candidate.meta?.field_name === "string" const fieldName =
? candidate.meta.field_name.toLowerCase() typeof candidate.meta?.field_name === "string" ? candidate.meta.field_name.toLowerCase() : "";
: ""; const modelName =
const modelName = typeof candidate.meta?.modelName === "string" typeof candidate.meta?.modelName === "string" ? candidate.meta.modelName.toLowerCase() : "";
? candidate.meta.modelName.toLowerCase()
: "";
if ( if (
typeof candidate.code === "string" typeof candidate.code === "string" &&
&& (candidate.code === "P2003" || candidate.code === "P2025") (candidate.code === "P2003" || candidate.code === "P2025") &&
&& fieldName.includes("assignee") fieldName.includes("assignee")
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -109,9 +110,9 @@ export function rethrowNotificationReferenceError(
} }
if ( if (
typeof candidate.code === "string" typeof candidate.code === "string" &&
&& (candidate.code === "P2003" || candidate.code === "P2025") (candidate.code === "P2003" || candidate.code === "P2025") &&
&& fieldName.includes("sender") fieldName.includes("sender")
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -121,15 +122,16 @@ export function rethrowNotificationReferenceError(
} }
if ( if (
typeof candidate.code === "string" typeof candidate.code === "string" &&
&& (candidate.code === "P2003" || candidate.code === "P2025") (candidate.code === "P2003" || candidate.code === "P2025") &&
&& fieldName.includes("userid") fieldName.includes("userid")
) { ) {
const message = recipientContext === "broadcast" const message =
? "Broadcast recipient user not found" recipientContext === "broadcast"
: recipientContext === "task" ? "Broadcast recipient user not found"
? "Task recipient user not found" : recipientContext === "task"
: "Notification recipient user not found"; ? "Task recipient user not found"
: "Notification recipient user not found";
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message, message,
@@ -138,13 +140,11 @@ export function rethrowNotificationReferenceError(
} }
if ( if (
typeof candidate.code === "string" typeof candidate.code === "string" &&
&& (candidate.code === "P2003" || candidate.code === "P2025") (candidate.code === "P2003" || candidate.code === "P2025") &&
&& ( (modelName.includes("notificationbroadcast") ||
modelName.includes("notificationbroadcast") fieldName.includes("broadcast") ||
|| fieldName.includes("broadcast") fieldName.includes("sourceid"))
|| fieldName.includes("sourceid")
)
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -203,11 +203,11 @@ export const ListNotificationTasksInputSchema = z.object({
}); });
export const NotificationIdInputSchema = z.object({ export const NotificationIdInputSchema = z.object({
id: z.string(), id: z.string().max(64),
}); });
export const UpdateNotificationTaskStatusInputSchema = z.object({ export const UpdateNotificationTaskStatusInputSchema = z.object({
id: z.string(), id: z.string().max(64),
status: taskStatusEnum, status: taskStatusEnum,
}); });
@@ -216,13 +216,13 @@ export const CreateReminderInputSchema = z.object({
body: z.string().max(2000).optional(), body: z.string().max(2000).optional(),
remindAt: z.date(), remindAt: z.date(),
recurrence: recurrenceEnum.optional(), recurrence: recurrenceEnum.optional(),
entityId: z.string().optional(), entityId: z.string().max(64).optional(),
entityType: z.string().optional(), entityType: z.string().max(64).optional(),
link: z.string().optional(), link: z.string().max(2048).optional(),
}); });
export const UpdateReminderInputSchema = z.object({ export const UpdateReminderInputSchema = z.object({
id: z.string(), id: z.string().max(64),
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
body: z.string().max(2000).optional(), body: z.string().max(2000).optional(),
remindAt: z.date().optional(), remindAt: z.date().optional(),
@@ -236,14 +236,14 @@ export const ListRemindersInputSchema = z.object({
export const CreateBroadcastInputSchema = z.object({ export const CreateBroadcastInputSchema = z.object({
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
body: z.string().max(2000).optional(), body: z.string().max(2000).optional(),
link: z.string().optional(), link: z.string().max(2048).optional(),
category: categoryEnum.default("NOTIFICATION"), category: categoryEnum.default("NOTIFICATION"),
priority: priorityEnum.default("NORMAL"), priority: priorityEnum.default("NORMAL"),
channel: channelEnum.default("in_app"), channel: channelEnum.default("in_app"),
targetType: targetTypeEnum, targetType: targetTypeEnum,
targetValue: z.string().optional(), targetValue: z.string().max(200).optional(),
scheduledAt: z.date().optional(), scheduledAt: z.date().optional(),
taskAction: z.string().optional(), taskAction: z.string().max(64).optional(),
dueDate: z.date().optional(), dueDate: z.date().optional(),
}); });
@@ -252,21 +252,21 @@ export const ListBroadcastsInputSchema = z.object({
}); });
export const CreateTaskInputSchema = z.object({ export const CreateTaskInputSchema = z.object({
userId: z.string(), userId: z.string().max(64),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
body: z.string().max(2000).optional(), body: z.string().max(2000).optional(),
priority: priorityEnum.default("NORMAL"), priority: priorityEnum.default("NORMAL"),
dueDate: z.date().optional(), dueDate: z.date().optional(),
taskAction: z.string().optional(), taskAction: z.string().max(64).optional(),
entityId: z.string().optional(), entityId: z.string().max(64).optional(),
entityType: z.string().optional(), entityType: z.string().max(64).optional(),
link: z.string().optional(), link: z.string().max(2048).optional(),
channel: channelEnum.default("in_app"), channel: channelEnum.default("in_app"),
}); });
export const AssignTaskInputSchema = z.object({ export const AssignTaskInputSchema = z.object({
id: z.string(), id: z.string().max(64),
assigneeId: z.string(), assigneeId: z.string().max(64),
}); });
export type BroadcastRecipientNotification = { id: string; userId: string }; export type BroadcastRecipientNotification = { id: string; userId: string };
@@ -411,9 +411,9 @@ export async function deleteNotification(
} }
if ( if (
(existing.category === "TASK" || existing.category === "APPROVAL") (existing.category === "TASK" || existing.category === "APPROVAL") &&
&& existing.senderId existing.senderId &&
&& existing.senderId !== userId existing.senderId !== userId
) { ) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
@@ -438,7 +438,7 @@ export const resourceMutationProcedures = {
}), }),
batchHardDelete: adminProcedure batchHardDelete: adminProcedure
.input(z.object({ ids: z.array(z.string()).min(1) })) .input(z.object({ ids: z.array(z.string().max(64)).min(1).max(500) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({ const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } }, where: { id: { in: input.ids } },
@@ -2,13 +2,18 @@ import { PermissionKey, SkillEntrySchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import {
adminProcedure,
managerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
const employeeInfoSchema = z const employeeInfoSchema = z
.object({ .object({
roleId: z.string().optional(), roleId: z.string().max(64).optional(),
yearsOfExperience: z.number().optional(), yearsOfExperience: z.number().min(0).max(100).optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")), portfolioUrl: z.string().url().max(2048).optional().or(z.literal("")),
}) })
.optional(); .optional();
@@ -16,7 +21,7 @@ export const resourceSkillImportProcedures = {
importSkillMatrix: protectedProcedure importSkillMatrix: protectedProcedure
.input( .input(
z.object({ z.object({
skills: z.array(SkillEntrySchema), skills: z.array(SkillEntrySchema).max(2000),
employeeInfo: employeeInfoSchema, employeeInfo: employeeInfoSchema,
}), }),
) )
@@ -40,7 +45,9 @@ export const resourceSkillImportProcedures = {
...(input.employeeInfo?.portfolioUrl !== undefined ...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null } ? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}), : {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), ...(input.employeeInfo?.roleId !== undefined
? { roleId: input.employeeInfo.roleId }
: {}),
}, },
}); });
@@ -50,8 +57,8 @@ export const resourceSkillImportProcedures = {
importSkillMatrixForResource: managerProcedure importSkillMatrixForResource: managerProcedure
.input( .input(
z.object({ z.object({
resourceId: z.string(), resourceId: z.string().max(64),
skills: z.array(SkillEntrySchema), skills: z.array(SkillEntrySchema).max(2000),
employeeInfo: employeeInfoSchema, employeeInfo: employeeInfoSchema,
}), }),
) )
@@ -70,7 +77,9 @@ export const resourceSkillImportProcedures = {
...(input.employeeInfo?.portfolioUrl !== undefined ...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null } ? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}), : {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}), ...(input.employeeInfo?.roleId !== undefined
? { roleId: input.employeeInfo.roleId }
: {}),
}, },
}); });
@@ -80,13 +89,15 @@ export const resourceSkillImportProcedures = {
batchImportSkillMatrices: adminProcedure batchImportSkillMatrices: adminProcedure
.input( .input(
z.object({ z.object({
entries: z.array( entries: z
z.object({ .array(
eid: z.string(), z.object({
skills: z.array(SkillEntrySchema), eid: z.string().max(64),
employeeInfo: employeeInfoSchema, skills: z.array(SkillEntrySchema).max(2000),
}), employeeInfo: employeeInfoSchema,
), }),
)
.max(5000),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -110,7 +121,9 @@ export const resourceSkillImportProcedures = {
...(entry.employeeInfo?.portfolioUrl !== undefined ...(entry.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null } ? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
: {}), : {}),
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}), ...(entry.employeeInfo?.roleId !== undefined
? { roleId: entry.employeeInfo.roleId }
: {}),
}, },
}), }),
); );
@@ -397,8 +397,8 @@ async function queryStaffingSuggestions(
}); });
} }
const GetProjectStaffingSuggestionsInputSchema = z.object({ const GetProjectStaffingSuggestionsInputSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1).max(64),
roleName: z.string().optional(), roleName: z.string().max(200).optional(),
startDate: z.coerce.date().optional(), startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(),
limit: z.number().int().min(1).max(50).optional().default(5), limit: z.number().int().min(1).max(50).optional().default(5),
@@ -408,14 +408,14 @@ export const staffingSuggestionsReadProcedures = {
getSuggestions: planningReadProcedure getSuggestions: planningReadProcedure
.input( .input(
z.object({ z.object({
requiredSkills: z.array(z.string()), requiredSkills: z.array(z.string().max(200)).max(200),
preferredSkills: z.array(z.string()).optional(), preferredSkills: z.array(z.string().max(200)).max(200).optional(),
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24), hoursPerDay: z.number().min(0).max(24),
budgetLcrCentsPerHour: z.number().optional(), budgetLcrCentsPerHour: z.number().int().min(0).max(1_000_000_00).optional(),
chapter: z.string().optional(), chapter: z.string().max(100).optional(),
skillCategory: z.string().optional(), skillCategory: z.string().max(100).optional(),
mainSkillsOnly: z.boolean().optional(), mainSkillsOnly: z.boolean().optional(),
minProficiency: z.number().min(1).max(5).optional(), minProficiency: z.number().min(1).max(5).optional(),
}), }),
@@ -1,35 +1,40 @@
import { z } from "zod"; import { z } from "zod";
const idFilter = () => z.array(z.string().max(64)).max(500);
const chapterFilter = () => z.array(z.string().max(100)).max(100);
const countryFilter = () => z.array(z.string().max(8)).max(300);
const dateStr = () => z.string().max(32);
export const TimelineWindowFiltersSchema = z.object({ export const TimelineWindowFiltersSchema = z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(), resourceIds: idFilter().optional(),
projectIds: z.array(z.string()).optional(), projectIds: idFilter().optional(),
clientIds: z.array(z.string()).optional(), clientIds: idFilter().optional(),
chapters: z.array(z.string()).optional(), chapters: chapterFilter().optional(),
eids: z.array(z.string()).optional(), eids: idFilter().optional(),
countryCodes: z.array(z.string()).optional(), countryCodes: countryFilter().optional(),
}); });
export const TimelineDetailFiltersSchema = z.object({ export const TimelineDetailFiltersSchema = z.object({
startDate: z.string().optional(), startDate: dateStr().optional(),
endDate: z.string().optional(), endDate: dateStr().optional(),
durationDays: z.number().int().min(1).max(366).optional(), durationDays: z.number().int().min(1).max(366).optional(),
resourceIds: z.array(z.string()).optional(), resourceIds: idFilter().optional(),
projectIds: z.array(z.string()).optional(), projectIds: idFilter().optional(),
clientIds: z.array(z.string()).optional(), clientIds: idFilter().optional(),
chapters: z.array(z.string()).optional(), chapters: chapterFilter().optional(),
eids: z.array(z.string()).optional(), eids: idFilter().optional(),
countryCodes: z.array(z.string()).optional(), countryCodes: countryFilter().optional(),
}); });
export const TimelineProjectContextDetailSchema = z.object({ export const TimelineProjectContextDetailSchema = z.object({
projectId: z.string(), projectId: z.string().max(64),
startDate: z.string().optional(), startDate: dateStr().optional(),
endDate: z.string().optional(), endDate: dateStr().optional(),
durationDays: z.number().int().min(1).max(366).optional(), durationDays: z.number().int().min(1).max(366).optional(),
}); });
export const TimelineProjectIdSchema = z.object({ export const TimelineProjectIdSchema = z.object({
projectId: z.string(), projectId: z.string().max(64),
}); });
@@ -13,45 +13,45 @@ import type { TRPCContext } from "../trpc.js";
import { invalidateRoleDefaultsCache } from "../trpc.js"; import { invalidateRoleDefaultsCache } from "../trpc.js";
export const CreateUserInputSchema = z.object({ export const CreateUserInputSchema = z.object({
email: z.string().email(), email: z.string().email().max(320),
name: z.string().min(1), name: z.string().min(1).max(200),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
}); });
export const SetUserPasswordInputSchema = z.object({ export const SetUserPasswordInputSchema = z.object({
userId: z.string(), userId: z.string().max(64),
password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
}); });
export const UpdateUserRoleInputSchema = z.object({ export const UpdateUserRoleInputSchema = z.object({
id: z.string(), id: z.string().max(64),
systemRole: z.nativeEnum(SystemRole), systemRole: z.nativeEnum(SystemRole),
}); });
export const UpdateUserNameInputSchema = z.object({ export const UpdateUserNameInputSchema = z.object({
id: z.string(), id: z.string().max(64),
name: z.string().min(1, "Name is required").max(200), name: z.string().min(1, "Name is required").max(200),
}); });
export const LinkUserResourceInputSchema = z.object({ export const LinkUserResourceInputSchema = z.object({
userId: z.string(), userId: z.string().max(64),
resourceId: z.string().nullable(), resourceId: z.string().max(64).nullable(),
}); });
export const SetUserPermissionsInputSchema = z.object({ export const SetUserPermissionsInputSchema = z.object({
userId: z.string(), userId: z.string().max(64),
overrides: z overrides: z
.object({ .object({
granted: z.array(z.string()).optional(), granted: z.array(z.string().max(128)).max(500).optional(),
denied: z.array(z.string()).optional(), denied: z.array(z.string().max(128)).max(500).optional(),
chapterIds: z.array(z.string()).optional(), chapterIds: z.array(z.string().max(64)).max(500).optional(),
}) })
.nullable(), .nullable(),
}); });
export const UserIdInputSchema = z.object({ export const UserIdInputSchema = z.object({
userId: z.string(), userId: z.string().max(64),
}); });
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">; type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
+9 -16
View File
@@ -6,17 +6,17 @@ export const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...
export const createWebhookInputSchema = z.object({ export const createWebhookInputSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
url: z.string().url(), url: z.string().url().max(2048),
secret: z.string().optional(), secret: z.string().min(16).max(256).optional(),
events: z.array(webhookEventEnum).min(1), events: z.array(webhookEventEnum).min(1).max(100),
isActive: z.boolean().default(true), isActive: z.boolean().default(true),
}); });
export const updateWebhookInputSchema = z.object({ export const updateWebhookInputSchema = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(), url: z.string().url().max(2048).optional(),
secret: z.string().nullish(), secret: z.string().min(16).max(256).nullish(),
events: z.array(webhookEventEnum).min(1).optional(), events: z.array(webhookEventEnum).min(1).max(100).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}); });
@@ -35,9 +35,7 @@ type WebhookDb = {
}; };
}; };
export function buildWebhookCreateData( export function buildWebhookCreateData(input: z.infer<typeof createWebhookInputSchema>) {
input: z.infer<typeof createWebhookInputSchema>,
) {
return { return {
name: input.name, name: input.name,
url: input.url, url: input.url,
@@ -47,9 +45,7 @@ export function buildWebhookCreateData(
}; };
} }
export function buildWebhookUpdateData( export function buildWebhookUpdateData(input: z.infer<typeof updateWebhookInputSchema>) {
input: z.infer<typeof updateWebhookInputSchema>,
) {
return { return {
...(input.name !== undefined ? { name: input.name } : {}), ...(input.name !== undefined ? { name: input.name } : {}),
...(input.url !== undefined ? { url: input.url } : {}), ...(input.url !== undefined ? { url: input.url } : {}),
@@ -59,10 +55,7 @@ export function buildWebhookUpdateData(
}; };
} }
export async function loadWebhookOrThrow( export async function loadWebhookOrThrow(db: WebhookDb, id: string) {
db: WebhookDb,
id: string,
) {
const webhook = await db.webhook.findUnique({ where: { id } }); const webhook = await db.webhook.findUnique({ where: { id } });
if (!webhook) { if (!webhook) {
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });