feat: persist AI conversation state in DB and fix LangGraph flow bugs

- Replace in-memory Maps in aiHandoffStore with DB-backed async functions
  using new patient_conversation table (stage + aiHandoff per patient)
- Add afterHoursEnabled to ai_settings table (persists across restarts)
- Fix runtime crash in reschedule-graph: mon/tue/wed variables were out
  of scope in the next-week fallback branch (ReferenceError)
- Wire rescheduleGreeting and generalFallback chat templates through to
  LangGraph nodes so user-configured messages take effect
- Add otherNode to reminder-graph to handle unclassified patient replies
  (e.g. "I want another appointment") and route to booking flow
- Fetch chatTemplates once per webhook request instead of per stage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-09 15:23:55 -04:00
parent e9296c68f9
commit 112529155c
321 changed files with 5096 additions and 446 deletions

View File

@@ -3,6 +3,7 @@ export const AiSettingsAggregateResultSchema = z.object({ _count: z.object({
id: z.number(),
userId: z.number(),
apiKey: z.number(),
afterHoursEnabled: z.number(),
user: z.number()
}).optional(),
_sum: z.object({

View File

@@ -3,5 +3,6 @@ export const AiSettingsCreateResultSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
});

View File

@@ -3,5 +3,6 @@ export const AiSettingsDeleteResultSchema = z.nullable(z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
}));

View File

@@ -3,5 +3,6 @@ export const AiSettingsFindFirstResultSchema = z.nullable(z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
}));

View File

@@ -4,6 +4,7 @@ export const AiSettingsFindManyResultSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
})),
pagination: z.object({

View File

@@ -3,5 +3,6 @@ export const AiSettingsFindUniqueResultSchema = z.nullable(z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
}));

View File

@@ -3,10 +3,12 @@ export const AiSettingsGroupByResultSchema = z.array(z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
_count: z.object({
id: z.number(),
userId: z.number(),
apiKey: z.number(),
afterHoursEnabled: z.number(),
user: z.number()
}).optional(),
_sum: z.object({

View File

@@ -3,5 +3,6 @@ export const AiSettingsUpdateResultSchema = z.nullable(z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
}));

View File

@@ -3,5 +3,6 @@ export const AiSettingsUpsertResultSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
apiKey: z.string(),
afterHoursEnabled: z.boolean(),
user: z.unknown()
});

View File

@@ -28,7 +28,8 @@ export const PatientAggregateResultSchema = z.object({ _count: z.object({
groups: z.number(),
payment: z.number(),
communications: z.number(),
documents: z.number()
documents: z.number(),
conversation: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable(),

View File

@@ -0,0 +1,35 @@
import * as z from 'zod';
export const PatientConversationAggregateResultSchema = z.object({ _count: z.object({
id: z.number(),
patientId: z.number(),
userId: z.number(),
stage: z.number(),
aiHandoff: z.number(),
updatedAt: z.number(),
patient: z.number(),
user: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable(),
patientId: z.number().nullable(),
userId: z.number().nullable()
}).nullable().optional(),
_avg: z.object({
id: z.number().nullable(),
patientId: z.number().nullable(),
userId: z.number().nullable()
}).nullable().optional(),
_min: z.object({
id: z.number().int().nullable(),
patientId: z.number().int().nullable(),
userId: z.number().int().nullable(),
stage: z.string().nullable(),
updatedAt: z.date().nullable()
}).nullable().optional(),
_max: z.object({
id: z.number().int().nullable(),
patientId: z.number().int().nullable(),
userId: z.number().int().nullable(),
stage: z.string().nullable(),
updatedAt: z.date().nullable()
}).nullable().optional()});

View File

@@ -0,0 +1,2 @@
import * as z from 'zod';
export const PatientConversationCountResultSchema = z.number();

View File

@@ -0,0 +1,4 @@
import * as z from 'zod';
export const PatientConversationCreateManyResultSchema = z.object({
count: z.number()
});

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationCreateResultSchema = z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
});

View File

@@ -0,0 +1,4 @@
import * as z from 'zod';
export const PatientConversationDeleteManyResultSchema = z.object({
count: z.number()
});

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationDeleteResultSchema = z.nullable(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
}));

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationFindFirstResultSchema = z.nullable(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
}));

View File

@@ -0,0 +1,21 @@
import * as z from 'zod';
export const PatientConversationFindManyResultSchema = z.object({
data: z.array(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
})),
pagination: z.object({
page: z.number().int().min(1),
pageSize: z.number().int().min(1),
total: z.number().int().min(0),
totalPages: z.number().int().min(0),
hasNext: z.boolean(),
hasPrev: z.boolean()
})
});

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationFindUniqueResultSchema = z.nullable(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
}));

View File

@@ -0,0 +1,43 @@
import * as z from 'zod';
export const PatientConversationGroupByResultSchema = z.array(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
_count: z.object({
id: z.number(),
patientId: z.number(),
userId: z.number(),
stage: z.number(),
aiHandoff: z.number(),
updatedAt: z.number(),
patient: z.number(),
user: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable(),
patientId: z.number().nullable(),
userId: z.number().nullable()
}).nullable().optional(),
_avg: z.object({
id: z.number().nullable(),
patientId: z.number().nullable(),
userId: z.number().nullable()
}).nullable().optional(),
_min: z.object({
id: z.number().int().nullable(),
patientId: z.number().int().nullable(),
userId: z.number().int().nullable(),
stage: z.string().nullable(),
updatedAt: z.date().nullable()
}).nullable().optional(),
_max: z.object({
id: z.number().int().nullable(),
patientId: z.number().int().nullable(),
userId: z.number().int().nullable(),
stage: z.string().nullable(),
updatedAt: z.date().nullable()
}).nullable().optional()
}));

View File

@@ -0,0 +1,4 @@
import * as z from 'zod';
export const PatientConversationUpdateManyResultSchema = z.object({
count: z.number()
});

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationUpdateResultSchema = z.nullable(z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
}));

View File

@@ -0,0 +1,11 @@
import * as z from 'zod';
export const PatientConversationUpsertResultSchema = z.object({
id: z.number().int(),
patientId: z.number().int(),
userId: z.number().int(),
stage: z.string(),
aiHandoff: z.boolean(),
updatedAt: z.date(),
patient: z.unknown(),
user: z.unknown()
});

View File

@@ -28,5 +28,6 @@ export const PatientCreateResultSchema = z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
});

View File

@@ -28,5 +28,6 @@ export const PatientDeleteResultSchema = z.nullable(z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
}));

View File

@@ -28,5 +28,6 @@ export const PatientFindFirstResultSchema = z.nullable(z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
}));

View File

@@ -29,7 +29,8 @@ export const PatientFindManyResultSchema = z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
})),
pagination: z.object({
page: z.number().int().min(1),

View File

@@ -28,5 +28,6 @@ export const PatientFindUniqueResultSchema = z.nullable(z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
}));

View File

@@ -49,7 +49,8 @@ export const PatientGroupByResultSchema = z.array(z.object({
groups: z.number(),
payment: z.number(),
communications: z.number(),
documents: z.number()
documents: z.number(),
conversation: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable(),

View File

@@ -28,5 +28,6 @@ export const PatientUpdateResultSchema = z.nullable(z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
}));

View File

@@ -28,5 +28,6 @@ export const PatientUpsertResultSchema = z.object({
groups: z.array(z.unknown()),
payment: z.array(z.unknown()),
communications: z.array(z.unknown()),
documents: z.array(z.unknown())
documents: z.array(z.unknown()),
conversation: z.unknown().optional()
});

View File

@@ -23,7 +23,8 @@ export const UserAggregateResultSchema = z.object({ _count: z.object({
officeHours: z.number(),
officeContact: z.number(),
procedureTimeslot: z.number(),
insuranceContacts: z.number()
insuranceContacts: z.number(),
patientConversations: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable()

View File

@@ -23,5 +23,6 @@ export const UserCreateResultSchema = z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
});

View File

@@ -23,5 +23,6 @@ export const UserDeleteResultSchema = z.nullable(z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
}));

View File

@@ -23,5 +23,6 @@ export const UserFindFirstResultSchema = z.nullable(z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
}));

View File

@@ -24,7 +24,8 @@ export const UserFindManyResultSchema = z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
})),
pagination: z.object({
page: z.number().int().min(1),

View File

@@ -23,5 +23,6 @@ export const UserFindUniqueResultSchema = z.nullable(z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
}));

View File

@@ -29,7 +29,8 @@ export const UserGroupByResultSchema = z.array(z.object({
officeHours: z.number(),
officeContact: z.number(),
procedureTimeslot: z.number(),
insuranceContacts: z.number()
insuranceContacts: z.number(),
patientConversations: z.number()
}).optional(),
_sum: z.object({
id: z.number().nullable()

View File

@@ -23,5 +23,6 @@ export const UserUpdateResultSchema = z.nullable(z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
}));

View File

@@ -23,5 +23,6 @@ export const UserUpsertResultSchema = z.object({
officeHours: z.unknown().optional(),
officeContact: z.unknown().optional(),
procedureTimeslot: z.unknown().optional(),
insuranceContacts: z.array(z.unknown())
insuranceContacts: z.array(z.unknown()),
patientConversations: z.array(z.unknown())
});

View File

@@ -388,3 +388,16 @@ export { ProcedureTimeslotDeleteManyResultSchema } from './ProcedureTimeslotDele
export { ProcedureTimeslotAggregateResultSchema } from './ProcedureTimeslotAggregateResult.schema';
export { ProcedureTimeslotGroupByResultSchema } from './ProcedureTimeslotGroupByResult.schema';
export { ProcedureTimeslotCountResultSchema } from './ProcedureTimeslotCountResult.schema';
export { PatientConversationFindUniqueResultSchema } from './PatientConversationFindUniqueResult.schema';
export { PatientConversationFindFirstResultSchema } from './PatientConversationFindFirstResult.schema';
export { PatientConversationFindManyResultSchema } from './PatientConversationFindManyResult.schema';
export { PatientConversationCreateResultSchema } from './PatientConversationCreateResult.schema';
export { PatientConversationCreateManyResultSchema } from './PatientConversationCreateManyResult.schema';
export { PatientConversationUpdateResultSchema } from './PatientConversationUpdateResult.schema';
export { PatientConversationUpdateManyResultSchema } from './PatientConversationUpdateManyResult.schema';
export { PatientConversationUpsertResultSchema } from './PatientConversationUpsertResult.schema';
export { PatientConversationDeleteResultSchema } from './PatientConversationDeleteResult.schema';
export { PatientConversationDeleteManyResultSchema } from './PatientConversationDeleteManyResult.schema';
export { PatientConversationAggregateResultSchema } from './PatientConversationAggregateResult.schema';
export { PatientConversationGroupByResultSchema } from './PatientConversationGroupByResult.schema';
export { PatientConversationCountResultSchema } from './PatientConversationCountResult.schema';