echos-ocr/src/app/api/cards/route.ts
Randall Stillwell 0ab9932599 Add multi-user card review workflow with role-based access
- Add User model synced from Authentik headers with admin/reviewer/viewer roles
- Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard
- Add userId tracking to ActivityLog and Notification models
- Create auth.ts with getOrCreateUser() and role mapping from Authentik groups
- Create /api/users endpoint and /api/cards/assign batch assignment endpoint
- Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only)
- Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete
- Add "My Cards" stat card, Assigned To filter, and assignment columns to table
- Add Assign button with user picker to batch selection toolbar (admin only)
- Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign
- Make all field components accept readOnly prop for role-based editing
- Gate settings page behind admin role
- Add userId to stats API for per-user card counts
- Expose dbUser and role through UserProfileProvider context

Made-with: Cursor
2026-04-11 00:00:11 -05:00

143 lines
4.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const search = searchParams.get("search")?.trim() || undefined;
const ocrStatus = searchParams.get("ocrStatus") || undefined;
const reviewStatus = searchParams.get("reviewStatus") || undefined;
const attendanceDuration = searchParams.get("attendanceDuration") || undefined;
const visitType = searchParams.get("visitType") || undefined;
const serviceAttended = searchParams.get("serviceAttended") || undefined;
const assignedToId = searchParams.get("assignedToId") || undefined;
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc";
const user = await getOrCreateUser(request.headers);
const validSortFields = [
"createdAt",
"updatedAt",
"name",
"ocrStatus",
"reviewStatus",
"attendanceDuration",
"visitType",
"serviceAttended",
"assignedToId",
];
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc";
const where: Record<string, unknown> = {};
if (ocrStatus) where.ocrStatus = ocrStatus;
if (reviewStatus) where.reviewStatus = reviewStatus;
if (attendanceDuration) where.attendanceDuration = attendanceDuration;
if (visitType) where.visitType = visitType;
if (serviceAttended) where.serviceAttended = serviceAttended;
if (assignedToId === "me" && user) {
where.assignedToId = user.id;
} else if (assignedToId === "unassigned") {
where.assignedToId = null;
} else if (assignedToId) {
where.assignedToId = assignedToId;
}
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
{ cellPhone: { contains: search, mode: "insensitive" } },
{ homePhone: { contains: search, mode: "insensitive" } },
{ address: { contains: search, mode: "insensitive" } },
{ city: { contains: search, mode: "insensitive" } },
{ state: { contains: search, mode: "insensitive" } },
{ zip: { contains: search, mode: "insensitive" } },
];
}
const [cards, total] = await Promise.all([
prisma.responseCard.findMany({
where,
orderBy: { [orderByField]: order },
skip: (page - 1) * limit,
take: limit,
}),
prisma.responseCard.count({ where }),
]);
return NextResponse.json({ cards, total, page, limit });
} catch (error) {
console.error("[cards GET]", error);
return NextResponse.json(
{ error: "Failed to fetch cards" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = [
"name",
"gender",
"dateOfBirth",
"maritalStatus",
"maritalStatusOther",
"visitType",
"cellPhone",
"homePhone",
"email",
"address",
"aptNumber",
"city",
"state",
"zip",
"prayerRequests",
"messageTopicsOther",
"attendanceDuration",
"campusPreferenceOther",
"howHeardOther",
"serviceAttended",
"sourceFile",
"frontImagePath",
"backImagePath",
"ocrStatus",
"reviewStatus",
"ocrError",
];
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.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;
const card = await prisma.responseCard.create({
data: data as Parameters<typeof prisma.responseCard.create>[0]["data"],
});
return NextResponse.json(card, { status: 201 });
} catch (error) {
console.error("[cards POST]", error);
return NextResponse.json(
{ error: "Failed to create card" },
{ status: 500 }
);
}
}