From cc332c3282e4b9db82e3f92fd4d07690e9645d74 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 21:51:54 +0800 Subject: [PATCH 1/6] fix(settings-sheet): strip time part from date when loading from BFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB stores dates as ISO datetimes (e.g. '2026-05-29T09:00:00'). This value was loaded verbatim into the form, causing two problems: 1. could not parse it (showed empty placeholder) 2. sanitizeSettingsPatch bypassed the date→ISO conversion, so the API received a datetime without a timezone suffix, failing z.string().datetime() Fix: strip the time component on load so the date picker gets 'YYYY-MM-DD', which sanitizeSettingsPatch then correctly converts to a Z-suffixed ISO string. Applied to closingDate too for consistency. --- app/components/editor/InspectionSettingsSheet.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index 80245701..f40eb25d 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -90,8 +90,8 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS const loadedTemplateId = (insp.templateId as string) || ""; templateIdAtOpen.current = loadedTemplateId; setForm({ - date: (insp.date as string) || "", - closingDate: (insp.closingDate as string) || "", + date: ((insp.date as string) || "").replace(/T.*/, ''), + closingDate: ((insp.closingDate as string) || "").replace(/T.*/, ''), inspectorId: (insp.inspectorId as string) || "", orderId: (insp.orderId as string) || "", referralSource: (insp.referralSource as string) || "", From 7161089282207e62563e3e074f06f7be608ec8a6 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:15:13 +0800 Subject: [PATCH 2/6] fix(team): read body.data.members not body.data for team list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.team.members returns {data:{members:[...]}} — the loader was reading body.data (an object) and calling .filter() on it, causing a TypeError crash. Align with the BFF sheet route which correctly reads data?.members. --- app/routes/team.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/team.tsx b/app/routes/team.tsx index 6268b1aa..9e37205b 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -25,9 +25,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { try { const api = createApi(context, { token }); const res = await api.team.members.$get(); - const body = res.ok ? ((await res.json()) as Record) : { data: [] }; + const body = res.ok ? ((await res.json()) as unknown as { data?: { members?: Member[] } }) : { data: { members: [] as Member[] } }; return { - members: (body.data ?? []) as Member[], + members: (body.data?.members ?? []) as Member[], settings: {} as Record, }; } catch { From fa08973743cd570a485029c0380b790c641a7269 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:16:59 +0800 Subject: [PATCH 3/6] feat(team): wire InviteSeatModal to Invite Member button --- app/routes/team.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/routes/team.tsx b/app/routes/team.tsx index 9e37205b..e9cb9f64 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -4,6 +4,7 @@ import type { Route } from "./+types/team"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; import { SeatBanner } from "~/components/SeatBanner"; +import { InviteSeatModal } from "~/components/modals/InviteSeatModal"; import { useSessionContext } from "~/hooks/useSessionContext"; import { PageHeader, TabStrip, Card, Pill, Button, EmptyState } from "@core/shared-ui"; @@ -57,6 +58,9 @@ export default function TeamPage() { const { members } = useLoaderData(); const sessionCtx = useSessionContext(); const [activeTab, setActiveTab] = useState("active"); + const [inviteOpen, setInviteOpen] = useState(false); + + const leads = members.filter((m) => m.role === "lead").map((m) => ({ id: m.id, email: m.email })); const filtered = members.filter((m) => { if (activeTab === "active") return m.status !== "pending" && m.role !== "apprentice"; @@ -79,12 +83,14 @@ export default function TeamPage() { title="Workspace Team" meta={`${members.length} ${members.length === 1 ? "member" : "members"}`} actions={ - } /> + setInviteOpen(false)} leads={leads} /> + {filtered.length === 0 ? ( From 36949bad9d6b8fecf18953cb4bde2ae9bb6e0604 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:39:51 +0800 Subject: [PATCH 4/6] fix(bff): use valid pageSize=100 for template list in inspection-settings BFF pageSize: "200" fails Zod refine (valid: 12/25/50/100); the .catch(() => null) silently swallowed the 400 so the template dropdown was always empty. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/resources/inspection-settings-sheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/resources/inspection-settings-sheet.tsx b/app/routes/resources/inspection-settings-sheet.tsx index b3011175..241ce328 100644 --- a/app/routes/resources/inspection-settings-sheet.tsx +++ b/app/routes/resources/inspection-settings-sheet.tsx @@ -33,7 +33,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const [inspRes, tplRes, membersRes] = await Promise.all([ api.inspections[":id"].$get({ param: { id: inspectionId } }, hdr).catch(() => null), - api.inspections.templates.$get({ query: { page: "1", pageSize: "200" } }, hdr).catch(() => null), + api.inspections.templates.$get({ query: { page: "1", pageSize: "100" } }, hdr).catch(() => null), api.team.members.$get({}, hdr).catch(() => null), ]); From 51cbde6e4cdf4dd3e7debf071996b1be9e270cf1 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 23:42:55 +0800 Subject: [PATCH 5/6] feat(search): lazy-load template picker + server-side search for templates & dashboard - TemplateCombobox: new reusable combobox with debounced search, cursor pagination (25/page), keyboard nav, load-more; replaces setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search templates..." + className="w-full h-8 pl-8 pr-3 text-[13px] bg-ih-bg-muted rounded border border-ih-border outline-none focus:border-ih-primary text-ih-fg-1 placeholder:text-ih-fg-4" + /> + + + + {/* List */} +
+ {isLoading ? ( +
Loading…
+ ) : results.length === 0 ? ( +
+ {query ? "No templates match your search" : "No templates found"} +
+ ) : ( + <> + {/* Clear selection option */} + {value && ( + + )} + {results.map((t, i) => ( + + ))} + + )} +
+ + {/* Load more */} + {hasMore && !isLoading && ( +
+ +
+ )} + + )} + + ); +} diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index f40eb25d..7bd8db4c 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useFetcher } from "react-router"; +import { TemplateCombobox } from "~/components/TemplateCombobox"; interface SettingsForm { date: string; @@ -225,13 +226,14 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
Template -
diff --git a/app/routes.ts b/app/routes.ts index d18af330..4eebb648 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -86,6 +86,8 @@ export default [ route("resources/publish-readiness", "routes/resources/publish-readiness.tsx"), route("resources/recent-inspections", "routes/resources/recent-inspections.tsx"), route("resources/team-members", "routes/resources/team-members.tsx"), + route("resources/template-search", "routes/resources/template-search.tsx"), + route("resources/inspection-search", "routes/resources/inspection-search.tsx"), layout("routes/auth-layout.tsx", [ // IA-6 — BFF resource route for advisory schedule-conflict detection. // Loaded via useFetcher; no UI rendered; must be inside the auth layout so diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index e35e523f..e83954fb 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useLoaderData, Link, useFetcher, useSearchParams, redirect } from "react-router"; +import type { InspectionSearchItem } from "~/routes/resources/inspection-search"; import type { Route } from "./+types/dashboard"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -417,6 +418,14 @@ export default function DashboardPage() { const [visiblePage, setVisiblePage] = useState(1); const sentinelRef = useRef(null); + /* ---- Server-side search (fetcher + state) ---- */ + const searchFetcher = useFetcher<{ inspections: InspectionSearchItem[]; hasMore: boolean; nextCursor: string | null }>(); + const [serverResults, setServerResults] = useState([]); + const [serverCursor, setServerCursor] = useState(null); + const [serverHasMore, setServerHasMore] = useState(false); + const searchDebounceRef = useRef | null>(null); + const isLoadMoreRef = useRef(false); + /* ---- Columns (persisted in localStorage) ---- */ const [visibleColumns, setVisibleColumns] = useState(() => { if (typeof window === "undefined") return DEFAULT_COLUMNS; @@ -533,6 +542,38 @@ export default function DashboardPage() { // Reset page when filters change useEffect(() => { setVisiblePage(1); }, [activeTab, activeFilter, activeTagFilter, searchQuery, filterDateFrom, filterDateTo, filterAgentId]); + // Debounce searchQuery → server-side search via BFF + useEffect(() => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + if (!searchQuery.trim()) { + setServerResults([]); + setServerCursor(null); + setServerHasMore(false); + return; + } + searchDebounceRef.current = setTimeout(() => { + isLoadMoreRef.current = false; + setServerResults([]); + setServerCursor(null); + searchFetcher.load(`/resources/inspection-search?q=${encodeURIComponent(searchQuery.trim())}`); + }, 300); + return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); }; + }, [searchQuery]); // searchFetcher.load is stable; omitting it avoids infinite loop + + // Apply searchFetcher results (fresh search or load-more append) + useEffect(() => { + if (searchFetcher.state !== "idle" || !searchFetcher.data) return; + const d = searchFetcher.data; + if (isLoadMoreRef.current) { + setServerResults(prev => [...prev, ...d.inspections]); + isLoadMoreRef.current = false; + } else { + setServerResults(d.inspections); + } + setServerHasMore(d.hasMore ?? false); + setServerCursor(d.nextCursor ?? null); + }, [searchFetcher.state, searchFetcher.data]); + /* ---- IA-12: Onboarding steps ---- */ // siteNameSet: the session context always returns a non-null siteName // (falling back to 'OpenInspection' when not configured). If the value @@ -640,6 +681,13 @@ export default function DashboardPage() { URL.revokeObjectURL(url); }, [filteredInspections]); + /* ---- Server search load-more ---- */ + const handleSearchLoadMore = () => { + if (!serverHasMore || !serverCursor || !searchQuery.trim()) return; + isLoadMoreRef.current = true; + searchFetcher.load(`/resources/inspection-search?q=${encodeURIComponent(searchQuery.trim())}&cursor=${encodeURIComponent(serverCursor)}`); + }; + /* ---- Status transition ---- */ const transitionStatus = (id: string, status: string) => { fetcher.submit({ intent: "status", id, status }, { method: "post" }); @@ -939,21 +987,43 @@ export default function DashboardPage() { })} ) : ( - /* Flat filtered view */ - -
- - {filteredInspections.length} result{filteredInspections.length !== 1 ? "s" : ""} - -
-
- {paginatedList.map((insp) => ( - - ))} -
- {/* Infinite scroll sentinel */} - {hasMore &&
} - + /* Flat filtered view — server search when query active, client-side otherwise */ + (() => { + const isServerSearch = searchQuery.trim().length > 0; + const isSearching = isServerSearch && searchFetcher.state !== "idle"; + const displayList = isServerSearch ? (serverResults as unknown as Inspection[]) : paginatedList; + const displayCount = isServerSearch ? serverResults.length : filteredInspections.length; + return ( + +
+ + {isSearching ? "Searching…" : `${displayCount} result${displayCount !== 1 ? "s" : ""}`} + +
+
+ {!isSearching && displayList.map((insp) => ( + + ))} +
+ {isSearching && ( +
Searching…
+ )} + {/* Server search load-more */} + {isServerSearch && serverHasMore && !isSearching && ( +
+ +
+ )} + {/* Infinite scroll for non-search flat mode */} + {!isServerSearch && hasMore &&
} + + ); + })() )} {/* Wizard modal */} diff --git a/app/routes/resources/inspection-search.tsx b/app/routes/resources/inspection-search.tsx new file mode 100644 index 00000000..7a8ba5fb --- /dev/null +++ b/app/routes/resources/inspection-search.tsx @@ -0,0 +1,58 @@ +/** + * BFF resource route for dashboard inspection search: server-side full-text + * search across all inspections (not just the loaded bucket subset). + */ +import type { Route } from "./+types/inspection-search"; +import { getToken } from "~/lib/session.server"; +import { createApi } from "~/lib/api-client.server"; + +export interface InspectionSearchItem { + id: string; + address: string; + propertyAddress: string; + clientName: string | null; + clientEmail: string | null; + status: string; + date: string | null; +} + +export async function loader({ request, context }: Route.LoaderArgs) { + const token = await getToken(context, request); + if (!token) return { inspections: [] as InspectionSearchItem[], hasMore: false, nextCursor: null as string | null }; + + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const cursor = url.searchParams.get("cursor") ?? ""; + + const api = createApi(context, { token }); + const hdr = { headers: { "x-token-relay": "1" } } as const; + + const query: Record = { limit: "25" }; + if (q) query.search = q; + if (cursor) query.cursor = cursor; + + const res = await api.inspections.index.$get({ query }, hdr).catch(() => null); + + if (!res?.ok) return { inspections: [] as InspectionSearchItem[], hasMore: false, nextCursor: null as string | null }; + + const body = (await res.json()) as { + data?: Array<{ id: string; propertyAddress: string; clientName: string | null; clientEmail: string | null; status: string; date: string | null }>; + meta?: { nextCursor?: string | null }; + }; + + const inspections: InspectionSearchItem[] = (body.data ?? []).map(r => ({ + id: r.id, + address: r.propertyAddress, + propertyAddress: r.propertyAddress, + clientName: r.clientName ?? null, + clientEmail: r.clientEmail ?? null, + status: r.status, + date: r.date ?? null, + })); + + return { + inspections, + hasMore: !!body.meta?.nextCursor, + nextCursor: body.meta?.nextCursor ?? null, + }; +} diff --git a/app/routes/resources/template-search.tsx b/app/routes/resources/template-search.tsx new file mode 100644 index 00000000..dccde5e7 --- /dev/null +++ b/app/routes/resources/template-search.tsx @@ -0,0 +1,45 @@ +/** + * BFF resource route for TemplateCombobox: lazy-loads templates with optional + * search query and pagination, avoiding any direct client-side API fetches. + */ +import type { Route } from "./+types/template-search"; +import { getToken } from "~/lib/session.server"; +import { createApi } from "~/lib/api-client.server"; + +interface TemplateSummary { + id: string; + name: string; +} + +export async function loader({ request, context }: Route.LoaderArgs) { + const token = await getToken(context, request); + if (!token) return { templates: [] as TemplateSummary[], hasMore: false, page: 1, totalPages: 1 }; + + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); + + const api = createApi(context, { token }); + const hdr = { headers: { "x-token-relay": "1" } } as const; + + const res = await api.inspections.templates.$get({ + query: { page: String(page), pageSize: "25", ...(q ? { q } : {}) }, + }, hdr).catch(() => null); + + if (!res?.ok) return { templates: [] as TemplateSummary[], hasMore: false, page: 1, totalPages: 1 }; + + const body = (await res.json()) as { + data?: TemplateSummary[]; + meta?: { page: number; totalPages: number }; + }; + + const templates = (body.data ?? []).map(t => ({ id: t.id, name: t.name })); + const meta = body.meta; + + return { + templates, + hasMore: meta ? meta.page < meta.totalPages : false, + page: meta?.page ?? 1, + totalPages: meta?.totalPages ?? 1, + }; +} diff --git a/app/routes/templates.tsx b/app/routes/templates.tsx index b4229a5b..1b593074 100644 --- a/app/routes/templates.tsx +++ b/app/routes/templates.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import { useLoaderData, useFetcher, useNavigate, Link } from "react-router"; +import { useLoaderData, useFetcher, useNavigate, useSearchParams, Link } from "react-router"; import type { Route } from "./+types/templates"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -42,12 +42,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { const url = new URL(request.url); const page = url.searchParams.get("page") ?? "1"; const pageSize = url.searchParams.get("pageSize") ?? "50"; + const q = url.searchParams.get("q") ?? ""; const api = createApi(context, { token }); // Best-effort /api/auth/me to read spectoraMappingSeen (same pattern as dashboard IA-12). // TODO(C-10): same hono/client collapse as auth.me — localized cast. const meGet = api.auth.me.$get as unknown as (args?: unknown) => Promise; const [res, meRes] = await Promise.all([ - api.inspections.templates.$get({ query: { page, pageSize } }), + api.inspections.templates.$get({ query: { page, pageSize, ...(q ? { q } : {}) } }), meGet().catch(() => null), ]); const body = res.ok @@ -62,11 +63,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { }; spectoraMappingSeen = meBody.data?.user?.onboardingState?.spectoraMappingSeen === true; } - return { templates, meta, token, spectoraMappingSeen }; + return { templates, meta, q, token, spectoraMappingSeen }; } catch { return { templates: [] as Template[], meta: { total: 0, page: 1, pageSize: 50, totalPages: 1 }, + q: "", token: "", spectoraMappingSeen: false, }; @@ -179,14 +181,16 @@ function countItems(t: Template): number { /* ------------------------------------------------------------------ */ export default function TemplatesPage() { - const { templates, meta, spectoraMappingSeen: loaderMappingSeen } = useLoaderData(); + const { templates, meta, q: loaderQ, spectoraMappingSeen: loaderMappingSeen } = useLoaderData(); const fetcher = useFetcher(); const mappingFetcher = useFetcher(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { setPage, setPageSize } = usePagination(); const [view, setView] = useState<"list" | "card">("list"); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(loaderQ); + const searchDebounceRef = useRef | null>(null); const [sortBy, setSortBy] = useState("name"); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); @@ -200,6 +204,22 @@ export default function TemplatesPage() { const [mappingSeenOptimistic, setMappingSeenOptimistic] = useState(false); const spectoraMappingSeen = loaderMappingSeen || mappingSeenOptimistic; + // Debounced URL-based search: triggers loader re-run for server-side filtering + useEffect(() => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + const params = new URLSearchParams(searchParams); + if (searchQuery) { + params.set("q", searchQuery); + } else { + params.delete("q"); + } + params.delete("page"); // reset to page 1 on new search + navigate(`?${params}`, { replace: true }); + }, 350); + return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); }; + }, [searchQuery]); // navigate/searchParams are stable refs; omitting avoids re-trigger loop + // Navigate to newly created/duplicated template. const fetcherData = fetcher.data as Record | undefined; if (fetcherData?.ok && fetcherData?.newId && typeof fetcherData.newId === "string") { @@ -223,20 +243,9 @@ export default function TemplatesPage() { } }, [fetcherData, spectoraMappingSeen]); - /* ---- Filter + Sort ---- */ + /* ---- Sort (search filtering is now server-side via URL ?q=) ---- */ const filtered = useMemo(() => { - let list = [...templates]; - - // Search - if (searchQuery) { - const q = searchQuery.toLowerCase(); - list = list.filter((t) => - t.name.toLowerCase().includes(q) || - (t.description || "").toLowerCase().includes(q), - ); - } - - // Sort + const list = [...templates]; list.sort((a, b) => { switch (sortBy) { case "name": return a.name.localeCompare(b.name); @@ -245,9 +254,8 @@ export default function TemplatesPage() { default: return 0; } }); - return list; - }, [templates, searchQuery, sortBy]); + }, [templates, sortBy]); const imported = templates.filter((t) => t.marketplaceTemplateId).length; const withUpdates = templates.filter((t) => t.upstreamUpdateAvailable).length; diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 19d721a1..e06d6b3b 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -145,7 +145,7 @@ const listTemplatesRoute = createRoute(withMcpMetadata({ tags: ["inspections", "templates"], summary: "List inspection templates (paginated)", description: "Paginated list of inspection templates for the tenant.", - request: { query: paginationQuerySchema }, + request: { query: paginationQuerySchema.extend({ q: z.string().optional().describe('Filter templates by name (case-insensitive substring match)') }) }, responses: { 200: { content: { @@ -1893,13 +1893,18 @@ export const inspectionsRoutes = createApiRouter() }, 200); }) .openapi(listTemplatesRoute, async (c) => { - const q = c.req.valid('query'); + const queryParams = c.req.valid('query'); const service = c.var.services.template; - const { rows, total } = await service.listTemplates(c.get('tenantId'), q); + const { page, pageSize, q } = queryParams; + const { rows, total } = await service.listTemplates(c.get('tenantId'), { + ...(page !== undefined ? { page } : {}), + ...(pageSize !== undefined ? { pageSize } : {}), + ...(q !== undefined ? { q } : {}), + }); return c.json({ success: true, data: rows, - meta: buildMeta({ total, page: q.page, pageSize: q.pageSize }), + meta: buildMeta({ total, page: queryParams.page, pageSize: queryParams.pageSize }), }, 200); }) .openapi(listTemplateDuplicatesRoute, async (c) => { diff --git a/server/services/template.service.ts b/server/services/template.service.ts index 7196a938..269ce76f 100644 --- a/server/services/template.service.ts +++ b/server/services/template.service.ts @@ -1,5 +1,5 @@ import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, desc, sql } from 'drizzle-orm'; +import { eq, and, desc, sql, like } from 'drizzle-orm'; import { templates, inspections } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { TemplateSchemaV2Schema } from '../lib/validations/template.schema'; @@ -58,20 +58,23 @@ export class TemplateService { /** * Lists all templates for a tenant. */ - async listTemplates(tenantId: string, opts: { page?: number; pageSize?: number } = {}) { - const { page = 1, pageSize = 50 } = opts; + async listTemplates(tenantId: string, opts: { page?: number; pageSize?: number; q?: string } = {}) { + const { page = 1, pageSize = 50, q } = opts; const db = this.getDrizzle(); + const baseWhere = eq(templates.tenantId, tenantId); + const where = q?.trim() ? and(baseWhere, like(templates.name, `%${q.trim()}%`)) : baseWhere; + const totalRow = await db .select({ c: sql`count(*)` }) .from(templates) - .where(eq(templates.tenantId, tenantId)) + .where(where) .get(); const total = totalRow?.c ?? 0; const rows = await db.select({ id: templates.id, name: templates.name, version: templates.version, schema: templates.schema }) .from(templates) - .where(eq(templates.tenantId, tenantId)) + .where(where) .orderBy(desc(templates.createdAt)) .limit(pageSize) .offset((page - 1) * pageSize) From eb0c842a6115805f772d4024949447b979bb756c Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 12:39:51 +0800 Subject: [PATCH 6/6] fix(agreements): Signing-tab send accepts non-UUID agreementId/inspectionId (TEXT ids) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Track I-a SendAgreementSchema gated agreementId + inspectionId with .uuid(), which 400'd legitimate non-UUID rows (Spectora-imported / seeded templates and inspections — agreements.id and inspections.id are TEXT, the handler resolves them by tenant-scoped lookup). Relaxes both to .min(1), matching the hub send fix in inspection.schema.ts. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- server/lib/validations/admin.schema.ts | 8 ++++-- tests/unit/agreement-send-endpoints.spec.ts | 27 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/server/lib/validations/admin.schema.ts b/server/lib/validations/admin.schema.ts index 55b342c6..bec05105 100644 --- a/server/lib/validations/admin.schema.ts +++ b/server/lib/validations/admin.schema.ts @@ -205,14 +205,18 @@ export const SignerInputSchema = z.object({ }).openapi('AgreementSignerInput'); export const SendAgreementSchema = z.object({ - agreementId: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }).describe('TODO describe agreementId field for the OpenInspection MCP integration'), + // Plain non-empty string, NOT .uuid(): agreements.id / inspections.id are TEXT + // columns and may hold non-UUID values (e.g. Spectora-imported or seeded rows). + // The handler resolves them by tenant-scoped lookup, so .uuid() would only reject + // legitimate non-UUID rows. (Matches the hub send fix in inspection.schema.ts.) + agreementId: z.string().min(1).openapi({ example: 'agr-0c1f2e3d' }).describe('Agreement template id (TEXT; not necessarily a UUID)'), // Track I-a Task 9 — `clientEmail` is only consumed on the legacy // single-recipient path; the multi-signer path keys recipients off the // `signers` array. Optional here, gated by the refine below so exactly one // of the two paths is always satisfiable. clientEmail: z.string().email().optional().openapi({ example: 'client@example.com' }).describe('Recipient email for the legacy single-signer send; omit when `signers` is provided'), clientName: z.string().max(100).optional().openapi({ example: 'John Smith' }).describe('TODO describe clientName field for the OpenInspection MCP integration'), - inspectionId: z.string().uuid().optional().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }).describe('TODO describe inspectionId field for the OpenInspection MCP integration'), + inspectionId: z.string().min(1).optional().openapi({ example: 'insp-0c1f2e3d' }).describe('Inspection id (TEXT; not necessarily a UUID)'), // Track I-a Task 9 — multi-signer envelope. When `signers` is provided the // send routes through AgreementService.findOrCreate (signer rows + snapshot // pinning + per-signer links). Omitted → legacy single-recipient behavior. diff --git a/tests/unit/agreement-send-endpoints.spec.ts b/tests/unit/agreement-send-endpoints.spec.ts index d1d65db7..bb10541d 100644 --- a/tests/unit/agreement-send-endpoints.spec.ts +++ b/tests/unit/agreement-send-endpoints.spec.ts @@ -148,6 +148,33 @@ describe('POST /api/admin/agreements/send — multi-signer', () => { expect(emailSend).toHaveBeenCalledTimes(2); }); + it('accepts NON-UUID agreementId / inspectionId (TEXT ids, e.g. Spectora-imported or seeded rows)', async () => { + // Regression: the body schema once gated agreementId/inspectionId with + // .uuid(), which 400'd legitimate non-UUID rows. agreements.id and + // inspections.id are TEXT columns; the handler resolves them by tenant- + // scoped lookup. Mirrors the hub send fix in inspection.schema.ts. + const TEXT_AGR = 'agr-seeded-not-a-uuid'; + const TEXT_INSP = 'insp-seeded-not-a-uuid'; + await db.insert(schema.inspections).values({ id: TEXT_INSP, tenantId: TENANT, propertyAddress: '2 Oak St', clientName: 'Pat', clientEmail: 'pat@test.com', date: '2026-06-02', status: 'draft', paymentStatus: 'unpaid', price: 40000, agreementRequired: true, paymentRequired: false, createdAt: new Date() }); + await db.insert(schema.agreements).values({ id: TEXT_AGR, tenantId: TENANT, name: 'Imported Agreement', content: 'Imported text...', version: 1, createdAt: new Date() }); + + const res = await buildApp().request('/api/admin/agreements/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agreementId: TEXT_AGR, + inspectionId: TEXT_INSP, + completionPolicy: 'all', + signers: [{ name: 'Pat', email: 'pat@test.com', role: 'client' }], + }), + }, ENV, EXEC); + // Must NOT be rejected by UUID validation — reaches the handler and sends. + expect(res.status).toBe(200); + const body = await res.json() as { success: boolean; data: { requestId: string } }; + expect(body.success).toBe(true); + expect(body.data.requestId).toBeTruthy(); + }); + it('rejects a request with neither clientEmail nor signers', async () => { const res = await buildApp().request('/api/admin/agreements/send', { method: 'POST',