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 = {}; 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; 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; const updated = await prisma.responseCard.update({ where: { id }, data: data as Parameters[0]["data"], }); const newCard = updated as unknown as Record; 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[] = []; 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); } }