Phase 1 - Security & Bug Fixes: - Add requireApiAuth helper and protect all 25 unprotected API routes - Add org-tenant scoping to all card, job, stats, and notification queries - Fix SSRF in ai-test, mask secrets in settings API, fix middleware bypass - Fix cards pagination routing, stat filter sync, drag-drop file passing - Add PUT /api/auth/me for profile persistence, stuck job recovery - Fix email watcher MIME type detection Phase 2 - Dynamic Fields & Digital Survey: - Add FormTemplate, FormField, Person, PasswordResetToken models to schema - Add fieldData, formTemplateId, firstName, lastName, personId to ResponseCard - Build FormTemplate CRUD API with field management and org scoping - Build Form Builder UI with field ordering, type config, and section management - Refactor card detail page to render fields dynamically from templates - Add dynamic OCR prompt/schema generation from template fields - Build public survey page at /s/[orgSlug]/[formSlug] with branding - Add QR code generation API and share section component Phase 3 - People & Analytics: - Build People CRUD API with merge and batch auto-link endpoints - Build People list and detail pages with search, merge dialog - Add auto-link logic in OCR completion to match/create Person records - Add /api/stats/trends endpoint with time series and team activity - Build Reports page with Recharts (area charts, bar charts, pipeline) - Upgrade dashboard with sparklines and People stat card Phase 4 - UX Polish: - Replace silent error handling with toast notifications across all pages - Add loading skeletons, differentiated empty states - Add ARIA labels, skip-to-content link, accessible column toggle - Add forgot password flow, Cmd+K command palette, Collection Days pages - Unify Echo branding and theme toggle consistency Made-with: Cursor
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
import { generateText, Output } from "ai";
|
|
import { gateway } from "@ai-sdk/gateway";
|
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
import { z } from "zod";
|
|
import { prisma } from "./db";
|
|
import type { LanguageModel } from "ai";
|
|
import type { FormField as PrismaFormField } from "@/generated/prisma/client";
|
|
|
|
const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
|
|
const DEFAULT_OLLAMA_MODEL = "llava:7b";
|
|
|
|
async function getSettings() {
|
|
let settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
});
|
|
if (!settings) {
|
|
settings = await prisma.appSettings.create({
|
|
data: { id: "singleton" },
|
|
});
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function getModelInstance(
|
|
provider: string,
|
|
modelId: string,
|
|
ollamaUrl: string
|
|
): LanguageModel {
|
|
if (provider === "ollama") {
|
|
const resolvedModel = modelId || DEFAULT_OLLAMA_MODEL;
|
|
const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
|
|
const ollama = createOpenAICompatible({ name: "ollama", baseURL });
|
|
return ollama.chatModel(resolvedModel);
|
|
}
|
|
|
|
const resolvedModel = modelId || DEFAULT_GATEWAY_MODEL;
|
|
return gateway(resolvedModel);
|
|
}
|
|
|
|
const responseCardSchema = z.object({
|
|
name: z.string().nullable().describe("Full name as written on the card"),
|
|
gender: z.string().nullable().describe("Male or Female"),
|
|
dateOfBirth: z.string().nullable().describe("Date of birth as written"),
|
|
maritalStatus: z.string().nullable().describe("Married, Single, or Other"),
|
|
maritalStatusOther: z.string().nullable().describe("Value if Other is checked"),
|
|
visitType: z.string().nullable().describe("Which checkbox is marked? Normalize to exactly: 'First/Second Time Guest' or 'Update My Information'. The card may say 'I am a first or second time guest at Echo Life' — map that to 'First/Second Time Guest'. Return null if neither is checked."),
|
|
cellPhone: z.string().nullable().describe("Cell phone number"),
|
|
homePhone: z.string().nullable().describe("Home phone number"),
|
|
email: z.string().nullable().describe("Email address"),
|
|
address: z.string().nullable().describe("Street address"),
|
|
aptNumber: z.string().nullable().describe("Apartment number"),
|
|
city: z.string().nullable().describe("City"),
|
|
state: z.string().nullable().describe("State"),
|
|
zip: z.string().nullable().describe("ZIP code"),
|
|
prayerRequests: z.string().nullable().describe("Written prayer requests"),
|
|
prayerForTeam: z.boolean().describe("Whether prayer team checkbox is checked"),
|
|
prayerConfidential: z.boolean().describe("Whether confidential checkbox is checked"),
|
|
confidence: z.number().min(0).max(100).describe("Your confidence in the OCR accuracy, 0-100"),
|
|
});
|
|
|
|
const surveySchema = z.object({
|
|
messageTopics: z.array(z.string()).describe(
|
|
"ONLY items whose checkbox is physically marked (X, checkmark, or filled). Options: Stress/Anxiety, Marriage, Hearing God's Voice, Dealing With Doubt, Parenting, Grief & Loss, Forgiveness, Finances, Purpose/Calling, Prayer, Healthy Boundaries, Understanding The Bible, Emotional Health, Sharing My Faith, Decision Making, Spiritual Disciplines, Spiritual Gifts. Return empty array if none are marked."
|
|
),
|
|
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
|
|
nextStep: z.array(z.string()).describe(
|
|
"CRITICAL: This question has EXACTLY 2 separate checkboxes, each on its own line. Inspect each checkbox square individually. 'Baptism' = FIRST checkbox (next to 'Expressing my faith in Jesus...'). 'Next Steps' = SECOND checkbox (next to 'Learning more about becoming a partner...'). A checkbox is checked ONLY if its square contains a visible X, checkmark, or fill. An empty square = NOT checked. Most people check only one. Return empty array if neither is marked."
|
|
),
|
|
attendanceDuration: z.string().nullable().describe(
|
|
"The single checked radio option. One of: Less than 6 months, 6 Months - 1 Year, 1-3 Years, 4-6 Years, or 7+ Years. null if none is marked."
|
|
),
|
|
campusPreference: z.array(z.string()).describe(
|
|
"ONLY locations whose checkbox is physically marked. Options: Beulah, Pace/Milton, Gulf Breeze, Warrington. Return empty array if none are marked."
|
|
),
|
|
campusPreferenceOther: z.string().nullable().describe("Value if Other is filled in"),
|
|
howHeard: z.array(z.string()).describe(
|
|
"ONLY items whose checkbox is physically marked. Options: This is my church home, Regular Attender, Drove by, Social Media, Google, Personal Invite. Return empty array if none are marked."
|
|
),
|
|
howHeardOther: z.string().nullable().describe("Value if Other is filled in"),
|
|
serviceAttended: z.string().nullable().describe("Service letter: A, B, C, or D"),
|
|
confidence: z.number().min(0).max(100).describe("Your confidence in the OCR accuracy, 0-100"),
|
|
});
|
|
|
|
const RESPONSE_SYSTEM_PROMPT = `You are analyzing a scanned church response card. This is the PERSONAL INFORMATION side.
|
|
|
|
Extract ALL of the following fields from the image.
|
|
|
|
CHECKBOX RULES - be very strict:
|
|
- A checkbox is CHECKED only if it has a visible mark inside it: an X, a checkmark, a filled square, or pen/pencil marks inside the box.
|
|
- An EMPTY box (no marks inside) means UNCHECKED, even if text appears next to it.
|
|
- When in doubt, treat a checkbox as UNCHECKED.
|
|
|
|
For handwritten text, read it as accurately as possible.
|
|
Be precise: return null for fields you cannot read. Set prayerForTeam and prayerConfidential to false if the checkboxes are not clearly marked.`;
|
|
|
|
const SURVEY_SYSTEM_PROMPT = `You are analyzing a scanned church Easter survey form. This is the SURVEY side.
|
|
|
|
Extract ALL of the following fields from the image.
|
|
|
|
CHECKBOX RULES - be very strict:
|
|
- A checkbox is CHECKED only if its square box contains a visible mark: an X, a checkmark, a filled square, or pen/pencil marks INSIDE the box.
|
|
- An EMPTY box (no marks inside the square) means UNCHECKED, even if text appears next to it.
|
|
- When in doubt, treat a checkbox as UNCHECKED.
|
|
- For array fields (messageTopics, nextStep, campusPreference, howHeard): ONLY include items whose checkbox square is physically marked. Return empty arrays when no checkboxes in that group are marked.
|
|
- Do NOT confuse section headings or question titles with checked answers.
|
|
|
|
SPECIAL ATTENTION for "Next Step" question (question 2): It has exactly 2 checkboxes on separate lines. Look at each checkbox square independently. Most respondents check only one. Do not assume both are checked.
|
|
|
|
Be precise: return null for fields you cannot read.`;
|
|
|
|
type FormFieldDef = Pick<
|
|
PrismaFormField,
|
|
"key" | "label" | "type" | "options" | "visibleOnCard" | "visibleOnSurvey"
|
|
>;
|
|
|
|
export function buildSchemaFromFields(fields: FormFieldDef[]) {
|
|
const shape: Record<string, z.ZodTypeAny> = {};
|
|
for (const field of fields) {
|
|
const opts = Array.isArray(field.options)
|
|
? (field.options as string[])
|
|
: [];
|
|
switch (field.type) {
|
|
case "text":
|
|
case "email":
|
|
case "phone":
|
|
case "textarea":
|
|
case "url":
|
|
shape[field.key] = z.string().nullable().describe(field.label);
|
|
break;
|
|
case "select":
|
|
case "radio":
|
|
shape[field.key] = z
|
|
.string()
|
|
.nullable()
|
|
.describe(
|
|
opts.length > 0
|
|
? `${field.label}. Options: ${opts.join(", ")}`
|
|
: field.label
|
|
);
|
|
break;
|
|
case "multiselect":
|
|
shape[field.key] = z
|
|
.array(z.string())
|
|
.describe(
|
|
opts.length > 0
|
|
? `ONLY checked items for ${field.label}. Options: ${opts.join(", ")}`
|
|
: `Checked items for ${field.label}`
|
|
);
|
|
break;
|
|
case "checkbox":
|
|
shape[field.key] = z
|
|
.boolean()
|
|
.describe(`Whether ${field.label} checkbox is checked`);
|
|
break;
|
|
case "date":
|
|
shape[field.key] = z
|
|
.string()
|
|
.nullable()
|
|
.describe(`${field.label} as written`);
|
|
break;
|
|
case "number":
|
|
shape[field.key] = z.number().nullable().describe(field.label);
|
|
break;
|
|
default:
|
|
shape[field.key] = z.string().nullable().describe(field.label);
|
|
}
|
|
}
|
|
shape.confidence = z
|
|
.number()
|
|
.min(0)
|
|
.max(100)
|
|
.describe("Your confidence in the OCR accuracy, 0-100");
|
|
return z.object(shape);
|
|
}
|
|
|
|
export function buildPromptFromFields(
|
|
fields: FormFieldDef[],
|
|
templateName: string
|
|
): string {
|
|
const fieldList = fields.map((f) => `- ${f.label} (${f.type})`).join("\n");
|
|
return `You are analyzing a scanned ${templateName}. Extract ALL of the following fields from the image:\n\n${fieldList}\n\nCHECKBOX RULES - be very strict:\n- A checkbox is CHECKED only if it has a visible mark inside it: an X, a checkmark, a filled square, or pen/pencil marks inside the box.\n- An EMPTY box means UNCHECKED.\n- When in doubt, treat a checkbox as UNCHECKED.\n\nFor handwritten text, read it as accurately as possible.\nBe precise: return null for fields you cannot read.`;
|
|
}
|
|
|
|
export interface OcrResult {
|
|
data: Record<string, unknown>;
|
|
confidence: number;
|
|
raw: string;
|
|
side: "response" | "survey";
|
|
}
|
|
|
|
const MAX_RETRIES = 4;
|
|
const INITIAL_BACKOFF_MS = 5_000;
|
|
|
|
async function extractStructured<T extends Record<string, unknown>>(
|
|
model: LanguageModel,
|
|
schema: z.ZodType<T>,
|
|
systemPrompt: string,
|
|
imageBase64: string
|
|
): Promise<T> {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
const { output, text } = await generateText({
|
|
model,
|
|
output: Output.object({ schema }),
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Extract all data from this scanned card image." },
|
|
{ type: "image", image: Buffer.from(imageBase64, "base64") },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!output) {
|
|
throw new Error(`AI model did not return structured output. Raw text: ${(text || "").slice(0, 500)}`);
|
|
}
|
|
|
|
return output;
|
|
} catch (err) {
|
|
lastError = err;
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const isRateLimit = /rate.?limit|too many requests|429|temporarily|free credits/i.test(msg);
|
|
|
|
if (!isRateLimit || attempt === MAX_RETRIES) throw err;
|
|
|
|
const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
|
|
console.log(`[ai-ocr] Rate limited (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${backoff / 1000}s...`);
|
|
await new Promise((r) => setTimeout(r, backoff));
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
export async function ocrImage(
|
|
imageBase64: string,
|
|
side: "response" | "survey",
|
|
templateFields?: { fields: FormFieldDef[]; templateName: string }
|
|
): Promise<OcrResult> {
|
|
const settings = await getSettings();
|
|
const provider = settings.aiProvider || "gateway";
|
|
const modelId = settings.aiModel || "";
|
|
const model = getModelInstance(provider, modelId, settings.ollamaUrl);
|
|
|
|
let output: Record<string, unknown>;
|
|
|
|
if (templateFields) {
|
|
const relevantFields = templateFields.fields.filter((f) =>
|
|
side === "response" ? f.visibleOnCard : f.visibleOnSurvey
|
|
);
|
|
const dynamicSchema = buildSchemaFromFields(relevantFields);
|
|
const dynamicPrompt = buildPromptFromFields(
|
|
relevantFields,
|
|
templateFields.templateName
|
|
);
|
|
output = await extractStructured(model, dynamicSchema, dynamicPrompt, imageBase64);
|
|
} else if (side === "response") {
|
|
output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64);
|
|
} else {
|
|
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);
|
|
}
|
|
|
|
const confidence = typeof output.confidence === "number" ? output.confidence : 50;
|
|
const data = { ...output };
|
|
delete data.confidence;
|
|
|
|
return { data, confidence, raw: JSON.stringify(output), side };
|
|
}
|