fix: resolve appointment form validation and socket connection issues

- Fix status enum in browser.ts (uppercase → lowercase values)
- Fix date validation (z.string → z.coerce.date) so form accepts Date objects
- Add missing type/userId/title fields to browser.ts appointment schema
- Replace nested PatientSearch Select with simple inline Input to fix patient selection
- Fix mutationFn to throw on non-ok responses so backend errors surface correctly
- Connect socket.io directly to backend (port 5000) instead of Vite proxy to fix AggregateError on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-04-23 23:30:08 -04:00
parent 4f8a211bf1
commit a279a3e7c1
5 changed files with 287 additions and 91 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -35,10 +35,6 @@ import {
} from "@repo/db/types"; } from "@repo/db/types";
import { DateInputField } from "@/components/ui/dateInputField"; import { DateInputField } from "@/components/ui/dateInputField";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import {
PatientSearch,
SearchCriteria,
} from "@/components/patients/patient-search";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
interface AppointmentFormProps { interface AppointmentFormProps {
@@ -152,75 +148,26 @@ export function AppointmentForm({
}); });
// ----------------------------- // -----------------------------
// PATIENT SEARCH (reuse PatientSearch) // PATIENT SEARCH (simple inline search)
// ----------------------------- // -----------------------------
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [patientSearchTerm, setPatientSearchTerm] = useState("");
const [debouncedPatientSearch] = useDebounce(patientSearchTerm, 300);
// search criteria state (reused from patient page) const searchKeyPart = debouncedPatientSearch.trim() || "recent";
const [searchCriteria, setSearchCriteria] = useState<SearchCriteria | null>(
null
);
const [isSearchActive, setIsSearchActive] = useState(false);
// debounce search criteria so we don't hammer the backend
const [debouncedSearchCriteria] = useDebounce(searchCriteria, 300);
const limit = 50; // dropdown size
const offset = 0; // always first page for dropdown
// compute key used in patient page: recent or trimmed term
const searchKeyPart = useMemo(
() => debouncedSearchCriteria?.searchTerm?.trim() || "recent",
[debouncedSearchCriteria]
);
// Query function mirrors PatientTable logic (so backend contract is identical)
const queryFn = async (): Promise<Patient[]> => { const queryFn = async (): Promise<Patient[]> => {
const trimmedTerm = debouncedSearchCriteria?.searchTerm?.trim(); const trimmed = debouncedPatientSearch.trim();
const isSearch = !!trimmedTerm && trimmedTerm.length > 0; const url = trimmed
const rawSearchBy = debouncedSearchCriteria?.searchBy || "name"; ? `/api/patients/search?name=${encodeURIComponent(trimmed)}&limit=50&offset=0`
const validSearchKeys = [ : `/api/patients/recent?limit=50&offset=0`;
"name",
"phone",
"insuranceId",
"gender",
"dob",
"all",
];
const searchKey = validSearchKeys.includes(rawSearchBy)
? rawSearchBy
: "name";
let url: string;
if (isSearch) {
const searchParams = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (searchKey === "all") {
searchParams.set("term", trimmedTerm!);
} else {
searchParams.set(searchKey, trimmedTerm!);
}
url = `/api/patients/search?${searchParams.toString()}`;
} else {
url = `/api/patients/recent?limit=${limit}&offset=${offset}`;
}
const res = await apiRequest("GET", url); const res = await apiRequest("GET", url);
if (!res.ok) { if (!res.ok) {
const err = await res const err = await res.json().catch(() => ({ message: "Failed to fetch patients" }));
.json()
.catch(() => ({ message: "Failed to fetch patients" }));
throw new Error(err.message || "Failed to fetch patients"); throw new Error(err.message || "Failed to fetch patients");
} }
const payload = await res.json(); const payload = await res.json();
// Expect payload to be { patients: Patient[], totalCount: number } or just an array.
// Normalize: if payload.patients exists, return it; otherwise assume array of patients.
return Array.isArray(payload) ? payload : (payload.patients ?? []); return Array.isArray(payload) ? payload : (payload.patients ?? []);
}; };
@@ -231,12 +178,11 @@ export function AppointmentForm({
} = useQuery<Patient[], Error>({ } = useQuery<Patient[], Error>({
queryKey: ["patients-dropdown", searchKeyPart], queryKey: ["patients-dropdown", searchKeyPart],
queryFn, queryFn,
enabled: selectOpen || !!debouncedSearchCriteria?.searchTerm, enabled: selectOpen || debouncedPatientSearch.trim().length > 0,
}); });
// If select opened and no patients loaded, fetch
useEffect(() => { useEffect(() => {
if (selectOpen && (!patients || patients.length === 0)) { if (selectOpen && patients.length === 0) {
refetchPatients(); refetchPatients();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -418,11 +364,7 @@ export function AppointmentForm({
onOpenChange={(open: boolean) => { onOpenChange={(open: boolean) => {
setSelectOpen(open); setSelectOpen(open);
if (!open) { if (!open) {
// reset transient search state when the dropdown closes setPatientSearchTerm("");
setSearchCriteria(null);
setIsSearchActive(false);
// Remove transient prefill if the main cached list contains it now
if ( if (
prefillPatient && prefillPatient &&
patients && patients &&
@@ -433,7 +375,6 @@ export function AppointmentForm({
setPrefillPatient(null); setPrefillPatient(null);
} }
} else { } else {
// when opened, ensure initial results
if (!patients || patients.length === 0) refetchPatients(); if (!patients || patients.length === 0) refetchPatients();
} }
}} }}
@@ -458,21 +399,12 @@ export function AppointmentForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{/* Reuse full PatientSearch UI inside dropdown — callbacks update the query */}
<div className="p-2" onKeyDown={(e) => e.stopPropagation()}> <div className="p-2" onKeyDown={(e) => e.stopPropagation()}>
<PatientSearch <Input
onSearch={(criteria) => { placeholder="Search by name..."
setSearchCriteria(criteria); value={patientSearchTerm}
setIsSearchActive(true); onChange={(e) => setPatientSearchTerm(e.target.value)}
}} onClick={(e) => e.stopPropagation()}
onClearSearch={() => {
setSearchCriteria({
searchTerm: "",
searchBy: "name",
});
setIsSearchActive(false);
}}
isSearchActive={isSearchActive}
/> />
</div> </div>

View File

@@ -6,13 +6,16 @@
*/ */
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
const SOCKET_URL = const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND || import.meta.env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000";
(typeof window !== "undefined" ? window.location.origin : "");
export const socket: Socket = io(SOCKET_URL, { export const socket: Socket = io(SOCKET_URL, {
withCredentials: true, withCredentials: true,
autoConnect: true, autoConnect: true,
reconnectionAttempts: 5,
reconnectionDelay: 2000,
}); });
socket.on("connect", () => { socket.on("connect", () => {
@@ -22,3 +25,7 @@ socket.on("connect", () => {
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log("[socket] disconnected"); console.log("[socket] disconnected");
}); });
socket.on("connect_error", (err) => {
console.warn("[socket] connection error:", err.message);
});

View File

@@ -274,7 +274,9 @@ export default function AppointmentsPage() {
"/api/appointments/upsert", "/api/appointments/upsert",
appointment appointment
); );
return await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body?.message || "Failed to create appointment");
return body;
}, },
onSuccess: (appointment) => { onSuccess: (appointment) => {
toast({ toast({

View File

@@ -0,0 +1,252 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommunicationUncheckedCreateInputObjectSchema = exports.CloudFileUncheckedCreateInputObjectSchema = exports.CloudFolderUncheckedCreateInputObjectSchema = exports.BackupDestinationUncheckedCreateInputObjectSchema = exports.DatabaseBackupUncheckedCreateInputObjectSchema = exports.NotificationUncheckedCreateInputObjectSchema = exports.ServiceLineTransactionCreateInputObjectSchema = exports.PaymentUncheckedCreateInputObjectSchema = exports.PdfGroupUncheckedCreateInputObjectSchema = exports.PdfFileUncheckedCreateInputObjectSchema = exports.InsuranceCredentialUncheckedCreateInputObjectSchema = exports.ClaimUncheckedCreateInputObjectSchema = exports.NpiProviderUncheckedCreateInputObjectSchema = exports.AppointmentProcedureUncheckedCreateInputObjectSchema = exports.AppointmentUncheckedCreateInputObjectSchema = exports.StaffUncheckedCreateInputObjectSchema = exports.PatientUncheckedCreateInputObjectSchema = exports.UserUncheckedCreateInputObjectSchema = exports.CommunicationStatusSchema = exports.CommunicationDirectionSchema = exports.CommunicationChannelSchema = exports.PdfTitleKeySchema = exports.ProcedureSourceSchema = exports.NotificationTypesSchema = exports.PaymentStatusSchema = exports.PaymentMethodSchema = exports.ClaimStatusSchema = exports.PatientStatusSchema = void 0;
const z = __importStar(require("zod"));
// ─── Enums ────────────────────────────────────────────────────────────────────
exports.PatientStatusSchema = z.enum(["ACTIVE", "INACTIVE", "UNKNOWN"]);
exports.ClaimStatusSchema = z.enum([
"PENDING",
"APPROVED",
"CANCELLED",
"REVIEW",
"VOID",
]);
exports.PaymentMethodSchema = z.enum([
"EFT",
"CHECK",
"CASH",
"CARD",
"OTHER",
]);
exports.PaymentStatusSchema = z.enum([
"PENDING",
"PARTIALLY_PAID",
"PAID",
"OVERPAID",
"DENIED",
"VOID",
]);
exports.NotificationTypesSchema = z.enum([
"BACKUP",
"CLAIM",
"PAYMENT",
"ETC",
]);
exports.ProcedureSourceSchema = z.enum(["COMBO", "MANUAL"]);
exports.PdfTitleKeySchema = z.enum([
"INSURANCE_CLAIM",
"INSURANCE_CLAIM_PREAUTH",
"ELIGIBILITY_STATUS",
"CLAIM_STATUS",
"OTHER",
]);
exports.CommunicationChannelSchema = z.enum(["sms", "voice"]);
exports.CommunicationDirectionSchema = z.enum(["outbound", "inbound"]);
exports.CommunicationStatusSchema = z.enum([
"queued",
"sent",
"delivered",
"failed",
"completed",
"busy",
"no_answer",
]);
// ─── Object Schemas ───────────────────────────────────────────────────────────
exports.UserUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
username: z.string(),
password: z.string(),
});
exports.PatientUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.string(),
phone: z.string().optional(),
email: z.string().optional(),
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zipCode: z.string().optional(),
status: exports.PatientStatusSchema.optional(),
});
exports.StaffUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
firstName: z.string(),
lastName: z.string(),
role: z.string(),
phone: z.string().optional(),
email: z.string().optional(),
userId: z.number().int().optional(),
});
exports.AppointmentUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
patientId: z.number().int(),
userId: z.number().int().optional(),
staffId: z.number().int().optional(),
title: z.string().optional(),
date: z.coerce.date(),
startTime: z.string(),
endTime: z.string(),
type: z.string(),
status: z
.enum(["scheduled", "confirmed", "completed", "cancelled", "no-show"])
.optional(),
notes: z.string().optional(),
});
exports.AppointmentProcedureUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
appointmentId: z.number().int(),
patientId: z.number().int(),
procedureCode: z.string(),
procedureLabel: z.string().optional().nullable(),
fee: z.number().optional().nullable(),
category: z.string().optional().nullable(),
toothNumber: z.string().optional().nullable(),
toothSurface: z.string().optional().nullable(),
oralCavityArea: z.string().optional().nullable(),
source: exports.ProcedureSourceSchema.optional(),
comboKey: z.string().optional().nullable(),
createdAt: z.coerce.date().optional(),
});
exports.NpiProviderUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
npiNumber: z.string(),
providerName: z.string(),
createdAt: z.coerce.date().optional(),
});
exports.ClaimUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
patientId: z.number().int(),
appointmentId: z.number().int().optional(),
status: exports.ClaimStatusSchema.optional(),
submittedAt: z.string().optional(),
paidAt: z.string().optional(),
amount: z.number().optional(),
insuranceId: z.string().optional(),
});
exports.InsuranceCredentialUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
siteKey: z.string(),
username: z.string(),
password: z.string(),
});
exports.PdfFileUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
filename: z.string(),
pdfData: z.instanceof(Uint8Array),
uploadedAt: z.coerce.date().optional(),
groupId: z.number().int(),
});
exports.PdfGroupUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
title: z.string(),
titleKey: exports.PdfTitleKeySchema.optional(),
createdAt: z.coerce.date().optional(),
patientId: z.number().int(),
});
exports.PaymentUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
patientId: z.number().int(),
appointmentId: z.number().int().optional(),
amount: z.number(),
method: exports.PaymentMethodSchema.optional(),
status: exports.PaymentStatusSchema.optional(),
paymentDate: z.string().optional(),
updatedById: z.number().int().optional(),
});
exports.ServiceLineTransactionCreateInputObjectSchema = z.object({
transactionId: z.string().optional().nullable(),
paidAmount: z.number(),
adjustedAmount: z.number().optional(),
method: exports.PaymentMethodSchema,
receivedDate: z.coerce.date(),
payerName: z.string().optional().nullable(),
notes: z.string().optional().nullable(),
createdAt: z.coerce.date().optional(),
});
exports.NotificationUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
type: exports.NotificationTypesSchema,
message: z.string(),
createdAt: z.coerce.date().optional(),
read: z.boolean().optional(),
});
exports.DatabaseBackupUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
createdAt: z.coerce.date().optional(),
});
exports.BackupDestinationUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
path: z.string(),
isActive: z.boolean().optional(),
createdAt: z.coerce.date().optional(),
});
exports.CloudFolderUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
name: z.string(),
parentId: z.number().int().optional().nullable(),
createdAt: z.coerce.date().optional(),
});
exports.CloudFileUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
userId: z.number().int(),
name: z.string(),
mimeType: z.string().optional().nullable(),
fileSize: z.bigint(),
folderId: z.number().int().optional().nullable(),
isComplete: z.boolean().optional(),
totalChunks: z.number().int().optional().nullable(),
createdAt: z.coerce.date().optional(),
});
exports.CommunicationUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(),
patientId: z.number().int(),
userId: z.number().int().optional().nullable(),
channel: exports.CommunicationChannelSchema,
direction: exports.CommunicationDirectionSchema,
status: exports.CommunicationStatusSchema,
body: z.string().optional().nullable(),
callDuration: z.number().int().optional().nullable(),
twilioSid: z.string().optional().nullable(),
createdAt: z.coerce.date().optional(),
});

View File

@@ -107,12 +107,15 @@ export const StaffUncheckedCreateInputObjectSchema = z.object({
export const AppointmentUncheckedCreateInputObjectSchema = z.object({ export const AppointmentUncheckedCreateInputObjectSchema = z.object({
id: z.number().int().optional(), id: z.number().int().optional(),
patientId: z.number().int(), patientId: z.number().int(),
userId: z.number().int().optional(),
staffId: z.number().int().optional(), staffId: z.number().int().optional(),
date: z.string(), title: z.string().optional(),
date: z.coerce.date(),
startTime: z.string(), startTime: z.string(),
endTime: z.string(), endTime: z.string(),
type: z.string(),
status: z status: z
.enum(["SCHEDULED", "COMPLETED", "CANCELLED", "NO_SHOW"]) .enum(["scheduled", "confirmed", "completed", "cancelled", "no-show"])
.optional(), .optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });