Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions nextjs_space/app/api/consultation/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {

import { prisma } from "@/lib/db";
import { buildKycClientPayload } from '@/lib/drgreen/kyc-client-payload';
import { classifyDrGreenRegistrationError } from '@/lib/drgreen/registration-error';
import crypto from "crypto";
import { z } from "zod";

Expand All @@ -34,6 +35,9 @@ import { apiError, apiValidationError } from '@/lib/api-error';
import { checkPolicyGate } from '@/lib/legal/policy-gate';
import { CUSTOMER_TITLES, normaliseCustomerTitle } from '@/lib/customers/titles';
import { CONSENT_SOURCE } from '@/lib/customers/marketing-consent';
import { REFERRAL_COOKIE_NAME, resolveAffiliateAttribution } from '@/lib/affiliate/affiliate-code';
import { affiliateCodeField, parseSignUpWithOptionalAffiliateCode } from '@/lib/affiliate/affiliate-code-schema';
import { clearReferralCookie } from '@/lib/affiliate/referral-cookie';

/** 409 for "that address already belongs to an account you have not proven you own". */
function accountExistsResponse() {
Expand Down Expand Up @@ -88,6 +92,10 @@ const consultationSchema = z.object({
// BS-303: optional salutation from the fixed list; "" = not chosen.
title: z.union([z.enum(CUSTOMER_TITLES), z.literal("")]).optional(),

// BS-A02: optional Dr Green affiliate code (US-A02 format, max 20). A
// malformed one is dropped, never a 400 — parseSignUpWithOptionalAffiliateCode.
affiliateCode: affiliateCodeField,

// SA ID-upload (idMode) — see idDocumentSchema above.
idDocument: idDocumentSchema.optional(),

Expand Down Expand Up @@ -143,7 +151,11 @@ export async function POST(request: NextRequest) {

const rawBody = await request.json();

const parseResult = consultationSchema.safeParse(rawBody);
const { result: parseResult, affiliateCodeIssue } =
parseSignUpWithOptionalAffiliateCode(consultationSchema, rawBody);
if (affiliateCodeIssue) {
logger.info("[Consultation] malformed affiliate code ignored; sign-up continues");
}
if (!parseResult.success) {
const firstError = parseResult.error.errors[0];
return apiValidationError(
Expand Down Expand Up @@ -221,6 +233,11 @@ export async function POST(request: NextRequest) {
const customerTitle = normaliseCustomerTitle(body.title);
// US-023: consent only on an explicit tick — never inferred.
const consented = body.marketingConsent === true;
// BS-A02: 'link' when the submitted code is the remembered bs_ref one, else 'typed'.
const affiliate = resolveAffiliateAttribution(
body.affiliateCode,
request.cookies.get(REFERRAL_COOKIE_NAME)?.value,
);

// A storefront with no published privacy notice tells visitors exactly that
// — so taking a consultation here would collect special-category data with
Expand Down Expand Up @@ -500,6 +517,8 @@ export async function POST(request: NextRequest) {
title: customerTitle,
marketingConsent: consented,
consentSource: registrationSource,
affiliateCode: affiliate?.affiliateCode,
affiliateCodeSource: affiliate?.affiliateCodeSource,
shipping: {
address1: body.addressLine1,
address2: body.addressLine2 || "",
Expand Down Expand Up @@ -561,6 +580,8 @@ export async function POST(request: NextRequest) {
title: customerTitle,
marketingConsent: consented,
consentSource: registrationSource,
affiliateCode: affiliate?.affiliateCode ?? null,
affiliateCodeSource: affiliate?.affiliateCodeSource ?? null,
});

// Submit to Dr. Green API via shared client
Expand Down Expand Up @@ -645,6 +666,7 @@ export async function POST(request: NextRequest) {
data: {
drGreenClientId: clientId,
tenantId,
...(affiliate ?? {}), // BS-A03: the code sent with this client, for reference
updatedAt: new Date(),
},
});
Expand Down Expand Up @@ -684,14 +706,16 @@ export async function POST(request: NextRequest) {
},
});

return NextResponse.json({
const response = NextResponse.json({
success: true,
message: "Consultation submitted successfully",
questionnaireId: questionnaire.id,
drGreenClientId: clientId,
kycLink: kycLink,
adminApproval: "PENDING",
});
clearReferralCookie(response); // BS-A02: the landing code has done its job.
return response;
} catch (drGreenError: any) {
// Message only — the Dr Green error object/body can echo back the
// submitted PHI; never log the whole thing.
Expand All @@ -700,29 +724,9 @@ export async function POST(request: NextRequest) {
message: drGreenError instanceof Error ? drGreenError.message : String(drGreenError),
});

// Parse Dr Green error for user-friendly messages
const errorMsg = drGreenError.message || "";
let userMessage = "Registration failed. Please try again or contact support.";
let statusCode = 500;
let failureCode = "UNKNOWN";

if (errorMsg.includes("Phone Number already exists") || errorMsg.includes("phone") && errorMsg.includes("exists")) {
userMessage = "This phone number is already registered. Please use a different phone number or contact support.";
statusCode = 409;
failureCode = "PHONE_EXISTS";
} else if (errorMsg.includes("email") && errorMsg.includes("exists")) {
userMessage = "This email address is already registered. Please use a different email or try logging in.";
statusCode = 409;
failureCode = "EMAIL_EXISTS";
} else if (errorMsg.includes("409")) {
userMessage = "An account with these details already exists. Please use different details or contact support.";
statusCode = 409;
failureCode = "CONFLICT";
} else if (errorMsg.includes("400")) {
userMessage = "Invalid information provided. Please check your details and try again.";
statusCode = 400;
failureCode = "BAD_REQUEST";
}
// Parse Dr Green error for user-friendly messages (lib/drgreen/registration-error.ts)
const { userMessage, statusCode, failureCode } =
classifyDrGreenRegistrationError(drGreenError.message);

// Persist a stable classification, NOT the upstream message. Dr Green
// error bodies echo back submitted values (see the logger note above), so
Expand Down
38 changes: 36 additions & 2 deletions nextjs_space/app/store/[slug]/consultation/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { ConsultationForm } from "@/components/consultation/consultation-form";
import { notFound } from "next/navigation";
import { generateStoreRouteMetadata } from "@/lib/seo/generate-page-metadata";
Expand All @@ -10,6 +11,28 @@ import {
getTenantVerificationMode,
isSaIdUploadEnabled,
} from "@/lib/verification-mode";
import {
REFERRAL_COOKIE_NAME,
REFERRAL_QUERY_PARAM,
normaliseAffiliateCode,
} from "@/lib/affiliate/affiliate-code";

/**
* BS-A02: the referral-code field's initial value. A `?ref=` on this very
* request wins (middleware sets `bs_ref` on this response, so the cookie is
* not on the request yet); otherwise the remembered `bs_ref` cookie. The
* cookie is HttpOnly, so it is read here and handed down, never by the form.
*/
function initialAffiliateCode(
searchParams: Record<string, string | string[] | undefined> | undefined,
): string {
const ref = searchParams?.[REFERRAL_QUERY_PARAM];
return (
normaliseAffiliateCode(Array.isArray(ref) ? ref[0] : ref) ??
normaliseAffiliateCode(cookies().get(REFERRAL_COOKIE_NAME)?.value) ??
""
);
}

/**
* SEO US-007 — nav- and footer-linked (components/navigation.tsx:104,
Expand Down Expand Up @@ -37,8 +60,10 @@ export async function generateMetadata(): Promise<Metadata> {

export default async function ConsultationPage({
params,
searchParams,
}: {
params: { slug: string };
searchParams?: Record<string, string | string[] | undefined>;
}) {
const tenant = await getCurrentTenant();

Expand All @@ -54,6 +79,7 @@ export default async function ConsultationPage({
// SA ID-upload tenants skip the medical consultation: register + upload an ID.
const idMode =
isSaIdUploadEnabled() && getTenantVerificationMode(tenant) === "ID_UPLOAD";
const affiliateCode = initialAffiliateCode(searchParams);

return (
<div
Expand All @@ -74,7 +100,11 @@ export default async function ConsultationPage({
>
Register &amp; verify with your ID
</h2>
<IdUploadForm tenantSlug={tenant.subdomain} storeName={tenant.businessName} />
<IdUploadForm
tenantSlug={tenant.subdomain}
storeName={tenant.businessName}
initialAffiliateCode={affiliateCode}
/>
</div>
</div>
</section>
Expand All @@ -95,7 +125,11 @@ export default async function ConsultationPage({
>
Register here
</h2>
<ConsultationForm tenantSlug={tenant.subdomain} storeName={tenant.businessName} />
<ConsultationForm
tenantSlug={tenant.subdomain}
storeName={tenant.businessName}
initialAffiliateCode={affiliateCode}
/>
</div>
</div>
</section>
Expand Down
8 changes: 6 additions & 2 deletions nextjs_space/app/store/[slug]/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getTenantBasePath } from "@/lib/tenant/tenant-utils";
* Registration Redirect
*
* All customer signups must go through the consultation form for KYC compliance.
* This page redirects to the consultation page.
* This page redirects to the consultation page, query string included.
*/
export default function RegisterRedirectPage() {
const params = useParams();
Expand All @@ -19,7 +19,11 @@ export default function RegisterRedirectPage() {
useEffect(() => {
if (slug) {
const basePath = getTenantBasePath(slug);
router.replace(`${basePath}/consultation`);
// BS-A01: keep the query string so a holder's /register?ref=CODE link
// still pre-fills the referral code. Read from window.location (this
// effect only runs in the browser) rather than useSearchParams, which
// would need a Suspense boundary around this page.
router.replace(`${basePath}/consultation${window.location.search}`);
}
}, [slug, router]);

Expand Down
28 changes: 28 additions & 0 deletions nextjs_space/app/tenant-admin/customers/customers-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface Customer {
createdAt: Date;
/** BS-305: when the customer opted in to marketing; null = no consent. */
marketingConsentAt?: Date | null;
/** BS-A03: Dr Green referral code sent with the sign-up (read-only). */
affiliateCode?: string | null;
/** BS-A03: link | typed. */
affiliateCodeSource?: string | null;
_count: {
orders: number;
};
Expand Down Expand Up @@ -182,6 +186,9 @@ export function CustomersTable({
marketingConsentAt: c.marketingConsentAt
? format(new Date(c.marketingConsentAt), "yyyy-MM-dd HH:mm")
: "",
// BS-A03: the Dr Green referral code the customer signed up with.
affiliateCode: c.affiliateCode || "",
affiliateCodeSource: c.affiliateCodeSource || "",
}));

const csvHeaders = [
Expand All @@ -193,6 +200,8 @@ export function CustomersTable({
{ key: "createdAt" as const, label: "Joined" },
{ key: "marketingConsent" as const, label: "Marketing consent" },
{ key: "marketingConsentAt" as const, label: "Consent given" },
{ key: "affiliateCode" as const, label: "Referral code" },
{ key: "affiliateCodeSource" as const, label: "Referral source" },
];

await exportToCSV(
Expand Down Expand Up @@ -364,6 +373,7 @@ export function CustomersTable({
/>
<th className="text-left hidden sm:table-cell">Status</th>
<th className="text-left hidden lg:table-cell">Marketing</th>
<th className="text-left hidden xl:table-cell">Referral</th>
<th className="text-center hidden sm:table-cell">
<span className="flex items-center justify-center gap-1.5">
<ShoppingBag className="h-3.5 w-3.5 text-bs-fg-muted" aria-hidden="true" />
Expand Down Expand Up @@ -439,6 +449,24 @@ export function CustomersTable({
<RowPill tone="slate">No consent</RowPill>
)}
</td>
{/* BS-A03: read-only — Dr Green owns the link; this is
the code that travelled with the sign-up. */}
<td className="hidden xl:table-cell">
{customer.affiliateCode ? (
<span
className="font-mono text-sm text-bs-fg"
title={
customer.affiliateCodeSource === "typed"
? "Typed at sign-up"
: "From the link the customer followed"
}
>
{customer.affiliateCode}
</span>
) : (
<span className="text-xs text-bs-fg-muted">—</span>
)}
</td>
<td className="text-center hidden sm:table-cell">
<RowPill tone="gold">{customer._count.orders}</RowPill>
</td>
Expand Down
4 changes: 4 additions & 0 deletions nextjs_space/app/tenant-admin/customers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export default async function CustomersListPage({
phone: true,
createdAt: true,
marketingConsentAt: true,
affiliateCode: true, // BS-A03: read-only reference column + CSV
affiliateCodeSource: true,
_count: {
select: {
orders: true,
Expand Down Expand Up @@ -284,6 +286,8 @@ export default async function CustomersListPage({
name: string | null;
phone: string | null;
marketingConsentAt: Date | null;
affiliateCode: string | null;
affiliateCodeSource: string | null;
}) => {
const q = questionnaireByEmail.get(customer.email.toLowerCase());
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export interface ConsultationFormData {
marketingConsent: boolean;
// BS-303: optional salutation from lib/customers/titles.ts; "" = not chosen.
title: string;
// BS-A02: optional Dr Green referral code, pre-filled from the bs_ref
// landing cookie; "" = none. Never changes a price.
affiliateCode: string;

// Shipping Address
addressLine1: string;
Expand Down
5 changes: 5 additions & 0 deletions nextjs_space/components/consultation/consultation-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ interface ConsultationFormProps {
tenantSlug: string;
/** The store's business name — read into the marketing-consent copy (BS-302). */
storeName?: string;
/** BS-A02: referral code from ?ref= / the bs_ref cookie, read server-side. */
initialAffiliateCode?: string;
}

export function ConsultationForm({
tenantSlug,
storeName,
initialAffiliateCode,
}: ConsultationFormProps) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
Expand All @@ -47,6 +50,7 @@ export function ConsultationForm({
confirmPassword: "",
marketingConsent: false,
title: "",
affiliateCode: initialAffiliateCode ?? "",

addressLine1: "",
addressLine2: "",
Expand Down Expand Up @@ -126,6 +130,7 @@ export function ConsultationForm({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
affiliateCode: formData.affiliateCode.trim(),
// SECURITY: Send the tenant SLUG (public, in URL) — NEVER the
// internal tenantId UUID. Server resolves tenant from request
// host first; this slug is only used as a localhost dev fallback.
Expand Down
10 changes: 9 additions & 1 deletion nextjs_space/components/consultation/id-upload-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ interface IdUploadFormProps {
tenantSlug: string;
/** The store's business name — read into the marketing-consent copy (BS-302). */
storeName?: string;
/** BS-A02: referral code from ?ref= / the bs_ref cookie, read server-side. */
initialAffiliateCode?: string;
}

const fileToBase64 = (file: File): Promise<string> =>
Expand All @@ -39,7 +41,11 @@ const fileToBase64 = (file: File): Promise<string> =>
reader.readAsDataURL(file);
});

export function IdUploadForm({ tenantSlug, storeName }: IdUploadFormProps) {
export function IdUploadForm({
tenantSlug,
storeName,
initialAffiliateCode,
}: IdUploadFormProps) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
Expand All @@ -62,6 +68,7 @@ export function IdUploadForm({ tenantSlug, storeName }: IdUploadFormProps) {
confirmPassword: "",
marketingConsent: false,
title: "",
affiliateCode: initialAffiliateCode ?? "",

addressLine1: "",
addressLine2: "",
Expand Down Expand Up @@ -139,6 +146,7 @@ export function IdUploadForm({ tenantSlug, storeName }: IdUploadFormProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
affiliateCode: formData.affiliateCode.trim(),
tenantSlug,
idDocument: {
fileBase64,
Expand Down
Loading
Loading