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
This commit is contained in:
Randall Stillwell 2026-04-11 00:00:11 -05:00
parent aacf9c776b
commit 0ab9932599
20 changed files with 775 additions and 118 deletions

View file

@ -7,6 +7,18 @@ datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
authentikUid String @unique
username String
displayName String
email String
avatarUrl String @default("")
role String @default("viewer")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ResponseCard {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -52,6 +64,14 @@ model ResponseCard {
firstTimeGuestDate DateTime?
salvationDate DateTime?
// Assignment / Review workflow
assignedToId String?
assignedById String?
assignedAt DateTime?
reviewedById String?
reviewedAt DateTime?
reviewNotes String?
// Meta
sourceFile String?
frontImagePath String?
@ -69,6 +89,8 @@ model ResponseCard {
@@index([name])
@@index([createdAt])
@@index([mondayItemId])
@@index([assignedToId])
@@index([reviewedById])
}
model ProcessingJob {
@ -130,6 +152,7 @@ model ActivityLog {
source String
summary String
changes Json?
userId String?
@@index([cardId, createdAt])
}
@ -145,7 +168,9 @@ model Notification {
cardId String?
actionUrl String?
meta Json?
userId String?
@@index([read, dismissed, createdAt])
@@index([cardId])
@@index([userId])
}

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getOrCreateUser, type AppUser } from "@/lib/auth";
export type AuthentikUser = {
username: string;
@ -9,6 +10,8 @@ export type AuthentikUser = {
avatar: string;
};
export type { AppUser };
/**
* Reads Authentik forward-auth headers injected by Traefik and optionally
* enriches with avatar from the Authentik API.
@ -67,5 +70,7 @@ export async function GET(req: NextRequest) {
}
}
return NextResponse.json({ authenticated: true, user });
const dbUser = await getOrCreateUser(req.headers);
return NextResponse.json({ authenticated: true, user, dbUser });
}

View file

@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { deleteObject } from "@/lib/minio";
import { fireIntegrationEvent } from "@/lib/integrations";
import { logActivity, diffCardFields } from "@/lib/activity-log";
import { getOrCreateUser, RoleError } from "@/lib/auth";
export async function GET(
_request: NextRequest,
@ -44,6 +45,8 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUser(request.headers);
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
@ -53,6 +56,15 @@ export async function PUT(
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
if (user) {
if (user.role === "viewer") {
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
}
if (user.role === "reviewer" && card.assignedToId !== user.id) {
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
}
}
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
@ -84,6 +96,7 @@ export async function PUT(
"ocrStatus",
"reviewStatus",
"ocrError",
"reviewNotes",
];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
@ -94,7 +107,7 @@ export async function PUT(
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) {
for (const dateField of ["firstTimeGuestDate", "salvationDate", "assignedAt", "reviewedAt"] as const) {
if (body[dateField] !== undefined) {
data[dateField] = body[dateField] ? new Date(body[dateField]) : null;
}
@ -106,6 +119,21 @@ export async function PUT(
if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
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" && user) {
data.reviewedById = user.id;
data.reviewedAt = new Date();
}
const oldCard = card as unknown as Record<string, unknown>;
const updated = await prisma.responseCard.update({
@ -116,7 +144,14 @@ export async function PUT(
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).catch(() => {});
logActivity(
id,
"manual_edit",
"user",
`${changes.length} field(s) updated manually`,
changes,
user?.id
).catch(() => {});
}
const oldStatus = card.reviewStatus;
@ -131,6 +166,9 @@ export async function PUT(
return NextResponse.json(updated);
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[cards/[id] PUT]", error);
return NextResponse.json(
{ error: "Failed to update card" },
@ -140,10 +178,15 @@ export async function PUT(
}
export async function DELETE(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUser(request.headers);
if (user && user.role !== "admin") {
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
}
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },

View file

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
import { createNotification } from "@/lib/notifications";
import { logActivity } from "@/lib/activity-log";
export async function POST(req: NextRequest) {
try {
const user = await getOrCreateUser(req.headers);
requireRole(user, "admin");
const body = await req.json();
const { cardIds, assignToUserId } = body as {
cardIds: string[];
assignToUserId: string;
};
if (!cardIds?.length || !assignToUserId) {
return NextResponse.json(
{ error: "cardIds and assignToUserId are required" },
{ status: 400 }
);
}
const assignee = await prisma.user.findUnique({
where: { id: assignToUserId },
});
if (!assignee) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
await prisma.responseCard.updateMany({
where: { id: { in: cardIds } },
data: {
assignedToId: assignToUserId,
assignedById: user!.id,
assignedAt: new Date(),
reviewStatus: "assigned",
},
});
for (const cardId of cardIds) {
logActivity(
cardId,
"assignment",
"user",
`Assigned to ${assignee.displayName} by ${user!.displayName}`,
[{ field: "assignedToId", from: null, to: assignToUserId }],
user!.id
).catch(() => {});
}
await createNotification({
type: "card_assigned",
title: "Cards Assigned to You",
message: `${cardIds.length} card(s) assigned by ${user!.displayName}`,
actionUrl: "/?assignedToId=me",
userId: assignToUserId,
});
return NextResponse.json({
ok: true,
assigned: cardIds.length,
assignee: assignee.displayName,
});
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
console.error("[cards/assign POST]", error);
return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
}
}

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
@ -12,9 +13,12 @@ export async function GET(request: NextRequest) {
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",
@ -24,6 +28,7 @@ export async function GET(request: NextRequest) {
"attendanceDuration",
"visitType",
"serviceAttended",
"assignedToId",
];
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc";
@ -36,6 +41,14 @@ export async function GET(request: NextRequest) {
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" } },

View file

@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
export async function GET() {
export async function GET(request: NextRequest) {
try {
const [total, byOcrStatus, byReviewStatus] = await Promise.all([
const user = await getOrCreateUser(request.headers);
const userId = request.nextUrl.searchParams.get("userId") || undefined;
const effectiveUserId = userId === "me" && user ? user.id : userId;
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
prisma.responseCard.count(),
prisma.responseCard.groupBy({
by: ["ocrStatus"],
@ -13,6 +18,15 @@ export async function GET() {
by: ["reviewStatus"],
_count: { id: true },
}),
effectiveUserId
? prisma.responseCard.count({
where: { assignedToId: effectiveUserId },
})
: user
? prisma.responseCard.count({
where: { assignedToId: user.id },
})
: Promise.resolve(0),
]);
const ocrStatusCounts = Object.fromEntries(
@ -26,6 +40,7 @@ export async function GET() {
total,
byOcrStatus: ocrStatusCounts,
byReviewStatus: reviewStatusCounts,
myCards,
});
} catch (error) {
console.error("[stats GET]", error);

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getOrCreateUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
try {
const user = await getOrCreateUser(req.headers);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const role = req.nextUrl.searchParams.get("role") || undefined;
const where: Record<string, unknown> = {};
if (role) where.role = role;
const users = await prisma.user.findMany({
where,
select: {
id: true,
username: true,
displayName: true,
email: true,
avatarUrl: true,
role: true,
},
orderBy: { displayName: "asc" },
});
return NextResponse.json({ users });
} catch (error) {
console.error("[users GET]", error);
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
}
}

View file

@ -46,6 +46,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useUserProfile } from "@/lib/user-profile";
const MESSAGE_TOPIC_OPTIONS = [
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
@ -99,6 +100,12 @@ type CardData = {
ftGuestLetterSent: boolean;
firstTimeGuestDate: string | null;
salvationDate: string | null;
assignedToId: string | null;
assignedById: string | null;
assignedAt: string | null;
reviewedById: string | null;
reviewedAt: string | null;
reviewNotes: string | null;
ocrStatus: string;
reviewStatus: string;
ocrConfidence: number | null;
@ -118,10 +125,16 @@ type ActivityEntry = {
changes: { field: string; from: string | null; to: string | null }[] | null;
};
type AssignableUser = { id: string; displayName: string };
export default function CardDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const { role, dbUser } = useUserProfile();
const isAdmin = role === "admin";
const isReviewer = role === "reviewer";
const isViewer = role === "viewer";
const [card, setCard] = React.useState<CardData | null>(null);
const [loading, setLoading] = React.useState(true);
@ -134,6 +147,12 @@ export default function CardDetailPage() {
const [activityLoading, setActivityLoading] = React.useState(false);
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
const [pushingToMonday, setPushingToMonday] = React.useState(false);
const [users, setUsers] = React.useState<AssignableUser[]>([]);
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
const isAssignedToMe = card?.assignedToId && dbUser?.id === card.assignedToId;
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe);
const fetchCard = React.useCallback(async () => {
setLoading(true);
@ -154,6 +173,34 @@ export default function CardDetailPage() {
fetchCard();
}, [fetchCard]);
React.useEffect(() => {
if (!isAdmin) return;
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {});
}, [isAdmin]);
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const assignedToId = params.get("assignedToId") || (isReviewer ? "me" : undefined);
const apiParams = new URLSearchParams();
apiParams.set("limit", "200");
if (assignedToId) apiParams.set("assignedToId", assignedToId);
fetch(`/api/cards?${apiParams.toString()}`)
.then((r) => r.json())
.then((data) => {
const ids = (data.cards || []).map((c: { id: string }) => c.id);
const idx = ids.indexOf(id);
setPrevNextIds({
prev: idx > 0 ? ids[idx - 1] : null,
next: idx >= 0 && idx < ids.length - 1 ? ids[idx + 1] : null,
});
})
.catch(() => {});
}, [id, isReviewer]);
React.useEffect(() => {
if (card?.ocrStatus !== "processing") return;
const interval = setInterval(async () => {
@ -210,21 +257,70 @@ export default function CardDetailPage() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
try {
const payload: Record<string, unknown> = { ...edits };
if (card?.reviewStatus === "assigned") {
payload.reviewStatus = "in_review";
}
const res = await fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(edits),
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error();
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to save");
}
toast.success("Card updated");
await fetchCard();
} catch {
toast.error("Failed to save");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
const handleMarkComplete = async () => {
setSaving(true);
try {
const payload: Record<string, unknown> = {
...edits,
reviewStatus: "reviewed",
};
const res = await fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to complete review");
}
toast.success("Review complete — card will sync to Monday.com");
await fetchCard();
setEdits({});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to complete review");
} finally {
setSaving(false);
}
};
const handleReassign = async (userId: string) => {
try {
const res = await fetch("/api/cards/assign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cardIds: [id], assignToUserId: userId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
toast.success(`Reassigned to ${data.assignee}`);
fetchCard();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Reassignment failed");
}
};
const handleMarkReviewed = async () => {
await fetch(`/api/cards/${id}`, {
method: "PUT",
@ -352,9 +448,26 @@ export default function CardDetailPage() {
</div>
</div>
{card.assignedToId && (
<div className="flex items-center gap-2 rounded-xl border border-purple-300 bg-purple-500/10 px-4 py-2.5 dark:border-purple-800">
<User className="size-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm">
Assigned to <strong>{card.assignedToId === dbUser?.id ? "you" : (card.assignedToId)}</strong>
{card.assignedAt && (
<> on {new Date(card.assignedAt).toLocaleDateString()}</>
)}
</span>
{card.reviewedById && card.reviewedAt && (
<span className="text-sm text-muted-foreground ml-2">
· Reviewed {new Date(card.reviewedAt).toLocaleDateString()}
</span>
)}
</div>
)}
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<div className="flex flex-wrap gap-2">
{ocrStatus !== "processing" && (
{isAdmin && ocrStatus !== "processing" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
{reprocessing ? (
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
@ -368,6 +481,7 @@ export default function CardDetailPage() {
<Loader2 className="size-3.5 animate-spin" /> Processing...
</Badge>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
@ -381,17 +495,30 @@ export default function CardDetailPage() {
<><LayoutGrid className="mr-1 size-4" /> {card.mondayItemId ? "Update Monday" : "Push to Monday"}</>
)}
</Button>
{reviewStatus !== "reviewed" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
<Check className="mr-1 size-4" /> Mark Reviewed
)}
{isAdmin && users.length > 0 && (
<Select onValueChange={(v: string | null) => { if (v) handleReassign(v); }}>
<SelectTrigger className="w-[160px] rounded-xl h-8 text-sm">
<SelectValue placeholder="Reassign..." />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.displayName}</SelectItem>
))}
</SelectContent>
</Select>
)}
{canMarkComplete && reviewStatus !== "reviewed" && reviewStatus !== "exported" && (
<Button size="sm" className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleMarkComplete} disabled={saving}>
<Check className="mr-1 size-4" /> Mark Complete
</Button>
)}
{reviewStatus !== "exported" && (
{isAdmin && reviewStatus !== "exported" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
<Download className="mr-1 size-4" /> Export
</Button>
)}
{hasEdits && (
{hasEdits && canEdit && (
<Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
@ -434,22 +561,22 @@ export default function CardDetailPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} />
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} />
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} />
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} />
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} />
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} />
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} readOnly={!canEdit} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} readOnly={!canEdit} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} readOnly={!canEdit} />
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} readOnly={!canEdit} />
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} readOnly={!canEdit} />
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} readOnly={!canEdit} />
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} readOnly={!canEdit} />
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} />
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} readOnly={!canEdit} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} readOnly={!canEdit} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} readOnly={!canEdit} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} readOnly={!canEdit} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} readOnly={!canEdit} />
</div>
<Separator className="opacity-50" />
<div>
@ -458,11 +585,13 @@ export default function CardDetailPage() {
value={getValue("prayerRequests") || ""}
onChange={(e) => setField("prayerRequests", e.target.value)}
rows={3}
readOnly={!canEdit}
className={!canEdit ? "opacity-70 cursor-default" : ""}
/>
</div>
<div className="flex flex-wrap gap-6">
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} />
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} />
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} readOnly={!canEdit} />
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} readOnly={!canEdit} />
</div>
</CardContent>
</Card>
@ -481,27 +610,31 @@ export default function CardDetailPage() {
value={getArrayValue("messageTopics")}
options={MESSAGE_TOPIC_OPTIONS}
onChange={(v) => setField("messageTopics", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="Next Steps"
value={getArrayValue("nextStep")}
options={NEXT_STEP_OPTIONS}
onChange={(v) => setField("nextStep", v)}
readOnly={!canEdit}
/>
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} />
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} readOnly={!canEdit} />
<MultiSelectField
label="Campus Preference"
value={getArrayValue("campusPreference")}
options={CAMPUS_OPTIONS}
onChange={(v) => setField("campusPreference", v)}
readOnly={!canEdit}
/>
<MultiSelectField
label="How Heard"
value={getArrayValue("howHeard")}
options={HOW_HEARD_OPTIONS}
onChange={(v) => setField("howHeard", v)}
readOnly={!canEdit}
/>
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} />
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} readOnly={!canEdit} />
</div>
</CardContent>
</Card>
@ -515,15 +648,15 @@ export default function CardDetailPage() {
</CardHeader>
<CardContent>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<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="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="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} readOnly={!canEdit} />
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} readOnly={!canEdit} />
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} readOnly={!canEdit} />
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} readOnly={!canEdit} />
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} readOnly={!canEdit} />
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} readOnly={!canEdit} />
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} readOnly={!canEdit} />
</div>
{getValue("notes") && (
{(getValue("notes") || canEdit) && (
<>
<Separator className="my-4 opacity-50" />
<div>
@ -532,6 +665,8 @@ export default function CardDetailPage() {
value={getValue("notes")}
onChange={(e) => setField("notes", e.target.value)}
rows={3}
readOnly={!canEdit}
className={!canEdit ? "opacity-70 cursor-default" : ""}
/>
</div>
</>
@ -651,10 +786,22 @@ export default function CardDetailPage() {
<ArrowLeft className="mr-1 size-4" /> All Cards
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="rounded-xl" disabled>
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={!prevNextIds.prev}
onClick={() => prevNextIds.prev && router.push(`/cards/${prevNextIds.prev}`)}
>
<ArrowLeft className="mr-1 size-4" /> Previous
</Button>
<Button variant="outline" size="sm" className="rounded-xl" disabled>
<Button
variant="outline"
size="sm"
className="rounded-xl"
disabled={!prevNextIds.next}
onClick={() => prevNextIds.next && router.push(`/cards/${prevNextIds.next}`)}
>
Next <ArrowRight className="ml-1 size-4" />
</Button>
</div>
@ -728,11 +875,11 @@ function ImagePanel({ label, url }: { label: string; url: string | null }) {
);
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
function Field({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Input value={value || ""} onChange={(e) => onChange(e.target.value)} />
<Input value={value || ""} onChange={(e) => onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
</div>
);
}
@ -749,7 +896,15 @@ function formatTimeAgo(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
function SelectField({ label, value, options, onChange, readOnly }: { label: string; value: string; options: string[]; onChange: (v: string) => void; readOnly?: boolean }) {
if (readOnly) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Input value={value || "—"} readOnly className="opacity-70 cursor-default" />
</div>
);
}
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
@ -773,15 +928,18 @@ function MultiSelectField({
value,
options,
onChange,
readOnly,
}: {
label: string;
value: string[];
options: string[];
onChange: (v: string[]) => void;
readOnly?: boolean;
}) {
const selected = new Set(value);
const toggle = (opt: string) => {
if (readOnly) return;
const next = new Set(selected);
if (next.has(opt)) next.delete(opt);
else next.add(opt);
@ -799,11 +957,13 @@ function MultiSelectField({
key={opt}
type="button"
onClick={() => toggle(opt)}
disabled={readOnly}
className={cn(
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
isOn
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground",
readOnly && "cursor-default opacity-70"
)}
>
{opt}
@ -815,11 +975,11 @@ function MultiSelectField({
);
}
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
function DateField({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) {
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)} />
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
</div>
);
}
@ -828,15 +988,17 @@ function BooleanField({
label,
value,
onChange,
readOnly,
}: {
label: string;
value: boolean;
onChange: (v: boolean) => void;
readOnly?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<Switch checked={value} onCheckedChange={onChange} size="sm" />
<Label className="text-sm cursor-pointer" onClick={() => onChange(!value)}>
<Switch checked={value} onCheckedChange={readOnly ? undefined : onChange} size="sm" disabled={readOnly} />
<Label className={cn("text-sm", readOnly ? "cursor-default opacity-70" : "cursor-pointer")} onClick={readOnly ? undefined : () => onChange(!value)}>
{label}
</Label>
</div>

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTheme } from "next-themes";
import {
@ -26,6 +27,7 @@ import {
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { useUserProfile } from "@/lib/user-profile";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -153,6 +155,15 @@ function loadNotificationPrefs(): NotificationPrefs {
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const { role, loading: userLoading } = useUserProfile();
const settingsRouter = useRouter();
React.useEffect(() => {
if (!userLoading && role !== "admin") {
settingsRouter.replace("/");
toast.error("Settings are restricted to admins");
}
}, [role, userLoading, settingsRouter]);
const [settings, setSettings] = React.useState<SettingsData>({
ollamaUrl: "",

View file

@ -113,11 +113,19 @@ export type ResponseCard = {
firstTimeGuestDate: string | null;
salvationDate: string | null;
mondayItemId: string | null;
assignedToId: string | null;
assignedById: string | null;
assignedAt: string | null;
reviewedById: string | null;
reviewedAt: string | null;
reviewNotes: string | null;
ocrStatus: string;
reviewStatus: string;
ocrConfidence: number | null;
frontImageUrl: string | null;
backImageUrl: string | null;
assignedToName?: string | null;
reviewedByName?: string | null;
};
export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [
@ -152,6 +160,10 @@ const ocrStatusVariant: Record<string, string> = {
const reviewStatusVariant: Record<string, string> = {
unreviewed:
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
assigned:
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
in_review:
"bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
reviewed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
exported: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
};
@ -478,6 +490,26 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
),
enableSorting: false,
},
{
id: "assignedTo",
header: "Assigned To",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.assignedToName ?? "—"}
</span>
),
enableSorting: false,
},
{
id: "reviewedBy",
header: "Reviewed By",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.reviewedByName ?? "—"}
</span>
),
enableSorting: false,
},
{
accessorKey: "ocrStatus",
header: ({ column }) => (

View file

@ -10,6 +10,7 @@ import { DataTable, getDefaultColumnVisibility, ALL_TOGGLEABLE_COLUMNS } from ".
import { SelectionToolbar } from "./selection-toolbar";
import { UploadModal, type UploadingFile } from "./upload-modal";
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
import { useUserProfile } from "@/lib/user-profile";
const VISIT_TYPE_OPTIONS = [
"First/Second Time Guest",
@ -29,6 +30,8 @@ const SERVICE_OPTIONS = ["A", "B", "C", "D"];
export function DashboardContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { role } = useUserProfile();
const isAdmin = role === "admin";
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
@ -102,6 +105,7 @@ export function DashboardContent() {
params.delete("ocrStatus");
params.delete("reviewStatus");
params.delete("assignedToId");
if (filter === "complete") {
params.set("ocrStatus", "complete");
@ -109,6 +113,8 @@ export function DashboardContent() {
params.set("ocrStatus", "error");
} else if (filter === "unreviewed") {
params.set("reviewStatus", "unreviewed");
} else if (filter === "my_cards") {
params.set("assignedToId", "me");
}
params.set("page", "1");
@ -201,6 +207,26 @@ export function DashboardContent() {
}
};
const handleAssign = async (ids: string[], userId: string) => {
try {
const res = await fetch("/api/cards/assign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cardIds: ids, assignToUserId: userId }),
});
const result = await res.json();
if (!res.ok) {
toast.error(result.error || "Assignment failed");
return;
}
toast.success(`Assigned ${result.assigned} card(s) to ${result.assignee}`);
setSelectedIds([]);
fetchCards();
} catch {
toast.error("Failed to assign cards");
}
};
const handleExportCsv = () => {
if (data.length === 0) {
toast.error("No data to export");
@ -245,7 +271,8 @@ export function DashboardContent() {
() =>
createColumns({
onViewDetails: (card) => router.push(`/cards/${card.id}`),
onMarkReviewed: async (card) => {
onMarkReviewed: isAdmin
? async (card) => {
await fetch(`/api/cards/${card.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@ -253,8 +280,10 @@ export function DashboardContent() {
});
toast.success("Marked as reviewed");
fetchCards();
},
onReprocess: async (card) => {
}
: undefined,
onReprocess: isAdmin
? async (card) => {
try {
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
method: "POST",
@ -270,14 +299,17 @@ export function DashboardContent() {
err instanceof Error ? err.message : "Failed to start reprocessing"
);
}
},
onDelete: async (card) => {
}
: undefined,
onDelete: isAdmin
? async (card) => {
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
toast.success("Card deleted");
fetchCards();
},
}
: undefined,
}),
[router, fetchCards]
[router, fetchCards, isAdmin]
);
const handleUploadStart = (files: UploadingFile[]) => {
@ -421,9 +453,10 @@ export function DashboardContent() {
attendanceDurationOptions={ATTENDANCE_OPTIONS}
serviceAttendedOptions={SERVICE_OPTIONS}
onExportCsv={handleExportCsv}
onUploadClick={openUpload}
onUploadClick={isAdmin ? openUpload : undefined}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
showAssignedToFilter
/>
{/* Data table */}
@ -450,11 +483,12 @@ export function DashboardContent() {
<SelectionToolbar
selectedIds={selectedIds}
selectedRows={selectedRows}
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onReprocess={handleBulkReprocess}
onSyncMonday={handleBulkSyncMonday}
onDelete={(ids) => handleBulkAction(ids, "delete")}
onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined}
onReprocess={isAdmin ? handleBulkReprocess : undefined}
onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined}
onAssign={isAdmin ? handleAssign : undefined}
onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined}
onClear={() => setSelectedIds([])}
/>

View file

@ -93,6 +93,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
{ id: "salvationDate", label: "Salvation Date" },
{ id: "mondayLinked", label: "Monday.com" },
{ id: "assignedTo", label: "Assigned To" },
{ id: "reviewedBy", label: "Reviewed By" },
],
},
];
@ -105,6 +107,7 @@ const DEFAULT_HIDDEN: string[] = [
"messageTopics", "nextStep", "campusPreference", "howHeard",
"followUp", "notes", "serviceTime", "planningCenter",
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
"assignedTo", "reviewedBy",
];
export function getDefaultColumnVisibility(): VisibilityState {

View file

@ -27,7 +27,7 @@ const COLUMN_GROUPS: { label: string; ids: string[] }[] = [
{ label: "Personal", ids: ["homePhone", "gender", "dateOfBirth", "maritalStatus", "address", "zip"] },
{ label: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] },
{ label: "Prayer", ids: ["prayerRequests", "prayerForTeam", "prayerConfidential"] },
{ label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked"] },
{ label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked", "assignedTo", "reviewedBy"] },
];
const COL_LABELS: Record<string, string> = {};
@ -35,6 +35,8 @@ for (const col of ALL_TOGGLEABLE_COLUMNS) {
COL_LABELS[col.id] = col.label;
}
type UserOption = { id: string; displayName: string };
export type FiltersProps = {
search?: string;
visitType?: string;
@ -47,6 +49,7 @@ export type FiltersProps = {
onUploadClick?: () => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
showAssignedToFilter?: boolean;
};
export function Filters({
@ -61,10 +64,20 @@ export function Filters({
onUploadClick,
columnVisibility = {},
onColumnVisibilityChange,
showAssignedToFilter = false,
}: FiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [users, setUsers] = React.useState<UserOption[]>([]);
React.useEffect(() => {
if (!showAssignedToFilter) return;
fetch("/api/users")
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {});
}, [showAssignedToFilter]);
const search = searchParams.get("search") ?? initialSearch;
const visitType = searchParams.get("visitType") ?? initialVisitType;
@ -72,6 +85,7 @@ export function Filters({
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
const serviceAttended =
searchParams.get("serviceAttended") ?? initialServiceAttended;
const assignedToId = searchParams.get("assignedToId") ?? "";
const updateParams = React.useCallback(
(updates: Record<string, string | undefined>) => {
@ -176,6 +190,31 @@ export function Filters({
))}
</SelectContent>
</Select>
{showAssignedToFilter && (
<Select
value={assignedToId || null}
onValueChange={(v: string | null) =>
updateParams({
assignedToId: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[150px] rounded-xl">
<SelectValue placeholder="Assigned To" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All assignees</SelectItem>
<SelectItem value="me">My Cards</SelectItem>
<SelectItem value="unassigned">Unassigned</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>

View file

@ -9,6 +9,7 @@ import {
Trash2,
X,
LayoutGrid,
UserPlus,
} from "lucide-react";
import { toast } from "sonner";
@ -22,6 +23,8 @@ import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
type AssignableUser = { id: string; displayName: string };
interface SelectionToolbarProps {
selectedIds: string[];
selectedRows: ResponseCard[];
@ -29,6 +32,7 @@ interface SelectionToolbarProps {
onMarkExported?: (ids: string[]) => void;
onReprocess?: (ids: string[]) => void;
onSyncMonday?: (ids: string[]) => void;
onAssign?: (ids: string[], userId: string) => void;
onDelete?: (ids: string[]) => void;
onClear: () => void;
}
@ -57,6 +61,7 @@ export function SelectionToolbar({
onMarkExported,
onReprocess,
onSyncMonday,
onAssign,
onDelete,
onClear,
}: SelectionToolbarProps) {
@ -65,6 +70,26 @@ export function SelectionToolbar({
() => new Set(DEFAULT_COPY_FIELDS)
);
const [copyOpen, setCopyOpen] = React.useState(false);
const [assignOpen, setAssignOpen] = React.useState(false);
const [users, setUsers] = React.useState<AssignableUser[]>([]);
React.useEffect(() => {
if (!assignOpen) return;
fetch("/api/users?role=reviewer")
.then((r) => r.json())
.then((data) => {
const reviewers: AssignableUser[] = data.users || [];
fetch("/api/users?role=admin")
.then((r2) => r2.json())
.then((d2) => {
const admins: AssignableUser[] = d2.users || [];
const all = [...admins, ...reviewers];
const seen = new Set<string>();
setUsers(all.filter((u) => (seen.has(u.id) ? false : (seen.add(u.id), true))));
});
})
.catch(() => {});
}, [assignOpen]);
const toggleCopyField = (field: string) => {
setCopyFields((prev) => {
@ -169,6 +194,46 @@ export function SelectionToolbar({
</Button>
)}
{onAssign && (
<Popover open={assignOpen} onOpenChange={setAssignOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" className="rounded-xl" />
}
>
<UserPlus className="size-4" />
<span className="hidden sm:inline ml-1">Assign</span>
</PopoverTrigger>
<PopoverContent side="top" sideOffset={8} className="w-56 p-0">
<div className="px-3 pt-3 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Assign to
</p>
</div>
<div className="max-h-[200px] overflow-y-auto px-1 pb-2">
{users.length === 0 && (
<p className="px-3 py-2 text-sm text-muted-foreground">
No users available
</p>
)}
{users.map((u) => (
<button
key={u.id}
type="button"
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground text-left"
onClick={() => {
onAssign(selectedIds, u.id);
setAssignOpen(false);
}}
>
{u.displayName}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
<PopoverTrigger
render={

View file

@ -6,6 +6,7 @@ import {
CheckCircle,
AlertTriangle,
Clock,
UserCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -13,9 +14,10 @@ type Stats = {
total: number;
byOcrStatus: Record<string, number>;
byReviewStatus: Record<string, number>;
myCards?: number;
};
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null;
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | "my_cards" | null;
type StatCardData = {
label: string;
@ -48,6 +50,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
{ label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" },
{ label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" },
{ label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
{ label: "My Cards", value: 0, icon: UserCheck, filterKey: "my_cards", accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400", activeRing: "ring-blue-500/40" },
];
}
return [
@ -83,6 +86,14 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
activeRing: "ring-amber-500/40",
},
{
label: "My Cards",
value: stats.myCards ?? 0,
icon: UserCheck,
filterKey: "my_cards" as StatFilter,
accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400",
activeRing: "ring-blue-500/40",
},
];
}, [stats]);
@ -95,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
};
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
{cards.map((card) => {
const Icon = card.icon;
const isActive = activeFilter === card.filterKey;

View file

@ -37,7 +37,8 @@ export async function logActivity(
action: string,
source: string,
summary: string,
changes?: FieldChange[] | null
changes?: FieldChange[] | null,
userId?: string | null
) {
try {
await prisma.activityLog.create({
@ -47,6 +48,7 @@ export async function logActivity(
source,
summary,
changes: changes && changes.length > 0 ? changes : undefined,
userId: userId ?? undefined,
},
});
} catch (err) {

84
src/lib/auth.ts Normal file
View file

@ -0,0 +1,84 @@
import { prisma } from "./db";
export type AppUser = {
id: string;
authentikUid: string;
username: string;
displayName: string;
email: string;
avatarUrl: string;
role: "admin" | "reviewer" | "viewer";
};
type CacheEntry = { user: AppUser; ts: number };
const userCache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 60_000;
function mapGroupsToRole(groups: string[]): "admin" | "reviewer" | "viewer" {
const lower = groups.map((g) => g.toLowerCase());
if (lower.some((g) => g.includes("admin"))) return "admin";
if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer";
return "viewer";
}
export async function getOrCreateUser(headers: Headers): Promise<AppUser | null> {
const uid = headers.get("x-authentik-uid") ?? "";
const username = headers.get("x-authentik-username") ?? "";
const email = headers.get("x-authentik-email") ?? "";
if (!uid && !username && !email) return null;
const cacheKey = uid || username || email;
const cached = userCache.get(cacheKey);
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.user;
}
const name = headers.get("x-authentik-name") ?? "";
const groupsRaw = headers.get("x-authentik-groups") ?? "";
const groups = groupsRaw ? groupsRaw.split("|") : [];
const role = mapGroupsToRole(groups);
const dbUser = await prisma.user.upsert({
where: { authentikUid: uid || `fallback-${username || email}` },
update: {
username: username || undefined,
displayName: name || username || undefined,
email: email || undefined,
role,
},
create: {
authentikUid: uid || `fallback-${username || email}`,
username: username || email,
displayName: name || username || email,
email,
role,
},
});
const user: AppUser = {
id: dbUser.id,
authentikUid: dbUser.authentikUid,
username: dbUser.username,
displayName: dbUser.displayName,
email: dbUser.email,
avatarUrl: dbUser.avatarUrl,
role: dbUser.role as AppUser["role"],
};
userCache.set(cacheKey, { user, ts: Date.now() });
return user;
}
export function requireRole(user: AppUser | null, ...roles: AppUser["role"][]): void {
if (!user || !roles.includes(user.role)) {
throw new RoleError("Insufficient permissions");
}
}
export class RoleError extends Error {
constructor(message: string) {
super(message);
this.name = "RoleError";
}
}

View file

@ -169,18 +169,10 @@ async function handleMonday(
card: Record<string, unknown>,
cardId: string
) {
if (event === "ocr_complete") {
if (event === "card_reviewed" || event === "card_exported") {
await pushCardToMonday(cardId, settings);
} else if (event === "card_reviewed" || event === "card_exported") {
const token = settings.mondayApiToken;
const boardId = settings.mondayBoardId;
const columnMap = (settings.mondayColumnMap as Record<string, unknown>) ?? {};
const mondayItemId = card.mondayItemId as string | null;
if (mondayItemId) {
const columnValues = mapCardToColumnValues(card, columnMap);
await updateItem(token, boardId, mondayItemId, columnValues);
}
}
// ocr_complete no longer triggers Monday.com push -- cards are pushed when reviewed
}
export async function pushCardToMonday(

View file

@ -8,6 +8,7 @@ type CreateNotificationInput = {
cardId?: string;
actionUrl?: string;
meta?: Prisma.InputJsonValue;
userId?: string;
};
export async function createNotification(input: CreateNotificationInput) {
@ -20,6 +21,7 @@ export async function createNotification(input: CreateNotificationInput) {
cardId: input.cardId,
actionUrl: input.actionUrl,
meta: input.meta,
userId: input.userId,
},
});
} catch (err) {

View file

@ -2,6 +2,9 @@
import * as React from "react";
import type { AuthentikUser } from "@/app/api/auth/me/route";
import type { AppUser } from "@/lib/auth";
export type UserRole = "admin" | "reviewer" | "viewer";
export type UserProfile = {
displayName: string;
@ -28,6 +31,8 @@ type UserProfileContextValue = {
updateProfile: (updates: Partial<UserProfile>) => void;
initials: string;
authentikUser: AuthentikUser | null;
dbUser: AppUser | null;
role: UserRole;
isAuthenticated: boolean;
loading: boolean;
};
@ -58,6 +63,7 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null);
const [dbUser, setDbUser] = React.useState<AppUser | null>(null);
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
const [loading, setLoading] = React.useState(true);
const [mounted, setMounted] = React.useState(false);
@ -72,6 +78,9 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
if (data.authenticated && data.user) {
setAuthentikUser(data.user);
}
if (data.dbUser) {
setDbUser(data.dbUser);
}
})
.catch(() => {})
.finally(() => setLoading(false));
@ -107,10 +116,11 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
const isAuthenticated = !!authentikUser;
const role: UserRole = dbUser?.role ?? "admin";
const value = React.useMemo(
() => ({ profile, updateProfile, initials, authentikUser, isAuthenticated, loading }),
[profile, updateProfile, initials, authentikUser, isAuthenticated, loading]
() => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }),
[profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading]
);
if (!mounted) return <>{children}</>;
@ -130,6 +140,8 @@ export function useUserProfile() {
updateProfile: () => {},
initials: "",
authentikUser: null,
dbUser: null,
role: "admin" as UserRole,
isAuthenticated: false,
loading: false,
};