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
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:
@@ -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, {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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">;
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user