Add First Time Guest Date and Salvation Date fields

- Add firstTimeGuestDate and salvationDate (DateTime?) to Prisma schema
- Auto-compute dates during OCR: find previous Sunday from card createdAt
  when visitType indicates first/second time guest or nextStep includes
  Baptism
- Add editable date inputs on card detail page in Workflow section
- Add to Monday.com mappable fields in settings for column mapping
- Add table columns (hidden by default) for both date fields
- Handle full ISO datetime strings in Monday.com date parser
- Apply same logic during reprocessing

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-09 17:20:24 -05:00
parent d6d209cf9d
commit b2c29120cd
8 changed files with 99 additions and 5 deletions

View file

@ -49,6 +49,8 @@ model ResponseCard {
planningCenter String? planningCenter String?
iSaidYesBookSent Boolean @default(false) iSaidYesBookSent Boolean @default(false)
ftGuestLetterSent Boolean @default(false) ftGuestLetterSent Boolean @default(false)
firstTimeGuestDate DateTime?
salvationDate DateTime?
// Meta // Meta
sourceFile String? sourceFile String?

View file

@ -93,6 +93,12 @@ export async function PUT(
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential); if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent); if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent); if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) {
if (body[dateField] !== undefined) {
data[dateField] = body[dateField] ? new Date(body[dateField]) : null;
}
}
if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence); if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence);
if (body.messageTopics != null) data.messageTopics = body.messageTopics; if (body.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep; if (body.nextStep != null) data.nextStep = body.nextStep;

View file

@ -97,6 +97,8 @@ type CardData = {
planningCenter: string | null; planningCenter: string | null;
iSaidYesBookSent: boolean; iSaidYesBookSent: boolean;
ftGuestLetterSent: boolean; ftGuestLetterSent: boolean;
firstTimeGuestDate: string | null;
salvationDate: string | null;
ocrStatus: string; ocrStatus: string;
reviewStatus: string; reviewStatus: string;
ocrConfidence: number | null; ocrConfidence: number | null;
@ -189,6 +191,17 @@ export default function CardDetailPage() {
return Boolean(card?.[field]); return Boolean(card?.[field]);
}; };
const getDateValue = (field: keyof CardData): string => {
if (field in edits) {
const v = edits[field];
if (!v) return "";
return String(v).slice(0, 10);
}
const val = card?.[field];
if (!val) return "";
return String(val).slice(0, 10);
};
const setField = (field: string, value: unknown) => { const setField = (field: string, value: unknown) => {
setEdits((prev) => ({ ...prev, [field]: value })); setEdits((prev) => ({ ...prev, [field]: value }));
}; };
@ -505,6 +518,8 @@ export default function CardDetailPage() {
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} /> <Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} />
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} /> <Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} /> <Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} />
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} />
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} /> <BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} /> <BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
</div> </div>
@ -800,6 +815,15 @@ function MultiSelectField({
); );
} }
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function BooleanField({ function BooleanField({
label, label,
value, value,

View file

@ -91,6 +91,8 @@ const MONDAY_MAPPABLE_FIELDS = [
{ field: "planningCenter", label: "Planning Center" }, { field: "planningCenter", label: "Planning Center" },
{ field: "iSaidYesBookSent", label: "I Said Yes Book Sent" }, { field: "iSaidYesBookSent", label: "I Said Yes Book Sent" },
{ field: "ftGuestLetterSent", label: "FT Guest Letter Sent" }, { field: "ftGuestLetterSent", label: "FT Guest Letter Sent" },
{ field: "firstTimeGuestDate", label: "First Time Guest Date" },
{ field: "salvationDate", label: "Salvation Date" },
{ field: "reviewStatus", label: "Review Status" }, { field: "reviewStatus", label: "Review Status" },
] as const; ] as const;

View file

@ -110,6 +110,8 @@ export type ResponseCard = {
planningCenter: string | null; planningCenter: string | null;
iSaidYesBookSent: boolean; iSaidYesBookSent: boolean;
ftGuestLetterSent: boolean; ftGuestLetterSent: boolean;
firstTimeGuestDate: string | null;
salvationDate: string | null;
mondayItemId: string | null; mondayItemId: string | null;
ocrStatus: string; ocrStatus: string;
reviewStatus: string; reviewStatus: string;
@ -447,6 +449,22 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
header: "FT Guest Letter", header: "FT Guest Letter",
cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />, cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />,
}, },
{
accessorKey: "firstTimeGuestDate",
header: "FT Guest Date",
cell: ({ row }) => {
const v = row.original.firstTimeGuestDate;
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
},
},
{
accessorKey: "salvationDate",
header: "Salvation Date",
cell: ({ row }) => {
const v = row.original.salvationDate;
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
},
},
{ {
id: "mondayLinked", id: "mondayLinked",
header: "Monday.com", header: "Monday.com",

View file

@ -90,6 +90,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
{ id: "planningCenter", label: "Planning Center" }, { id: "planningCenter", label: "Planning Center" },
{ id: "iSaidYesBookSent", label: "I Said Yes Book" }, { id: "iSaidYesBookSent", label: "I Said Yes Book" },
{ id: "ftGuestLetterSent", label: "FT Guest Letter" }, { id: "ftGuestLetterSent", label: "FT Guest Letter" },
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
{ id: "salvationDate", label: "Salvation Date" },
{ id: "mondayLinked", label: "Monday.com" }, { id: "mondayLinked", label: "Monday.com" },
], ],
}, },
@ -102,7 +104,7 @@ const DEFAULT_HIDDEN: string[] = [
"prayerRequests", "prayerForTeam", "prayerConfidential", "prayerRequests", "prayerForTeam", "prayerConfidential",
"messageTopics", "nextStep", "campusPreference", "howHeard", "messageTopics", "nextStep", "campusPreference", "howHeard",
"followUp", "notes", "serviceTime", "planningCenter", "followUp", "notes", "serviceTime", "planningCenter",
"iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked", "iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
]; ];
export function getDefaultColumnVisibility(): VisibilityState { export function getDefaultColumnVisibility(): VisibilityState {

View file

@ -249,6 +249,10 @@ function normalizeYear(y: string): string {
function parseToISO(dateStr: string): string | null { function parseToISO(dateStr: string): string | null {
const s = dateStr.replace(/\s+/g, " ").trim(); const s = dateStr.replace(/\s+/g, " ").trim();
// Full ISO datetime e.g. "2026-04-06T00:00:00.000Z"
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})T/);
if (isoFull) return `${isoFull[1]}-${isoFull[2]}-${isoFull[3]}`;
// MM/DD/YYYY or DD/MM/YYYY (all-numeric) // MM/DD/YYYY or DD/MM/YYYY (all-numeric)
const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/); const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/);
if (numParts) { if (numParts) {

View file

@ -114,6 +114,10 @@ export async function processFile(
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0; const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
const visitType = asString(responseData.visitType);
const nextStepArr = surveyData.nextStep ?? [];
const sunday = getPreviousSunday(card.createdAt);
await prisma.responseCard.update({ await prisma.responseCard.update({
where: { id: card.id }, where: { id: card.id },
data: { data: {
@ -122,7 +126,7 @@ export async function processFile(
dateOfBirth: asString(responseData.dateOfBirth), dateOfBirth: asString(responseData.dateOfBirth),
maritalStatus: asString(responseData.maritalStatus), maritalStatus: asString(responseData.maritalStatus),
maritalStatusOther: asString(responseData.maritalStatusOther), maritalStatusOther: asString(responseData.maritalStatusOther),
visitType: asString(responseData.visitType), visitType,
cellPhone: asString(responseData.cellPhone), cellPhone: asString(responseData.cellPhone),
homePhone: asString(responseData.homePhone), homePhone: asString(responseData.homePhone),
email: asString(responseData.email), email: asString(responseData.email),
@ -136,13 +140,15 @@ export async function processFile(
prayerConfidential: asBool(responseData.prayerConfidential), prayerConfidential: asBool(responseData.prayerConfidential),
messageTopics: surveyData.messageTopics ?? [], messageTopics: surveyData.messageTopics ?? [],
messageTopicsOther: asString(surveyData.messageTopicsOther), messageTopicsOther: asString(surveyData.messageTopicsOther),
nextStep: surveyData.nextStep ?? [], nextStep: nextStepArr,
attendanceDuration: asString(surveyData.attendanceDuration), attendanceDuration: asString(surveyData.attendanceDuration),
campusPreference: surveyData.campusPreference ?? [], campusPreference: surveyData.campusPreference ?? [],
campusPreferenceOther: asString(surveyData.campusPreferenceOther), campusPreferenceOther: asString(surveyData.campusPreferenceOther),
howHeard: surveyData.howHeard ?? [], howHeard: surveyData.howHeard ?? [],
howHeardOther: asString(surveyData.howHeardOther), howHeardOther: asString(surveyData.howHeardOther),
serviceAttended: asString(surveyData.serviceAttended), serviceAttended: asString(surveyData.serviceAttended),
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
ocrStatus: "complete", ocrStatus: "complete",
ocrConfidence: Math.round(avgConfidence), ocrConfidence: Math.round(avgConfidence),
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
@ -221,6 +227,10 @@ export async function reprocessCard(cardId: string): Promise<void> {
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0; const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
const visitType = asString(responseData.visitType);
const nextStepArr = surveyData.nextStep ?? [];
const sunday = getPreviousSunday(card.createdAt);
await prisma.responseCard.update({ await prisma.responseCard.update({
where: { id: cardId }, where: { id: cardId },
data: { data: {
@ -229,7 +239,7 @@ export async function reprocessCard(cardId: string): Promise<void> {
dateOfBirth: asString(responseData.dateOfBirth), dateOfBirth: asString(responseData.dateOfBirth),
maritalStatus: asString(responseData.maritalStatus), maritalStatus: asString(responseData.maritalStatus),
maritalStatusOther: asString(responseData.maritalStatusOther), maritalStatusOther: asString(responseData.maritalStatusOther),
visitType: asString(responseData.visitType), visitType,
cellPhone: asString(responseData.cellPhone), cellPhone: asString(responseData.cellPhone),
homePhone: asString(responseData.homePhone), homePhone: asString(responseData.homePhone),
email: asString(responseData.email), email: asString(responseData.email),
@ -243,13 +253,15 @@ export async function reprocessCard(cardId: string): Promise<void> {
prayerConfidential: asBool(responseData.prayerConfidential), prayerConfidential: asBool(responseData.prayerConfidential),
messageTopics: surveyData.messageTopics ?? [], messageTopics: surveyData.messageTopics ?? [],
messageTopicsOther: asString(surveyData.messageTopicsOther), messageTopicsOther: asString(surveyData.messageTopicsOther),
nextStep: surveyData.nextStep ?? [], nextStep: nextStepArr,
attendanceDuration: asString(surveyData.attendanceDuration), attendanceDuration: asString(surveyData.attendanceDuration),
campusPreference: surveyData.campusPreference ?? [], campusPreference: surveyData.campusPreference ?? [],
campusPreferenceOther: asString(surveyData.campusPreferenceOther), campusPreferenceOther: asString(surveyData.campusPreferenceOther),
howHeard: surveyData.howHeard ?? [], howHeard: surveyData.howHeard ?? [],
howHeardOther: asString(surveyData.howHeardOther), howHeardOther: asString(surveyData.howHeardOther),
serviceAttended: asString(surveyData.serviceAttended), serviceAttended: asString(surveyData.serviceAttended),
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
ocrStatus: "complete", ocrStatus: "complete",
ocrConfidence: Math.round(avgConfidence), ocrConfidence: Math.round(avgConfidence),
ocrError: null, ocrError: null,
@ -301,3 +313,27 @@ function asString(v: unknown): string | null {
function asBool(v: unknown): boolean { function asBool(v: unknown): boolean {
return v === true; return v === true;
} }
function getPreviousSunday(from: Date): Date {
const d = new Date(from);
const day = d.getDay();
const diff = day === 0 ? 0 : day;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
}
function isFirstTimeGuest(visitType: string | null): boolean {
if (!visitType) return false;
const lower = visitType.toLowerCase();
return lower.includes("first") || lower.includes("second") || lower.includes("guest");
}
function hasBaptism(nextStep: unknown): boolean {
if (!Array.isArray(nextStep)) return false;
return nextStep.some((s) => {
if (typeof s !== "string") return false;
const lower = s.toLowerCase();
return lower.includes("baptism") || lower.includes("expressing my faith");
});
}