diff --git a/nextjs_space/app/actions/kyc-check.ts b/nextjs_space/app/actions/kyc-check.ts index 83fdf231..84c24c15 100644 --- a/nextjs_space/app/actions/kyc-check.ts +++ b/nextjs_space/app/actions/kyc-check.ts @@ -4,6 +4,7 @@ import { getCurrentUser } from "@/lib/auth-helper"; import { prisma } from "@/lib/db"; import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; import { fetchClient, fetchClientByEmail } from "@/lib/drgreen/doctor-green-api"; +import { canonicalAdminApproval } from "@/lib/drgreen/approval-status"; import { logger } from "@/lib/logger"; export type KycStatus = { @@ -181,6 +182,12 @@ export async function checkUserKycStatus(): Promise { // consultation still lives under the original tenant), migrate the // row to the current tenant so the mirror actually writes. { + // Mirror Dr Green's adminApproval verbatim (canonicalised to + // the VERIFIED|PENDING|REJECTED enum — the legacy "APPROVED" + // literal this block used to write is what let the products + // gate and the dashboard disagree). Unknown values leave the + // stored field untouched. + const mirroredApproval = canonicalAdminApproval(client.adminApproval); const current = await prisma.consultation_questionnaires.updateMany({ where: { tenantId: dbUser.tenantId, @@ -188,7 +195,7 @@ export async function checkUserKycStatus(): Promise { }, data: { isKycVerified: isVerified, - ...(isVerified ? { adminApproval: "APPROVED" } : {}), + ...(mirroredApproval ? { adminApproval: mirroredApproval } : {}), updatedAt: new Date(), }, }); @@ -211,7 +218,7 @@ export async function checkUserKycStatus(): Promise { // on this path would re-create the latch for // any user whose row lives under another tenant. isKycVerified: isVerified, - ...(isVerified ? { adminApproval: "APPROVED" } : {}), + ...(mirroredApproval ? { adminApproval: mirroredApproval } : {}), updatedAt: new Date(), }, }); diff --git a/nextjs_space/app/api/consultation/status/route.ts b/nextjs_space/app/api/consultation/status/route.ts index 7ac17d71..54fbd069 100644 --- a/nextjs_space/app/api/consultation/status/route.ts +++ b/nextjs_space/app/api/consultation/status/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/api-auth"; import { prisma } from "@/lib/db"; import { getTenantFromRequest } from "@/lib/tenant/tenant"; +import { ADMIN_APPROVAL, canonicalAdminApproval } from "@/lib/drgreen/approval-status"; import { apiError } from "@/lib/api-error"; export const GET = withAuth(async (req, { user }) => { @@ -53,7 +54,10 @@ export const GET = withAuth(async (req, { user }) => { drGreenClientId: consultation.drGreenClientId, kycLink: consultation.kycLink, isKycVerified: consultation.isKycVerified, - adminApproval: consultation.adminApproval, + // Canonicalised so consumers comparing to "VERIFIED" (the products-page + // gate) also accept rows stored with the legacy "APPROVED" literal. + adminApproval: + canonicalAdminApproval(consultation.adminApproval) ?? ADMIN_APPROVAL.PENDING, }); } catch (error: any) { console.error("Error fetching consultation status:", error); diff --git a/nextjs_space/app/api/shop/register/route.ts b/nextjs_space/app/api/shop/register/route.ts index ee5ef65b..253dfa79 100644 --- a/nextjs_space/app/api/shop/register/route.ts +++ b/nextjs_space/app/api/shop/register/route.ts @@ -143,19 +143,33 @@ export const POST = withAuth(async (req, { user }) => { config, ); - // Update user with additional info - await prisma.users.update({ - where: { id: dbUser.id }, - data: { - name: `${personal.firstName} ${personal.lastName}`, - firstName: personal.firstName, - lastName: personal.lastName, - // Phone was collected + validated above but previously only sent to - // Dr Green — persist it locally so Customers detail/export show it. - phone: `${phoneCode} ${contactNumber}`.trim(), - updatedAt: new Date(), - }, - }); + // Update user with additional info. The Dr Green client already exists at + // this point — a local persistence failure must NOT fail the registration + // (log and continue; the status-refresh sweep self-heals the client id). + try { + await prisma.users.update({ + where: { id: dbUser.id }, + data: { + name: `${personal.firstName} ${personal.lastName}`, + firstName: personal.firstName, + lastName: personal.lastName, + // Phone was collected + validated above but previously only sent to + // Dr Green — persist it locally so Customers detail/export show it. + phone: `${phoneCode} ${contactNumber}`.trim(), + // The Dr Green client id was previously returned to the browser but + // never persisted, leaving these customers unreachable by webhooks + // and status sync — permanently "pending" on every admin surface. + ...(result.clientId ? { drGreenClientId: result.clientId } : {}), + ...(tenant?.id && !dbUser.tenantId ? { tenantId: tenant.id } : {}), + updatedAt: new Date(), + }, + }); + } catch (persistError) { + console.error( + "[shop/register] Dr Green client created but local persistence failed", + persistError instanceof Error ? persistError.message : persistError, + ); + } return NextResponse.json({ success: true, diff --git a/nextjs_space/app/api/tenant-admin/customers/[id]/route.ts b/nextjs_space/app/api/tenant-admin/customers/[id]/route.ts index b2524854..a6503ed0 100644 --- a/nextjs_space/app/api/tenant-admin/customers/[id]/route.ts +++ b/nextjs_space/app/api/tenant-admin/customers/[id]/route.ts @@ -5,6 +5,7 @@ import { clerkClient } from "@clerk/nextjs/server"; import { prisma } from "@/lib/db"; import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; import { fetchClientByEmail, updateClient } from "@/lib/drgreen/doctor-green-api"; +import { ADMIN_APPROVAL } from "@/lib/drgreen/approval-status"; import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log"; import { eraseUser } from "@/lib/gdpr/erasure"; import { apiError, apiValidationError } from "@/lib/api-error"; @@ -380,7 +381,10 @@ export const PATCH = withAuth(async (request, { user }, params) => { }, data: { isKycVerified: true, - adminApproval: "APPROVED", + // Dr Green's enum value — the legacy "APPROVED" literal here made + // the products-page gate (which only accepts VERIFIED) disagree + // with every other surface. See lib/drgreen/approval-status.ts. + adminApproval: ADMIN_APPROVAL.VERIFIED, updatedAt: new Date(), }, }); diff --git a/nextjs_space/app/tenant-admin/customers/[id]/page.tsx b/nextjs_space/app/tenant-admin/customers/[id]/page.tsx index ff39f6f5..b5486349 100644 --- a/nextjs_space/app/tenant-admin/customers/[id]/page.tsx +++ b/nextjs_space/app/tenant-admin/customers/[id]/page.tsx @@ -1,12 +1,17 @@ import { currentUser } from "@clerk/nextjs/server"; import { redirect, notFound } from "next/navigation"; +import { format } from "date-fns"; import { prisma } from "@/lib/db"; import { getActiveAdminTenant } from "@/lib/tenant/active-admin-tenant"; +import { + deriveVerificationStatus, + VERIFICATION_STATUS_DISPLAY, +} from "@/lib/drgreen/approval-status"; import CustomerEditForm from "./customer-edit-form"; import CustomerActions from "./customer-actions"; import CustomerTags from "./customer-tags"; import MarketingConsentCard from "./marketing-consent-card"; -import { Breadcrumbs } from "@/components/admin/shared"; +import { Breadcrumbs, RowPill } from "@/components/admin/shared"; export default async function CustomerDetailPage({ params, @@ -62,27 +67,39 @@ export default async function CustomerDetailPage({ notFound(); } - // Existing customers created via the ID-upload/consultation intake had their - // name saved only to users.name and phone only on the questionnaire (the - // granular users.firstName/lastName/phone were left null). Backfill for - // display so the form doesn't show "Not set" for data actually provided. - const needsBackfill = !(customer.firstName && customer.lastName && customer.phone); - const questionnaire = - needsBackfill && customer.email - ? await prisma.consultation_questionnaires.findFirst({ - where: { - email: customer.email, - ...(customer.tenantId && { tenantId: customer.tenantId }), - }, - orderBy: { createdAt: "desc" }, - select: { - firstName: true, - lastName: true, - phoneCode: true, - phoneNumber: true, - }, - }) - : null; + // Always loaded now: the name/phone backfill for the edit form (existing + // customers created via the ID-upload/consultation intake saved name/phone + // only on the questionnaire) PLUS the Dr Green approval mirror for the + // Verification card below. Last-known state — no Dr Green API call here. + const questionnaire = customer.email + ? await prisma.consultation_questionnaires.findFirst({ + where: { + email: { equals: customer.email, mode: "insensitive" }, + ...(customer.tenantId && { tenantId: customer.tenantId }), + }, + orderBy: { createdAt: "desc" }, + select: { + firstName: true, + lastName: true, + phoneCode: true, + phoneNumber: true, + isKycVerified: true, + adminApproval: true, + idDocumentStatus: true, + kycLink: true, + drGreenClientId: true, + updatedAt: true, + }, + }) + : null; + + const verificationStatus = deriveVerificationStatus({ + hasQuestionnaire: !!questionnaire, + isKycVerified: questionnaire?.isKycVerified, + adminApproval: questionnaire?.adminApproval, + idDocumentStatus: questionnaire?.idDocumentStatus, + }); + const verificationDisplay = VERIFICATION_STATUS_DISPLAY[verificationStatus]; // US-024: the customer's tag chips. Tag rows always carry the tenant they // were created under; the extra tenant predicate keeps a super-admin's // cross-tenant view scoped to the row's own tenant. @@ -171,6 +188,75 @@ export default async function CustomerDetailPage({
+
+
+

+ Verification +

+ + {verificationDisplay.label} + +
+
+ {questionnaire?.idDocumentStatus && ( +
+
ID document
+
+ {questionnaire.idDocumentStatus === "UPLOAD_FAILED" + ? "Upload failed" + : "Uploaded"} +
+
+ )} + {questionnaire?.drGreenClientId && ( +
+
Dr Green client
+
+ {questionnaire.drGreenClientId} +
+
+ )} + {questionnaire && ( +
+
Last synced
+
+ {format(questionnaire.updatedAt, "MMM d, yyyy HH:mm")} +
+
+ )} +
+ {questionnaire && + !questionnaire.isKycVerified && + questionnaire.kycLink && + verificationStatus !== "VERIFIED" && ( + + Open customer's KYC link + + )} + {!questionnaire && ( +

+ This customer has not submitted a consultation, so they have no + Dr Green verification record yet. +

+ )} +

+ Last-known status from Dr Green — approvals happen in the Dr Green + admin. Use "Refresh from Dr Green" on the Customers list + to sync. +

+
+

; + /** Last "Refresh from Dr Green" run (ISO), or null if never refreshed. */ + lastSyncedAt?: string | null; + /** False for a cross-tenant super-admin view — nothing to refresh. */ + canRefresh?: boolean; +} + +function StatusPill({ status }: { status: CustomerVerificationStatus }) { + const display = VERIFICATION_STATUS_DISPLAY[status]; + return {display.label}; } /** Filter shape for useTableState — `tag` rides the URL as ?tag=. */ @@ -50,7 +67,29 @@ export function CustomersTable({ customers, totalCount, availableTags = [], + statusCounts, + lastSyncedAt, + canRefresh = false, }: CustomersTableProps) { + const router = useRouter(); + const [isRefreshing, startRefresh] = useTransition(); + + const handleRefreshStatuses = () => { + startRefresh(async () => { + const result = await refreshCustomerStatuses(); + if (result.ok) { + toast.success( + result.updated + ? `Statuses refreshed — ${result.updated} customer${result.updated === 1 ? "" : "s"} updated.` + : "Statuses refreshed — everything already up to date.", + ); + router.refresh(); + } else { + toast.error(result.error || "Refresh failed. Try again shortly."); + } + }); + }; + const [ { search, filters, page, pageSize, sort }, { setSearch, setFilter, setPage, setPageSize, setSort, resetFilters }, @@ -111,6 +150,9 @@ export function CustomersTable({ name: c.name || "N/A", email: c.email, phone: c.phone || "N/A", + status: c.verificationStatus + ? VERIFICATION_STATUS_DISPLAY[c.verificationStatus].label + : "N/A", orders: c._count.orders, createdAt: format(new Date(c.createdAt), "yyyy-MM-dd"), })); @@ -119,6 +161,7 @@ export function CustomersTable({ { key: "name" as const, label: "Name" }, { key: "email" as const, label: "Email" }, { key: "phone" as const, label: "Phone" }, + { key: "status" as const, label: "Status" }, { key: "orders" as const, label: "Orders" }, { key: "createdAt" as const, label: "Joined" }, ]; @@ -192,6 +235,42 @@ export function CustomersTable({ />

+ + {/* Approval-status summary + refresh. Counts are tenant-wide (they + ignore search/tag filters, like the stat cards above). */} + {statusCounts && ( +
+
+ {statusCounts.VERIFIED} verified + {statusCounts.PENDING} pending + + {statusCounts.REJECTED + statusCounts.ID_UPLOAD_FAILED} rejected / failed + + {statusCounts.NOT_SUBMITTED} not submitted +
+ {canRefresh && ( +
+ + {lastSyncedAt + ? `Synced ${formatDistanceToNow(new Date(lastSyncedAt), { addSuffix: true })}` + : "Showing last-known statuses"} + + +
+ )} +
+ )}
@@ -244,6 +323,7 @@ export function CustomersTable({ onSort={setSort} className="hidden md:table-cell" /> + Status