echos-ocr/src/app/api/cards/[id]/route.ts
Randall Stillwell 7aedae02fc Keep card name and contact fields in sync after edits
Two bugs caused the cards list and integrations to show stale data
after a user edited a card in the detail view:

1. ResponseCard.name is a denormalized display string set only at
   OCR / survey-submit time. Editing firstName or lastName never
   recomputed it, so the table header and Name column kept the old
   value. PUT /api/cards/[id] now recomputes name from first + last
   whenever either changes (unless the caller passed an explicit
   name). The detail page header reads from in-flight edits so the
   title updates live as the user types.

2. The default form template marked only firstName/lastName as
   isCore. Every other field (email, cellPhone, address, etc.) was
   non-core, so dynamic-field edits landed in ResponseCard.fieldData
   JSON and never touched the top-level columns the list view,
   search, CSV export, and integrations read from. The PUT route
   now promotes any fieldData keys that match canonical columns up
   to those columns; the default template marks all canonical
   fields as isCore so new orgs avoid the problem in the first
   place.

Adds scripts/backfill-core-fields.ts (dry-run by default; pass
--apply to commit) to flip existing FormField rows to isCore = true
where the key matches a canonical column and to promote any
existing fieldData values into empty top-level columns + recompute
stale name values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 08:30:27 -05:00

272 lines
9.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/storage";
import { fireIntegrationEvent } from "@/lib/integrations";
import { logActivity, diffCardFields } from "@/lib/activity-log";
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
import { can } from "@/lib/permissions";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
include: {
formTemplate: {
include: { fields: { orderBy: { sortOrder: "asc" } } },
},
},
});
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const frontImageUrl = card.frontImagePath
? `/api/images/${card.frontImagePath}`
: null;
const backImageUrl = card.backImagePath
? `/api/images/${card.backImagePath}`
: null;
return NextResponse.json({
...card,
frontImageUrl,
backImageUrl,
});
} catch (error) {
return handleApiError(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg();
const user = session.user;
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
// Editors and above can edit any card; reviewers can edit cards assigned
// to them; viewers cannot edit at all.
const canEditAnyCard = can(user.role, "cards.edit");
const canReviewAssigned =
can(user.role, "cards.review") && card.assignedToId === user.id;
if (!canEditAnyCard && !canReviewAssigned) {
return NextResponse.json(
{
error: canEditAnyCard === false && user.role === "reviewer"
? "You can only edit cards assigned to you"
: "You don't have permission to edit cards",
},
{ status: 403 }
);
}
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = [
"name",
"firstName",
"lastName",
"gender",
"dateOfBirth",
"maritalStatus",
"maritalStatusOther",
"visitType",
"cellPhone",
"homePhone",
"email",
"address",
"aptNumber",
"city",
"state",
"zip",
"prayerRequests",
"messageTopicsOther",
"attendanceDuration",
"campusPreferenceOther",
"howHeardOther",
"serviceAttended",
"followUp",
"notes",
"serviceTime",
"planningCenter",
"ocrStatus",
"reviewStatus",
"ocrError",
"reviewNotes",
];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
}
if (body.prayerForTeam != null) data.prayerForTeam = Boolean(body.prayerForTeam);
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
for (const dateField of ["firstTimeGuestDate", "salvationDate", "assignedAt", "reviewedAt"] 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.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep;
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
if (body.fieldData !== undefined) data.fieldData = body.fieldData;
// Promote any fieldData keys that match canonical top-level ResponseCard
// columns up to those columns. Form templates may mark contact/address
// fields as non-core, which means user edits land in `fieldData` only —
// promotion keeps the top-level columns (used by the list view, search,
// integrations, CSV export) in sync. Explicit body fields always win.
if (body.fieldData && typeof body.fieldData === "object") {
const fd = body.fieldData as Record<string, unknown>;
const PROMOTABLE_STRING = new Set([
"firstName", "lastName", "name",
"gender", "dateOfBirth",
"maritalStatus", "maritalStatusOther", "visitType",
"cellPhone", "homePhone", "email",
"address", "aptNumber", "city", "state", "zip",
"prayerRequests", "messageTopicsOther", "attendanceDuration",
"campusPreferenceOther", "howHeardOther", "serviceAttended",
"followUp", "notes", "serviceTime", "planningCenter",
]);
const PROMOTABLE_BOOL = new Set([
"prayerForTeam", "prayerConfidential",
"iSaidYesBookSent", "ftGuestLetterSent",
]);
const PROMOTABLE_ARRAY = new Set([
"messageTopics", "nextStep", "campusPreference", "howHeard",
]);
const PROMOTABLE_DATE = new Set([
"firstTimeGuestDate", "salvationDate",
]);
for (const [k, v] of Object.entries(fd)) {
if (k in data) continue;
if (PROMOTABLE_STRING.has(k)) {
data[k] = v == null || v === "" ? null : String(v);
} else if (PROMOTABLE_BOOL.has(k)) {
data[k] = Boolean(v);
} else if (PROMOTABLE_ARRAY.has(k)) {
data[k] = Array.isArray(v) ? v : null;
} else if (PROMOTABLE_DATE.has(k)) {
data[k] = v ? new Date(String(v)) : null;
}
}
}
// Keep the denormalized `name` field in sync with firstName/lastName.
// `name` is read by the list view, search, sort, integrations, and CSV
// export, so whenever first/last changes (and the caller didn't already
// pass an explicit `name`), recompute it.
if (("firstName" in data || "lastName" in data) && !("name" in data)) {
const nextFirst =
"firstName" in data ? (data.firstName as string | null) : card.firstName;
const nextLast =
"lastName" in data ? (data.lastName as string | null) : card.lastName;
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
data.name = combined || null;
}
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
if (body[assignField] !== undefined) {
data[assignField] = body[assignField] || null;
}
}
if (body.reviewStatus === "in_review" && card.reviewStatus === "assigned") {
data.reviewStatus = "in_review";
}
if (body.reviewStatus === "reviewed") {
data.reviewedById = user.id;
data.reviewedAt = new Date();
}
const oldCard = card as unknown as Record<string, unknown>;
const updated = await prisma.responseCard.update({
where: { id },
data: data as Parameters<typeof prisma.responseCard.update>[0]["data"],
});
const newCard = updated as unknown as Record<string, unknown>;
const changes = diffCardFields(oldCard, newCard);
if (changes.length > 0) {
logActivity(
id,
"manual_edit",
"user",
`${changes.length} field(s) updated manually`,
changes,
user.id
).catch(() => {});
}
const oldStatus = card.reviewStatus;
const newStatus = updated.reviewStatus;
if (oldStatus !== newStatus) {
if (newStatus === "reviewed") {
fireIntegrationEvent("card_reviewed", id, { oldCard }).catch(() => {});
} else if (newStatus === "exported") {
fireIntegrationEvent("card_exported", id, { oldCard }).catch(() => {});
}
}
return NextResponse.json(updated);
} catch (error) {
return handleApiError(error);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await requireApiAuthWithOrg("cards.delete");
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card || card.organizationId !== session.user.orgId) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
fireIntegrationEvent("card_deleted", id).catch(() => {});
const deletePromises: Promise<void>[] = [];
if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath));
if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath));
await Promise.allSettled(deletePromises);
await prisma.responseCard.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
return handleApiError(error);
}
}