fix(regressions): client-bundle ioredis + Drizzle ANY() array bindings

Two regressions from yesterday's audit-tier-0 work that broke the dev
server and every clients API call.

- baseListQuerySchema lived in route-helpers.ts, which was made
  server-only by the rate-limit import. Every validator imported it,
  pulling ioredis (and dns/net/tls/fs/node:async_hooks) into the client
  bundle — every form/detail page returned 500 in dev. Extracted the
  schema to src/lib/api/list-query.ts and updated all 14 validators.
- clients.service.listClients and email-compose used raw SQL
  ANY(\${jsArray}) which Drizzle binds as JSON — Postgres rejects with
  42809 "op ANY/ALL (array) requires array on right side". Switched to
  the inArray helper.

GET /api/v1/clients now returns 200 again. Affects every form/detail
page that imports a validator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:56:59 +02:00
parent 7bd969b41a
commit 1fb3aa3aeb
18 changed files with 32 additions and 34 deletions

15
src/lib/api/list-query.ts Normal file
View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const baseListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
includeArchived: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('false'),
});
export type BaseListQuery = z.infer<typeof baseListQuerySchema>;

View File

@@ -8,23 +8,6 @@ import {
type RateLimiterName,
} from '@/lib/rate-limit';
/**
* Base list query schema shared by all paginated list endpoints.
*/
export const baseListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
includeArchived: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
.default('false'),
});
export type BaseListQuery = z.infer<typeof baseListQuerySchema>;
/**
* Parses URL search params against a Zod schema.
* Throws a ZodError on validation failure (caught by `errorResponse`).

View File

@@ -158,7 +158,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
is_primary AS "isPrimary",
created_at AS "createdAt"
FROM client_contacts
WHERE client_id = ANY(${ids})
WHERE ${inArray(clientContacts.clientId, ids)}
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),

View File

@@ -1,5 +1,5 @@
import nodemailer from 'nodemailer';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
@@ -295,7 +295,7 @@ async function sendSystem(
const matchingDocs = await db
.select({ id: documents.id, signedFileId: documents.signedFileId })
.from(documents)
.where(and(eq(documents.portId, portId), sql`${documents.signedFileId} = ANY(${fileIds})`));
.where(and(eq(documents.portId, portId), inArray(documents.signedFileId, fileIds)));
for (const doc of matchingDocs) {
await db.insert(documentEvents).values({

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { BERTH_STATUSES } from '@/lib/constants';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
// ─── Create Berth ────────────────────────────────────────────────────────────

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import {
optionalCountryIsoSchema,
optionalIanaTimezoneSchema,

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
export const createCompanySchema = z.object({

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
const mergeFieldsSchema = z

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { DOCUMENT_TYPES, DOCUMENT_STATUSES } from '@/lib/constants';
export const createDocumentSchema = z.object({

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
/**

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
export const uploadFileSchema = z.object({
filename: z.string().min(1).max(255),

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
import {
optionalCountryIsoSchema,

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
export const INVOICE_KINDS = ['general', 'deposit'] as const;
export type InvoiceKind = (typeof INVOICE_KINDS)[number];

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
export const createReminderSchema = z.object({
title: z.string().min(1).max(300),

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
export const RESERVATION_STATUSES = ['pending', 'active', 'ended', 'cancelled'] as const;
export const TENURE_TYPES = ['permanent', 'fixed_term', 'seasonal'] as const;

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import {
optionalCountryIsoSchema,
optionalIanaTimezoneSchema,

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { WEBHOOK_EVENTS } from '@/lib/services/webhook-event-map';
// ─── SSRF guards ──────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
import { baseListQuerySchema } from '@/lib/api/list-query';
export const ownerRefSchema = z.object({
type: z.enum(['client', 'company']),