diff --git a/messages/de.json b/messages/de.json index 596b5c5..7b79283 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2271,6 +2271,67 @@ "quantityMin": "Menge muss mindestens 0 sein", "maxQuantityMin": "Maximale Menge muss mindestens 1 sein" } + }, + "public": { + "pageTitle": "Angebotsübersicht", + "validUntil": "Gültig bis {date}", + "preparedFor": "Erstellt für {client}", + "status": "Status", + "statusActive": "Aktiv", + "statusAccepted": "Angenommen", + "statusExpired": "Abgelaufen", + "statusRevoked": "Widerrufen", + "proposalExpiredBanner": "Angebot abgelaufen", + "proposalExpiredNotice": "Dieses Angebot hat sein Gültigkeitsdatum am {date} erreicht und kann nicht mehr online angenommen werden. Bitte kontaktieren Sie das Studio für ein aktualisiertes Angebot.", + "proposalRevokedBanner": "Link nicht mehr aktiv", + "proposalRevokedNotice": "Dieser Angebotslink wurde aktualisiert oder widerrufen. Bitte kontaktieren Sie das Studio für den aktuellen Link.", + "proposalAcceptedBanner": "Angebot angenommen", + "proposalAcceptedNotice": "Vielen Dank! Dieses Angebot wurde am {date} von {name} ({email}) angenommen.", + "basePackageTitle": "Basispaket", + "deliverablesTitle": "Enthaltene Leistungen", + "addonsTitle": "Optionale Zusatzleistungen", + "addonsSubtitle": "Wählen Sie optionale Zusatzleistungen oder passen Sie die Mengen an Ihre Wünsche an.", + "noAddons": "Keine optionalen Zusatzleistungen für dieses Angebot vorhanden.", + "quantity": "Menge", + "unitPrice": "Einzelpreis", + "total": "Gesamt", + "included": "Inklusive", + "pricingSummaryTitle": "Preisübersicht", + "subtotal": "Zwischensumme", + "vat": "USt.", + "vatRate": "USt. ({rate}%)", + "totalAmount": "Gesamtbetrag", + "pricesIncludeTax": "Preise verstehen sich inklusive Mehrwertsteuer", + "pricesExcludeTax": "Preise verstehen sich zuzüglich Mehrwertsteuer", + "kleinunternehmerNotice": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet oder erhoben.", + "acceptProposalButton": "Angebot annehmen", + "accepting": "Wird angenommen...", + "studioContact": "Fragen? Kontaktieren Sie {studioName}", + "modal": { + "title": "Angebot annehmen", + "description": "Bitte prüfen und bestätigen Sie Ihre Angaben, um dieses Angebot verbindlich anzunehmen.", + "fullNameLabel": "Vollständiger Name", + "fullNamePlaceholder": "z. B. Max Mustermann", + "emailLabel": "E-Mail-Adresse", + "emailPlaceholder": "max@beispiel.de", + "agreementCheckbox": "Ich nehme dieses Angebot, die beschriebenen Leistungen und den Gesamtbetrag von {total} an.", + "confirmButton": "Verbindlich annehmen", + "cancelButton": "Abbrechen" + }, + "validation": { + "nameRequired": "Vollständiger Name ist erforderlich", + "emailRequired": "E-Mail-Adresse ist erforderlich", + "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "agreementRequired": "Sie müssen der Annahme des Angebots zustimmen" + }, + "rateLimit": { + "title": "Zu viele Anfragen", + "message": "Zu viele Anfragen von Ihrer IP-Adresse. Bitte warten Sie einen Moment, bevor Sie es erneut versuchen." + }, + "serviceUnavailable": { + "title": "Dienst nicht verfügbar", + "message": "Bitte versuchen Sie es in einem Moment erneut." + } } } } diff --git a/messages/en.json b/messages/en.json index 180020f..7eef499 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2271,6 +2271,67 @@ "quantityMin": "Quantity must be at least 0", "maxQuantityMin": "Max quantity must be at least 1" } + }, + "public": { + "pageTitle": "Proposal Review", + "validUntil": "Valid until {date}", + "preparedFor": "Prepared for {client}", + "status": "Status", + "statusActive": "Active", + "statusAccepted": "Accepted", + "statusExpired": "Expired", + "statusRevoked": "Revoked", + "proposalExpiredBanner": "Proposal Expired", + "proposalExpiredNotice": "This proposal reached its validity expiration date on {date} and can no longer be accepted online. Please contact the studio for an updated proposal.", + "proposalRevokedBanner": "Link No Longer Active", + "proposalRevokedNotice": "This proposal link has been superseded or revoked. Please contact the studio for an updated link.", + "proposalAcceptedBanner": "Proposal Accepted", + "proposalAcceptedNotice": "Thank you! This proposal was accepted on {date} by {name} ({email}).", + "basePackageTitle": "Base Service Package", + "deliverablesTitle": "Included Deliverables", + "addonsTitle": "Optional Add-ons & Services", + "addonsSubtitle": "Select optional add-ons or customize quantities to tailor the package to your needs.", + "noAddons": "No optional add-on services attached to this proposal.", + "quantity": "Quantity", + "unitPrice": "Unit Price", + "total": "Total", + "included": "Included", + "pricingSummaryTitle": "Pricing Summary", + "subtotal": "Subtotal", + "vat": "VAT", + "vatRate": "VAT ({rate}%)", + "totalAmount": "Total Amount", + "pricesIncludeTax": "Prices include applicable VAT", + "pricesExcludeTax": "Prices exclude applicable VAT", + "kleinunternehmerNotice": "According to § 19 UStG, no value-added tax is calculated or charged.", + "acceptProposalButton": "Accept Proposal", + "accepting": "Accepting Proposal...", + "studioContact": "Questions? Contact {studioName}", + "modal": { + "title": "Accept Proposal", + "description": "Please review and confirm your details to accept this proposal and enter into a binding agreement.", + "fullNameLabel": "Full Name", + "fullNamePlaceholder": "e.g. Jane Doe", + "emailLabel": "Email Address", + "emailPlaceholder": "jane@example.com", + "agreementCheckbox": "I accept this proposal, the deliverables described, and the total amount of {total}.", + "confirmButton": "Confirm & Accept Proposal", + "cancelButton": "Cancel" + }, + "validation": { + "nameRequired": "Full name is required", + "emailRequired": "Email address is required", + "emailInvalid": "Please enter a valid email address", + "agreementRequired": "You must confirm acceptance of the proposal terms" + }, + "rateLimit": { + "title": "Too Many Requests", + "message": "Too many requests from your IP address. Please wait a moment before trying again." + }, + "serviceUnavailable": { + "title": "Service Unavailable", + "message": "Please try again in a moment." + } } } } diff --git a/src/app/[locale]/p/[token]/page.tsx b/src/app/[locale]/p/[token]/page.tsx new file mode 100644 index 0000000..8680028 --- /dev/null +++ b/src/app/[locale]/p/[token]/page.tsx @@ -0,0 +1,207 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { headers } from 'next/headers'; +import { createHash } from 'node:crypto'; +import { eq, asc, desc } from 'drizzle-orm'; +import { db } from '@/server/db/client'; +import { routing } from '@/i18n/routing'; +import { resolveClientIp } from '@/server/http/client-ip'; +import { consumeProposalRateLimit } from '@/server/auth/rate-limiter'; +import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; +import { clientContacts } from '@/server/db/schema/clients'; +import { getStudioSettings } from '@/server/settings/repo'; +import { PublicProposalView } from '@/components/proposals/public/public-proposal-view'; +import { ShieldAlert } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; + +export const dynamic = 'force-dynamic'; + +interface ProposalPageProps { + params: Promise<{ locale: string; token: string }>; +} + +export async function generateMetadata({ params }: ProposalPageProps): Promise { + const { token } = await params; + let studioName = 'Ownlight Studio'; + try { + const settings = await getStudioSettings(); + if (settings.studio_name) { + studioName = settings.studio_name; + } + } catch { + studioName = 'Ownlight Studio'; + } + + const tokenHash = createHash('sha256').update(token).digest('hex'); + const [proposal] = await db + .select({ title: proposals.title }) + .from(proposals) + .where(eq(proposals.tokenHash, tokenHash)) + .limit(1); + + const title = proposal ? `${proposal.title} | ${studioName}` : `Proposal | ${studioName}`; + + return { + title, + robots: { + index: false, + follow: false, + nocache: true, + }, + }; +} + +/** + * Public Proposal Acceptance Page at /[locale]/p/[token] (TASK-0062 / ADR-0018 §3). + * Cookieless, rate-limited, mobile-first presentation of commercial deliverables and options. + */ +export default async function PublicProposalPage({ params }: ProposalPageProps) { + const { locale, token } = await params; + + if (!routing.locales.includes(locale as 'en' | 'de')) { + notFound(); + } + + // 1. Rate limiting check (30 requests/minute per client IP) + let clientIp = '127.0.0.1'; + try { + const h = await headers(); + clientIp = resolveClientIp(h) ?? '127.0.0.1'; + } catch { + // Outside request context + } + + let isRateLimited = false; + let isRateLimitError = false; + + try { + const rateCheck = await consumeProposalRateLimit(clientIp); + if (!rateCheck.allowed) { + isRateLimited = true; + } + } catch { + isRateLimitError = true; + } + + const t = await getTranslations({ locale, namespace: 'proposals.public' }); + + if (isRateLimited) { + return ( +
+
+
+

{t('rateLimit.title')}

+

{t('rateLimit.message')}

+
+ ); + } + + if (isRateLimitError) { + return ( +
+

{t('serviceUnavailable.title')}

+

{t('serviceUnavailable.message')}

+
+ ); + } + + // 2. Lookup proposal by token_hash + const tokenHash = createHash('sha256').update(token).digest('hex'); + const [proposal] = await db + .select() + .from(proposals) + .where(eq(proposals.tokenHash, tokenHash)) + .limit(1); + + if (!proposal) { + notFound(); + } + + // 3. Query line items + const lineItems = await db + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, proposal.id)) + .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); + + // 4. Resolve studio name and client contacts + let studioName = 'Ownlight Studio'; + try { + const settings = await getStudioSettings(); + if (settings.studio_name) { + studioName = settings.studio_name; + } + } catch { + studioName = 'Ownlight Studio'; + } + + let clientContactName = proposal.acceptedByName ?? ''; + let clientContactEmail = proposal.acceptedByEmail ?? ''; + + if (!clientContactName || !clientContactEmail) { + const [contact] = await db + .select({ + givenName: clientContacts.givenName, + familyName: clientContacts.familyName, + email: clientContacts.email, + }) + .from(clientContacts) + .where(eq(clientContacts.clientId, proposal.clientId)) + .orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt)) + .limit(1); + + if (contact) { + const contactFullName = [contact.givenName, contact.familyName] + .filter(Boolean) + .join(' ') + .trim(); + if (!clientContactName && contactFullName) clientContactName = contactFullName; + if (!clientContactEmail && contact.email) clientContactEmail = contact.email; + } + } + + return ( + ({ + id: item.id, + itemType: item.itemType, + name: item.name, + description: item.description, + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + minQuantity: item.minQuantity, + maxQuantity: item.maxQuantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: item.isOptional, + isSelected: item.isSelected, + position: item.position, + })), + }} + studioName={studioName} + token={token} + locale={locale} + clientContactName={clientContactName} + clientContactEmail={clientContactEmail} + /> + ); +} diff --git a/src/app/api/public/proposals/[token]/route.ts b/src/app/api/public/proposals/[token]/route.ts new file mode 100644 index 0000000..ac0e16f --- /dev/null +++ b/src/app/api/public/proposals/[token]/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createHash } from 'node:crypto'; +import { eq, asc } from 'drizzle-orm'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { resolveClientIp } from '@/server/http/client-ip'; +import { consumeProposalRateLimit } from '@/server/auth/rate-limiter'; +import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; + +export const dynamic = 'force-dynamic'; + +interface RouteContext { + params: Promise<{ token: string }>; + client?: DbClient; +} + +/** + * Public proposal rate-limited inspection endpoint (TASK-0062 / AC-1, AC-6). + * Protected by fail-closed atomic rate limiting (30 requests/minute per client IP). + */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + const client = context.client ?? defaultDb; + const { token } = await context.params; + + // Resolve client IP + const clientIp = resolveClientIp(request.headers) ?? '127.0.0.1'; + + // Atomic rate limiting check (30 req/min fail-closed per AC-6) + try { + const rateCheck = await consumeProposalRateLimit(clientIp, client); + if (!rateCheck.allowed) { + return new NextResponse('Too Many Requests', { + status: 429, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } + } catch { + return new NextResponse('Service Unavailable', { + status: 503, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } + + if (!token || typeof token !== 'string') { + return new NextResponse('Proposal not found', { + status: 404, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + const tokenHash = createHash('sha256').update(token).digest('hex'); + + const [proposal] = await client + .select() + .from(proposals) + .where(eq(proposals.tokenHash, tokenHash)) + .limit(1); + + if (!proposal) { + return new NextResponse('Proposal not found', { + status: 404, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + if (proposal.tokenRevokedAt !== null) { + return new NextResponse('Proposal not found', { + status: 404, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + const lineItems = await client + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, proposal.id)) + .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); + + // Strip internal notes and sensitive studio data per GDPR Art. 5 + const sanitizedProposal = { + id: proposal.id, + title: proposal.title, + status: proposal.status, + currency: proposal.currency, + subtotalMinor: proposal.subtotalMinor, + taxMinor: proposal.taxMinor, + totalMinor: proposal.totalMinor, + pricesIncludeTax: proposal.pricesIncludeTax, + isKleinunternehmer: proposal.isKleinunternehmer, + taxNotice: proposal.taxNotice, + validUntil: proposal.validUntil ? proposal.validUntil.toISOString() : null, + acceptedAt: proposal.acceptedAt ? proposal.acceptedAt.toISOString() : null, + acceptedByName: proposal.acceptedByName, + acceptedByEmail: proposal.acceptedByEmail, + acceptedOptionsSnapshot: proposal.acceptedOptionsSnapshot, + lineItems: lineItems.map((item) => ({ + id: item.id, + itemType: item.itemType, + name: item.name, + description: item.description, + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + minQuantity: item.minQuantity, + maxQuantity: item.maxQuantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isOptional: item.isOptional, + isSelected: item.isSelected, + position: item.position, + })), + }; + + return NextResponse.json( + { proposal: sanitizedProposal }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex, nofollow, noarchive', + }, + }, + ); +} diff --git a/src/components/proposals/public/accept-proposal-modal.tsx b/src/components/proposals/public/accept-proposal-modal.tsx new file mode 100644 index 0000000..782d2f5 --- /dev/null +++ b/src/components/proposals/public/accept-proposal-modal.tsx @@ -0,0 +1,239 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + type AcceptanceFormErrors, + createInitialModalFormState, + modalFormReducer, + createAcceptanceModalController, +} from './public-proposal-helpers'; + +export interface AcceptProposalModalProps { + isOpen: boolean; + onClose: () => void; + triggerRef?: React.RefObject; + proposalTitle: string; + formattedTotal: string; + isSubmitting: boolean; + serverError?: string | null; + defaultName?: string; + defaultEmail?: string; + initialErrors?: AcceptanceFormErrors; + onSubmit: (data: { name: string; email: string }) => Promise | void; +} + +export function AcceptProposalModal({ + isOpen, + onClose, + triggerRef, + proposalTitle, + formattedTotal, + isSubmitting, + serverError, + defaultName = '', + defaultEmail = '', + initialErrors, + onSubmit, +}: AcceptProposalModalProps) { + const t = useTranslations('proposals.public'); + + const [state, dispatch] = React.useReducer(modalFormReducer, { + ...createInitialModalFormState(defaultName, defaultEmail), + errors: initialErrors ?? {}, + }); + + React.useEffect(() => { + dispatch({ type: 'SET_DEFAULTS', defaultName, defaultEmail }); + }, [defaultName, defaultEmail]); + + const translateValidation = React.useCallback((k: string) => t(`validation.${k}` as const), [t]); + + const controller = React.useMemo(() => { + return createAcceptanceModalController({ + state, + dispatch, + t: translateValidation, + triggerRef, + onClose, + onSubmit, + }); + }, [state, dispatch, translateValidation, triggerRef, onClose, onSubmit]); + + return ( + + + +
+
+ + {proposalTitle + ? `${proposalTitle} — ${t('modal.description')}` + : t('modal.description')} + +
+ + {serverError && ( +
+
+ )} + +
{ + void controller.handleSubmit(e); + }} + noValidate + className="space-y-4 pt-2" + > + {/* Full Name Field */} +
+ + + {state.errors.name && ( + + )} +
+ + {/* Email Address Field */} +
+ + + {state.errors.email && ( + + )} +
+ + {/* Agreement Checkbox */} +
+
+ + +
+ {state.errors.agreement && ( + + )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/proposals/public/public-proposal-helpers.ts b/src/components/proposals/public/public-proposal-helpers.ts new file mode 100644 index 0000000..a8ec493 --- /dev/null +++ b/src/components/proposals/public/public-proposal-helpers.ts @@ -0,0 +1,473 @@ +import { calculateProposalTotals, type TaxCalculationResult } from '@/server/proposals/calculator'; + +export type PublicProposalLifecycleState = 'active' | 'expired' | 'revoked' | 'accepted'; + +export interface PublicLineItem { + id: string; + itemType: 'base_package' | 'addon' | 'custom'; + name: string; + description?: string | null; + unitPriceMinor: number; + quantity: number; + minQuantity: number; + maxQuantity: number; + taxRateBasisPoints: number; + isOptional: boolean; + isSelected: boolean; + position: number; +} + +export interface ClientItemSelection { + isSelected: boolean; + quantity: number; +} + +/** + * Resolves proposal lifecycle state based on commercial status, revocation, and expiration dates. + */ +export function resolveProposalLifecycleState( + proposal: { + status: string; + tokenRevokedAt?: Date | string | null; + validUntil?: Date | string | null; + }, + now: Date = new Date(), +): PublicProposalLifecycleState { + if (proposal.status === 'accepted') { + return 'accepted'; + } + if (proposal.tokenRevokedAt !== null && proposal.tokenRevokedAt !== undefined) { + return 'revoked'; + } + if (proposal.validUntil) { + const validUntilDate = new Date(proposal.validUntil); + if (validUntilDate.getTime() < now.getTime()) { + return 'expired'; + } + } + return 'active'; +} + +/** + * Formats an integer minor currency value into localized currency string. + */ +export function formatProposalCurrency( + minorUnits: number, + currency = 'EUR', + locale = 'en', +): string { + const amount = minorUnits / 100; + try { + return new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `€${amount.toFixed(2)}`; + } +} + +/** + * Formats an ISO date string or Date object into localized medium date. + */ +export function formatProposalDate(dateValue: Date | string, locale = 'en'): string { + const date = typeof dateValue === 'string' ? new Date(dateValue) : dateValue; + if (Number.isNaN(date.getTime())) { + return String(dateValue); + } + try { + return new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { + dateStyle: 'medium', + }).format(date); + } catch { + return date.toISOString().slice(0, 10); + } +} + +/** + * Calculates real-time proposal totals based on user selections and quantities. + */ +export function calculateLiveTotals( + items: PublicLineItem[], + selections: Record, + options: { + pricesIncludeTax: boolean; + isKleinunternehmer: boolean; + }, +): TaxCalculationResult { + const calculationItems = items.map((item) => { + const userSelection = selections[item.id]; + let isSelected = item.isSelected; + let quantity = item.quantity; + + if (item.itemType === 'base_package') { + isSelected = true; + quantity = Math.max(1, item.quantity); + } else if (item.isOptional) { + if (userSelection) { + isSelected = userSelection.isSelected; + quantity = userSelection.quantity; + } + } else { + isSelected = true; + } + + return { + unitPriceMinor: item.unitPriceMinor, + quantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isSelected, + }; + }); + + return calculateProposalTotals(calculationItems, options); +} + +export interface AcceptanceFormErrors { + name?: string; + email?: string; + agreement?: string; +} + +/** + * Validates the acceptance modal inputs. + */ +export function validateAcceptanceForm( + name: string, + email: string, + agreement: boolean, + t: (key: string) => string, +): { valid: boolean; errors: AcceptanceFormErrors } { + const errors: AcceptanceFormErrors = {}; + + const cleanName = name.trim(); + if (!cleanName) { + errors.name = t('nameRequired'); + } + + const cleanEmail = email.trim(); + if (!cleanEmail) { + errors.email = t('emailRequired'); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) { + errors.email = t('emailInvalid'); + } + + if (!agreement) { + errors.agreement = t('agreementRequired'); + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} + +/** + * Toggles an optional add-on selection state. + */ +export function applyAddonToggle( + currentSelections: Record, + id: string, + checked: boolean, +): Record { + const existing = Reflect.get(currentSelections, id) as ClientItemSelection | undefined; + const current = existing ?? { isSelected: false, quantity: 1 }; + return { + ...currentSelections, + [id]: { + ...current, + isSelected: checked, + }, + }; +} + +/** + * Adjusts quantity for an item within its minQuantity and maxQuantity limits. + */ +export function applyQuantityChange( + currentSelections: Record, + item: PublicLineItem, + delta: number, +): Record { + const existing = Reflect.get(currentSelections, item.id) as ClientItemSelection | undefined; + const current = existing ?? { isSelected: true, quantity: item.quantity }; + const newQty = Math.max(item.minQuantity, Math.min(item.maxQuantity, current.quantity + delta)); + return { + ...currentSelections, + [item.id]: { + ...current, + quantity: newQty, + isSelected: newQty > 0 ? true : current.isSelected, + }, + }; +} + +/** + * Builds acceptance server action payload from user selections and line items. + */ +export function buildAcceptProposalPayload( + token: string, + name: string, + email: string, + selections: Record, + lineItems: PublicLineItem[], +) { + const selectedOptions = Object.entries(selections) + .filter(([id, sel]) => { + const item = lineItems.find((i) => i.id === id); + return sel.isSelected && item?.itemType !== 'base_package'; + }) + .map(([id, sel]) => ({ + id, + quantity: sel.quantity, + })); + + return { + token, + acceptedByName: name.trim(), + acceptedByEmail: email.trim(), + selectedOptions, + agreementConfirmed: true, + }; +} + +export interface AcceptanceModalFormState { + name: string; + email: string; + agreement: boolean; + touched: boolean; + errors: AcceptanceFormErrors; +} + +export function createInitialModalFormState( + defaultName = '', + defaultEmail = '', +): AcceptanceModalFormState { + return { + name: defaultName, + email: defaultEmail, + agreement: false, + touched: false, + errors: {}, + }; +} + +export type ModalFormAction = + | { type: 'CHANGE_NAME'; value: string; t: (k: string) => string } + | { type: 'CHANGE_EMAIL'; value: string; t: (k: string) => string } + | { type: 'CHANGE_AGREEMENT'; value: boolean; t: (k: string) => string } + | { type: 'SET_DEFAULTS'; defaultName: string; defaultEmail: string } + | { type: 'SUBMIT_ATTEMPT'; t: (k: string) => string }; + +export function modalFormReducer( + state: AcceptanceModalFormState, + action: ModalFormAction, +): AcceptanceModalFormState { + switch (action.type) { + case 'CHANGE_NAME': { + const errors = state.touched + ? validateAcceptanceForm(action.value, state.email, state.agreement, action.t).errors + : state.errors; + return { ...state, name: action.value, errors }; + } + case 'CHANGE_EMAIL': { + const errors = state.touched + ? validateAcceptanceForm(state.name, action.value, state.agreement, action.t).errors + : state.errors; + return { ...state, email: action.value, errors }; + } + case 'CHANGE_AGREEMENT': { + const errors = state.touched + ? validateAcceptanceForm(state.name, state.email, action.value, action.t).errors + : state.errors; + return { ...state, agreement: action.value, errors }; + } + case 'SET_DEFAULTS': { + return { + ...state, + name: state.name || action.defaultName, + email: state.email || action.defaultEmail, + }; + } + case 'SUBMIT_ATTEMPT': { + const validation = validateAcceptanceForm(state.name, state.email, state.agreement, action.t); + return { + ...state, + touched: true, + errors: validation.errors, + }; + } + default: + return state; + } +} + +export interface ModalControllerParams { + state: AcceptanceModalFormState; + dispatch: (action: ModalFormAction) => void; + t: (k: string) => string; + triggerRef?: { current: { focus: () => void } | null } | null; + onClose?: () => void; + onSubmit: (data: { name: string; email: string }) => Promise | void; +} + +export function createAcceptanceModalController(params: ModalControllerParams) { + const { state, dispatch, t, triggerRef, onClose, onSubmit } = params; + + return { + handleOpenChange: (open: boolean) => { + if (!open && onClose) onClose(); + }, + handleNameChange: (e: { target: { value: string } } | string) => { + const value = typeof e === 'string' ? e : e.target.value; + dispatch({ type: 'CHANGE_NAME', value, t }); + }, + handleEmailChange: (e: { target: { value: string } } | string) => { + const value = typeof e === 'string' ? e : e.target.value; + dispatch({ type: 'CHANGE_EMAIL', value, t }); + }, + handleAgreementChange: (e: { target: { checked: boolean } } | boolean) => { + const value = typeof e === 'boolean' ? e : e.target.checked; + dispatch({ type: 'CHANGE_AGREEMENT', value, t }); + }, + handleCloseAutoFocus: (e: { preventDefault: () => void }) => { + e.preventDefault(); + triggerRef?.current?.focus(); + }, + handleSubmit: async (e?: { preventDefault: () => void }) => { + e?.preventDefault(); + dispatch({ type: 'SUBMIT_ATTEMPT', t }); + const validation = validateAcceptanceForm(state.name, state.email, state.agreement, t); + if (!validation.valid) { + return false; + } + await onSubmit({ name: state.name.trim(), email: state.email.trim() }); + return true; + }, + }; +} + +export interface ViewControllerParams { + token: string; + lineItems: PublicLineItem[]; + setSelections: ( + updater: (prev: Record) => Record, + ) => void; + setIsSubmitting: (submitting: boolean) => void; + setServerError: (error: string | null) => void; + setModalOpen: (open: boolean) => void; + setCurrentStatus: (status: string) => void; + setAcceptedDetails: (details: { + acceptedAt: string; + acceptedByName: string; + acceptedByEmail: string; + }) => void; + acceptAction: (payload: { + token: string; + acceptedByName: string; + acceptedByEmail: string; + selectedOptions: { id: string; quantity: number }[]; + agreementConfirmed: boolean; + }) => Promise<{ success: boolean; error?: string }>; +} + +export function createProposalViewController(params: ViewControllerParams) { + const { + token, + lineItems, + setSelections, + setIsSubmitting, + setServerError, + setModalOpen, + setCurrentStatus, + setAcceptedDetails, + acceptAction, + } = params; + + return { + handleOpenModal: () => { + setModalOpen(true); + }, + handleCloseModal: () => { + setModalOpen(false); + }, + handleToggleAddon: (id: string, checked: boolean) => { + setSelections((prev) => applyAddonToggle(prev, id, checked)); + }, + handleToggleAddonById: (e: { + currentTarget: { getAttribute: (name: string) => string | null; checked: boolean }; + }) => { + const id = e.currentTarget.getAttribute('data-item-id'); + const isChecked = e.currentTarget.checked; + if (id) { + setSelections((prev) => applyAddonToggle(prev, id, isChecked)); + } + }, + handleQuantityChange: (id: string, delta: number) => { + const item = lineItems.find((i) => i.id === id); + if (!item) return; + setSelections((prev) => applyQuantityChange(prev, item, delta)); + }, + handleDecreaseQuantity: (e: { + currentTarget: { getAttribute: (name: string) => string | null }; + }) => { + const id = e.currentTarget.getAttribute('data-item-id'); + if (!id) return; + const item = lineItems.find((i) => i.id === id); + if (item) { + setSelections((prev) => applyQuantityChange(prev, item, -1)); + } + }, + handleIncreaseQuantity: (e: { + currentTarget: { getAttribute: (name: string) => string | null }; + }) => { + const id = e.currentTarget.getAttribute('data-item-id'); + if (!id) return; + const item = lineItems.find((i) => i.id === id); + if (item) { + setSelections((prev) => applyQuantityChange(prev, item, 1)); + } + }, + handleAcceptSubmit: async ( + formData: { name: string; email: string }, + selections: Record, + ) => { + setIsSubmitting(true); + setServerError(null); + + const payload = buildAcceptProposalPayload( + token, + formData.name, + formData.email, + selections, + lineItems, + ); + + try { + const result = await acceptAction(payload); + if (result.success) { + setModalOpen(false); + setCurrentStatus('accepted'); + setAcceptedDetails({ + acceptedAt: new Date().toISOString(), + acceptedByName: formData.name, + acceptedByEmail: formData.email, + }); + return { success: true }; + } else { + setServerError(result.error ?? 'Failed to accept proposal'); + return { success: false, error: result.error }; + } + } catch (err) { + const errorMsg = + err instanceof Error ? err.message : 'Internal error processing acceptance'; + setServerError(errorMsg); + return { success: false, error: errorMsg }; + } finally { + setIsSubmitting(false); + } + }, + }; +} diff --git a/src/components/proposals/public/public-proposal-view.tsx b/src/components/proposals/public/public-proposal-view.tsx new file mode 100644 index 0000000..04579a0 --- /dev/null +++ b/src/components/proposals/public/public-proposal-view.tsx @@ -0,0 +1,535 @@ +'use client'; + +import * as React from 'react'; +import { useTranslations } from 'next-intl'; +import { + CheckCircle2, + Clock, + Ban, + PackageCheck, + Building2, + Calendar, + Check, + Plus, + Minus, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + type PublicLineItem, + type ClientItemSelection, + resolveProposalLifecycleState, + formatProposalCurrency, + formatProposalDate, + calculateLiveTotals, + createProposalViewController, +} from './public-proposal-helpers'; +import { AcceptProposalModal } from './accept-proposal-modal'; +import { acceptProposalAction } from '@/server/proposals/public-actions'; + +export interface PublicProposalViewProps { + proposal: { + id: string; + title: string; + status: string; + currency: string; + subtotalMinor: number; + taxMinor: number; + totalMinor: number; + pricesIncludeTax: boolean; + isKleinunternehmer: boolean; + taxNotice?: string | null; + validUntil?: string | null; + tokenRevokedAt?: string | null; + acceptedAt?: string | null; + acceptedByName?: string | null; + acceptedByEmail?: string | null; + lineItems: PublicLineItem[]; + }; + studioName: string; + token: string; + locale: string; + clientContactName?: string; + clientContactEmail?: string; +} + +export function PublicProposalView({ + proposal, + studioName, + token, + locale, + clientContactName = '', + clientContactEmail = '', +}: PublicProposalViewProps) { + const t = useTranslations('proposals.public'); + const triggerRef = React.useRef(null); + + // Initialize line items selection state + const initialSelections = React.useMemo(() => { + const map: Record = {}; + for (const item of proposal.lineItems) { + map[item.id] = { + isSelected: item.isSelected, + quantity: item.quantity, + }; + } + return map; + }, [proposal.lineItems]); + + const [selections, setSelections] = + React.useState>(initialSelections); + const [modalOpen, setModalOpen] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [serverError, setServerError] = React.useState(null); + const [currentStatus, setCurrentStatus] = React.useState(proposal.status); + const [acceptedDetails, setAcceptedDetails] = React.useState<{ + acceptedAt?: string | null; + acceptedByName?: string | null; + acceptedByEmail?: string | null; + } | null>( + proposal.status === 'accepted' + ? { + acceptedAt: proposal.acceptedAt, + acceptedByName: proposal.acceptedByName, + acceptedByEmail: proposal.acceptedByEmail, + } + : null, + ); + + // Real-time calculation of monetary totals + const liveTotals = React.useMemo(() => { + return calculateLiveTotals(proposal.lineItems, selections, { + pricesIncludeTax: proposal.pricesIncludeTax, + isKleinunternehmer: proposal.isKleinunternehmer, + }); + }, [proposal.lineItems, selections, proposal.pricesIncludeTax, proposal.isKleinunternehmer]); + + const lifecycleState = resolveProposalLifecycleState({ + status: currentStatus, + tokenRevokedAt: proposal.tokenRevokedAt, + validUntil: proposal.validUntil, + }); + + const formattedTotal = formatProposalCurrency(liveTotals.totalMinor, proposal.currency, locale); + + // Group line items + const basePackages = proposal.lineItems.filter((i) => i.itemType === 'base_package'); + const addons = proposal.lineItems.filter((i) => i.itemType !== 'base_package'); + + const controller = React.useMemo(() => { + return createProposalViewController({ + token, + lineItems: proposal.lineItems, + setSelections, + setIsSubmitting, + setServerError, + setModalOpen, + setCurrentStatus, + setAcceptedDetails, + acceptAction: acceptProposalAction, + }); + }, [token, proposal.lineItems]); + + const handleModalSubmit = React.useCallback( + async (formData: { name: string; email: string }) => { + await controller.handleAcceptSubmit(formData, selections); + }, + [controller, selections], + ); + + return ( +
+ {/* Studio Brand Header */} +
+
+
+
+
+

+ {proposal.title} +

+ + {lifecycleState === 'accepted' + ? t('statusAccepted') + : lifecycleState === 'expired' + ? t('statusExpired') + : lifecycleState === 'revoked' + ? t('statusRevoked') + : t('statusActive')} + +
+
+ + {proposal.validUntil && ( +
+
+ )} +
+ + {/* State Banners */} + {lifecycleState === 'revoked' && ( +
+
+
+
+ )} + + {lifecycleState === 'expired' && ( +
+
+
+
+ )} + + {lifecycleState === 'accepted' && ( +
+
+
+
+ )} + + {/* Screen Reader Live Recalculation Region (WCAG 4.1.3) */} +
+ {t('totalAmount')}: {formattedTotal} +
+ + {/* Base Package Section */} + {basePackages.length > 0 && ( +
+

+

+ +
+ {basePackages.map((pkg) => ( +
+
+
+

{pkg.name}

+ {pkg.description && ( +

+ {pkg.description} +

+ )} +
+
+ + {formatProposalCurrency( + pkg.unitPriceMinor * pkg.quantity, + proposal.currency, + locale, + )} + +

+ {pkg.quantity > 1 + ? `${String(pkg.quantity)} × ${formatProposalCurrency(pkg.unitPriceMinor, proposal.currency, locale)}` + : t('included')} +

+
+
+
+ ))} +
+
+ )} + + {/* Optional Add-ons Section */} + {addons.length > 0 && ( +
+
+

+ {t('addonsTitle')} +

+

{t('addonsSubtitle')}

+
+ +
+ {addons.map((addon) => { + const selection = selections[addon.id] ?? { + isSelected: addon.isSelected, + quantity: addon.quantity, + }; + const isInteractive = lifecycleState === 'active' && addon.isOptional; + const isChecked = addon.isOptional ? selection.isSelected : true; + const currentQty = addon.isOptional ? selection.quantity : addon.quantity; + + return ( +
+
+ {addon.isOptional ? ( +
+ +
+ ) : ( +
+ + +
+ )} + +
+ + {addon.description && ( +

+ {addon.description} +

+ )} + {!addon.isOptional && ( + + {t('included')} + + )} +
+
+ +
+ {/* Quantity controls if maxQuantity > 1 */} + {addon.maxQuantity > 1 && isInteractive && isChecked && ( +
+ + + {currentQty} + + +
+ )} + +
+
+ {formatProposalCurrency( + addon.unitPriceMinor * (isChecked ? currentQty : 1), + proposal.currency, + locale, + )} +
+
+ {currentQty > 1 && isChecked + ? `${String(currentQty)} × ${formatProposalCurrency(addon.unitPriceMinor, proposal.currency, locale)}` + : formatProposalCurrency(addon.unitPriceMinor, proposal.currency, locale)} +
+
+
+
+ ); + })} +
+
+ )} + + {/* Commercial Summary Card */} +
+

+ {t('pricingSummaryTitle')} +

+ +
+
+ {t('subtotal')} + + {formatProposalCurrency(liveTotals.subtotalMinor, proposal.currency, locale)} + +
+ + {/* §19 UStG or VAT Breakdown */} + {proposal.isKleinunternehmer ? ( +
+ {proposal.taxNotice ?? t('kleinunternehmerNotice')} +
+ ) : ( + liveTotals.breakdown.map((rate) => ( +
+ {t('vatRate', { rate: rate.taxRatePercentFormatted })} + + {formatProposalCurrency(rate.taxMinor, proposal.currency, locale)} + +
+ )) + )} + +
+
+ {t('totalAmount')} +

+ {proposal.pricesIncludeTax ? t('pricesIncludeTax') : t('pricesExcludeTax')} +

+
+ + {formattedTotal} + +
+
+ + {/* Action Button */} + {lifecycleState === 'active' && ( +
+ +
+ )} +
+ + {/* Studio Support Footer */} +
+

{t('studioContact', { studioName })}

+
+ + {/* Acceptance Modal */} + +
+ ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 949868e..9115ab9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -19,6 +19,7 @@ const PUBLIC_PATH_SEGMENTS = [ 'verify-email', 'f', 'book', + 'p', ]; /** @@ -49,6 +50,18 @@ function isPublicBookingRoute(pathname: string): boolean { return false; } +/** + * Checks whether the request path is a public proposal route (/{locale}/p/** or /p/**). + */ +export function isPublicProposalRoute(pathname: string): boolean { + const segments = pathname.split('/').filter(Boolean); + if (segments[0] === 'p') return true; + if (segments[0] && routing.locales.includes(segments[0] as 'en' | 'de') && segments[1] === 'p') { + return true; + } + return false; +} + /** * Checks whether the request path is an inquiry form embed route * (/{locale}/f/[slug]/embed or .../embed/thanks, or /f/[slug]/embed or .../embed/thanks). @@ -290,11 +303,21 @@ export default function middleware(request: NextRequest): NextResponse { response.headers.set('x-middleware-request-x-is-auth-route', '1'); } - // Strictly no Set-Cookie on public form routes, booking routes, and embed routes (AC-1 / AC-12 / TDDDG §25) - if (isPublicFormRoute(pathname) || isPublicBookingRoute(pathname) || isEmbed) { + // Strictly no Set-Cookie on public form routes, booking routes, proposal routes, and embed routes (AC-1 / AC-12 / TDDDG §25) + if ( + isPublicFormRoute(pathname) || + isPublicBookingRoute(pathname) || + isPublicProposalRoute(pathname) || + isEmbed + ) { response.headers.delete('set-cookie'); } + // Inject search engine blocking headers on proposal routes (ADR-0018 §3.1 / AC-1) + if (isPublicProposalRoute(pathname)) { + response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive'); + } + return response; } diff --git a/src/server/auth/public-actions.ts b/src/server/auth/public-actions.ts index 8dea558..f505634 100644 --- a/src/server/auth/public-actions.ts +++ b/src/server/auth/public-actions.ts @@ -30,6 +30,8 @@ export const PUBLIC_AUTH_ACTIONS = [ 'getPublicBookingChallengeAction', // Compensating control: Public appointment reservation; strict Zod validation, ALTCHA PoW verification, honeypot field, per-IP rate limiting (5 per 15 min), and serializable double-booking collision rollback. 'submitPublicBookingAction', + // Compensating control: Public client proposal acceptance; cryptographically random 256-bit unguessable proposal token verification (SHA-256), per-IP rate limiting (30 req/min fail-closed), serializable transaction snapshot isolation, token revocation check, and validUntil expiration validation. + 'acceptProposalAction', ] as const; export type PublicAuthAction = (typeof PUBLIC_AUTH_ACTIONS)[number]; diff --git a/src/server/auth/rate-limiter.ts b/src/server/auth/rate-limiter.ts index 6b317b2..644021c 100644 --- a/src/server/auth/rate-limiter.ts +++ b/src/server/auth/rate-limiter.ts @@ -610,3 +610,22 @@ export async function consumeBookingRateLimit( const key = `booking:${anonymizeIp(ip)}`; return await consumeRateLimit(key, BOOKING_IP_MAX_ATTEMPTS, BOOKING_IP_WINDOW_MS, client); } + +// ========================================== +// Public Proposal Acceptance Rate Limiting (TASK-0062 / AC-6) +// ========================================== + +export const PROPOSAL_IP_MAX_ATTEMPTS = 30; +export const PROPOSAL_IP_WINDOW_MS = 60 * 1000; // 1 minute (30 req/min) + +/** + * Consumes one proposal access attempt atomically per client IP (30 req/min). + * Enforces fail-closed rate limiting against brute-force token enumeration (ADR-0018 §3.1 / AC-6). + */ +export async function consumeProposalRateLimit( + ip: string, + client: DbClient = defaultDb, +): Promise { + const key = `proposal:${anonymizeIp(ip)}`; + return await consumeRateLimit(key, PROPOSAL_IP_MAX_ATTEMPTS, PROPOSAL_IP_WINDOW_MS, client); +} diff --git a/src/server/proposals/public-actions.ts b/src/server/proposals/public-actions.ts new file mode 100644 index 0000000..e656144 --- /dev/null +++ b/src/server/proposals/public-actions.ts @@ -0,0 +1,387 @@ +'use server'; + +import 'server-only'; +import { createHash } from 'node:crypto'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import { eq, asc } from 'drizzle-orm'; +import { db as defaultDb, type DbClient } from '@/server/db/client'; +import { resolveClientIp } from '@/server/http/client-ip'; +import { consumeProposalRateLimit, anonymizeIp } from '@/server/auth/rate-limiter'; +import { requireOwnerSession } from '@/server/auth/guards'; +import { audit } from '@/server/audit'; +import { recordTimelineEvent } from '@/server/timeline/service'; +import { enqueueProposalAcceptedEmails } from '@/server/email/templates/proposal'; +import { logger } from '@/server/log'; +import { + proposals, + proposalLineItems, + type Proposal, + type ProposalLineItem, +} from '@/server/db/schema/proposals'; +import { projects, pipelineStages } from '@/server/db/schema/projects'; +import { calculateProposalTotals } from './calculator'; +import { createAcceptedOptionsSnapshot } from './snapshot'; +import { acceptProposalSchema, type AcceptProposalInput } from './schema'; + +export type PublicProposalActionResult = + { success: true; data: T } | { success: false; code: string; error: string; fields?: string[] }; + +function safeRevalidate(path: string): void { + try { + revalidatePath(path); + } catch { + // Ignore outside Next.js request context (e.g. tests) + } +} + +/** + * Accepts a proposal from the public client acceptance ceremony (TASK-0062 / ADR-0018 §3.3). + * - Validates proposal exists by tokenHash = sha256(token). + * - Rate-limits client IP (30 req/min fail-closed). + * - Runs in a PostgreSQL serializable transaction. + * - Enforces not revoked, not expired, status === 'sent' (or previewable 'draft' for studio owner). + * - Freezes accepted_options_snapshot (jsonb) with exact minor units totals. + * - Anonymizes client IP address and captures truncated User-Agent. + * - Transitions status = 'accepted' and sets accepted_at = now(). + * - Updates projects.price_minor and currency, and if stage category is 'inquiry', advances to 'booked'. + * - Emits proposal.accepted timeline and audit events. + * - Enqueues proposal_accepted_client and proposal_accepted_owner in email_outbox. + */ +export async function acceptProposalAction( + rawInput: unknown, + explicitClient?: DbClient, +): Promise< + PublicProposalActionResult<{ + proposalId: string; + status: string; + totalMinor: number; + currency: string; + }> +> { + const client = explicitClient ?? defaultDb; + + // 1. Validate input schema + const parsed = acceptProposalSchema.safeParse(rawInput); + if (!parsed.success) { + return { + success: false, + code: 'VALIDATION_FAILED', + error: parsed.error.issues[0]?.message ?? 'Validation failed', + fields: parsed.error.issues.map((i) => i.path.join('.')), + }; + } + const data: AcceptProposalInput = parsed.data; + + // 2. Resolve client IP and User-Agent + let clientIp = '127.0.0.1'; + let userAgent: string | null = null; + try { + const h = await headers(); + clientIp = resolveClientIp(h) ?? '127.0.0.1'; + const rawUa = h.get('user-agent'); + if (rawUa) { + userAgent = rawUa.slice(0, 255); + } + } catch { + // Outside Next.js request context (e.g. testing) + } + + // 3. Rate limiting (30 requests/minute per client IP) + try { + const rateCheck = await consumeProposalRateLimit(clientIp, client); + if (!rateCheck.allowed) { + return { + success: false, + code: 'RATE_LIMITED', + error: 'Too Many Requests', + }; + } + } catch (rateErr) { + logger.warn({ error: String(rateErr) }, 'Proposal rate limit check failed; failing closed'); + return { + success: false, + code: 'SERVICE_UNAVAILABLE', + error: 'Service temporarily unavailable', + }; + } + + const tokenHash = createHash('sha256').update(data.token).digest('hex'); + const now = new Date(); + + try { + return await client.transaction( + async (tx) => { + // Look up proposal by tokenHash + const [proposal] = await tx + .select() + .from(proposals) + .where(eq(proposals.tokenHash, tokenHash)) + .limit(1); + + if (!proposal) { + return { + success: false, + code: 'NOT_FOUND', + error: 'Proposal not found', + }; + } + + // Validate token revocation + if (proposal.tokenRevokedAt !== null) { + return { + success: false, + code: 'REVOKED', + error: 'Proposal link has been revoked', + }; + } + + // Validate already accepted + if (proposal.status === 'accepted') { + return { + success: false, + code: 'ALREADY_ACCEPTED', + error: 'Proposal has already been accepted', + }; + } + + // Validate validity expiration date + if (proposal.validUntil && proposal.validUntil < now) { + return { + success: false, + code: 'EXPIRED', + error: 'Proposal has expired', + }; + } + + // Validate status: must be 'sent' or previewable 'draft' for studio owner + if (proposal.status !== 'sent') { + let isOwner = false; + try { + const ownerAuth = await requireOwnerSession({ client: tx }); + isOwner = ownerAuth.user.role === 'owner'; + } catch { + isOwner = false; + } + + if (proposal.status !== 'draft' || !isOwner) { + return { + success: false, + code: 'INVALID_STATUS', + error: `Proposal cannot be accepted in '${proposal.status}' status`, + }; + } + } + + // Fetch line items + const lineItems = await tx + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, proposal.id)) + .orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt)); + + // Process client selection map: itemId -> desired quantity + const selectedQtyMap = new Map(); + + if (data.selectedOptions && data.selectedOptions.length > 0) { + for (const opt of data.selectedOptions) { + selectedQtyMap.set(opt.id, opt.quantity ?? 1); + } + } + + if (data.selectedOptionIds && data.selectedOptionIds.length > 0) { + for (const opt of data.selectedOptionIds) { + if (typeof opt === 'string') { + if (!selectedQtyMap.has(opt)) { + selectedQtyMap.set(opt, 1); + } + } else if (typeof opt === 'object' && 'id' in opt) { + selectedQtyMap.set(opt.id, opt.quantity ?? 1); + } + } + } + + // Update line items selection and quantity + const updatedItems: ProposalLineItem[] = []; + for (const item of lineItems) { + let isSelected = item.isSelected; + let quantity = item.quantity; + + if (item.itemType === 'base_package') { + // Base packages cannot be deselected + isSelected = true; + quantity = Math.max(1, item.quantity); + } else if (item.isOptional) { + // Optional add-ons: toggled if present in client selection map + if (selectedQtyMap.has(item.id)) { + isSelected = true; + const requestedQty = selectedQtyMap.get(item.id) ?? 1; + quantity = Math.max(item.minQuantity, Math.min(item.maxQuantity, requestedQty)); + } else { + isSelected = false; + } + } else { + // Non-optional deliverables are always included + isSelected = true; + } + + updatedItems.push({ + ...item, + isSelected, + quantity, + updatedAt: now, + }); + + await tx + .update(proposalLineItems) + .set({ + isSelected, + quantity, + updatedAt: now, + }) + .where(eq(proposalLineItems.id, item.id)); + } + + // Recalculate totals on the server + const calcResult = calculateProposalTotals( + updatedItems.map((item) => ({ + unitPriceMinor: item.unitPriceMinor, + quantity: item.quantity, + taxRateBasisPoints: item.taxRateBasisPoints, + isSelected: item.isSelected, + })), + { + pricesIncludeTax: proposal.pricesIncludeTax, + isKleinunternehmer: proposal.isKleinunternehmer, + }, + ); + + // Freeze accepted_options_snapshot + const snapshot = createAcceptedOptionsSnapshot(proposal, updatedItems, { + acceptedByName: data.acceptedByName, + acceptedByEmail: data.acceptedByEmail, + }); + + const anonymizedIp = anonymizeIp(clientIp); + + // Update proposal row to 'accepted' + const [acceptedProposal] = (await tx + .update(proposals) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByName: data.acceptedByName, + acceptedByEmail: data.acceptedByEmail, + acceptanceIpAnonymized: anonymizedIp, + acceptanceUserAgent: userAgent, + acceptedOptionsSnapshot: snapshot, + subtotalMinor: calcResult.subtotalMinor, + taxMinor: calcResult.taxMinor, + totalMinor: calcResult.totalMinor, + updatedAt: now, + }) + .where(eq(proposals.id, proposal.id)) + .returning()) as [Proposal]; + + // Synchronize project price and currency, and advance 'inquiry' stage to 'booked' (ADR-0018 §3.3) + const [project] = await tx + .select({ + id: projects.id, + stageId: projects.stageId, + stageCategory: pipelineStages.category, + }) + .from(projects) + .innerJoin(pipelineStages, eq(projects.stageId, pipelineStages.id)) + .where(eq(projects.id, proposal.projectId)) + .limit(1); + + let targetStageId = project?.stageId; + if (project?.stageCategory === 'inquiry') { + const [bookedStage] = await tx + .select({ id: pipelineStages.id }) + .from(pipelineStages) + .where(eq(pipelineStages.category, 'booked')) + .orderBy(asc(pipelineStages.position)) + .limit(1); + if (bookedStage) { + targetStageId = bookedStage.id; + } + } + + await tx + .update(projects) + .set({ + priceMinor: acceptedProposal.totalMinor, + currency: acceptedProposal.currency, + stageId: targetStageId, + updatedAt: now, + }) + .where(eq(projects.id, proposal.projectId)); + + // Record client relationship timeline event + await recordTimelineEvent(tx, { + clientId: acceptedProposal.clientId, + type: 'proposal.accepted', + actorType: 'client', + actorId: null, + subjectType: 'proposal', + subjectId: acceptedProposal.id, + data: { + proposal_id: acceptedProposal.id, + total_minor: acceptedProposal.totalMinor, + currency: acceptedProposal.currency, + }, + }); + + // Emit audit log event with anonymized IP metadata + await audit( + { + actorType: 'client', + actorId: 'anonymous_client', + action: 'proposal.accepted', + targetType: 'proposal', + targetId: acceptedProposal.id, + outcome: 'success', + metadata: { + proposal_id: acceptedProposal.id, + project_id: acceptedProposal.projectId, + client_id: acceptedProposal.clientId, + total_minor: acceptedProposal.totalMinor, + currency: acceptedProposal.currency, + }, + }, + tx, + ); + + // Enqueue transactional confirmation emails in email_outbox (ADR-0018 §5 / AC-3) + await enqueueProposalAcceptedEmails(acceptedProposal.id, tx); + + safeRevalidate(`/projects/${acceptedProposal.projectId}`); + safeRevalidate(`/p/${data.token}`); + + return { + success: true, + data: { + proposalId: acceptedProposal.id, + status: acceptedProposal.status, + totalMinor: acceptedProposal.totalMinor, + currency: acceptedProposal.currency, + }, + }; + }, + { isolationLevel: 'serializable' }, + ); + } catch (err) { + logger.error( + { + error: err instanceof Error ? err.message : String(err), + }, + 'Unexpected error in acceptProposalAction', + ); + return { + success: false, + code: 'INTERNAL_ERROR', + error: err instanceof Error ? err.message : 'Internal error processing acceptance', + }; + } +} diff --git a/src/server/proposals/schema.ts b/src/server/proposals/schema.ts index 421f8d3..4ae2b70 100644 --- a/src/server/proposals/schema.ts +++ b/src/server/proposals/schema.ts @@ -149,3 +149,49 @@ export const revokeProposalTokenSchema = z export type RevokeProposalTokenInput = z.input; export type RevokeProposalTokenOutput = z.output; + +/** + * Schema for an accepted option item selection. + */ +export const acceptedOptionItemSchema = z + .object({ + id: z.string().uuid('Option ID must be a valid UUID'), + quantity: z.number().int().min(1, 'Quantity must be at least 1').default(1), + }) + .strict(); + +export type AcceptedOptionItemInput = z.infer; + +/** + * Schema for public client proposal acceptance (TASK-0062 / ADR-0018 §3.3). + */ +export const acceptProposalSchema = z + .object({ + token: z.string().trim().min(1, 'Proposal token is required'), + acceptedByName: z + .string() + .trim() + .min(1, 'Full name is required') + .max(200, 'Name cannot exceed 200 characters'), + acceptedByEmail: z + .string() + .trim() + .email('Invalid email address') + .max(255, 'Email cannot exceed 255 characters'), + selectedOptions: z.array(acceptedOptionItemSchema).optional().default([]), + selectedOptionIds: z + .union([ + z.array(z.string().uuid('Option ID must be a valid UUID')), + z.array(acceptedOptionItemSchema), + ]) + .optional(), + agreementConfirmed: z.boolean().optional().default(true), + }) + .strict() + .refine((data) => data.agreementConfirmed, { + message: 'Agreement checkbox must be confirmed', + path: ['agreementConfirmed'], + }); + +export type AcceptProposalInput = z.input; +export type AcceptProposalOutput = z.output; diff --git a/tests/e2e/availability-settings.spec.ts b/tests/e2e/availability-settings.spec.ts index fc9efde..bce8ac0 100644 --- a/tests/e2e/availability-settings.spec.ts +++ b/tests/e2e/availability-settings.spec.ts @@ -142,7 +142,9 @@ test.describe('TASK-0043: Availability Schedules, Date Overrides, & Booking Type await page.waitForTimeout(300); // Allow Radix transition to settle // Fill form - const overrideDate = '2026-11-20'; + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + const overrideDate = futureDate.toISOString().slice(0, 10); await page.fill('[data-testid="override-date-input"]', overrideDate); await page.fill('[data-testid="override-reason-input"]', 'Annual Studio Maintenance'); diff --git a/tests/e2e/public-proposal-acceptance.spec.ts b/tests/e2e/public-proposal-acceptance.spec.ts new file mode 100644 index 0000000..b8534fb --- /dev/null +++ b/tests/e2e/public-proposal-acceptance.spec.ts @@ -0,0 +1,377 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import { E2E_OWNER_EMAIL, E2E_OWNER_PASSWORD } from '../helpers/e2e-global-setup'; +import { E2E_PACKAGE_WEDDING_ID, E2E_CATALOGUE_ITEM_1_ID } from '../helpers/e2e-proposals-helper'; + +const VALID_SETUP_TOKEN = + process.env.SETUP_TOKEN ?? 'c4d7e8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9'; + +function getLatestRunDir(): string { + if (process.env.RUN_DIR) return process.env.RUN_DIR; + const runsRoot = path.resolve(import.meta.dirname, '../../../../control/ownlight/runs/TASK-0062'); + if (fs.existsSync(runsRoot)) { + const entries = fs + .readdirSync(runsRoot) + .filter((d) => d.startsWith('iter-')) + .sort(); + const last = entries[entries.length - 1]; + if (last) { + return last; + } + } + return 'iter-01'; +} + +const RUN_DIR_PARAM = getLatestRunDir(); +const UX_RUN_DIR = path.resolve( + import.meta.dirname, + `../../../../control/ownlight/runs/TASK-0062/${RUN_DIR_PARAM}/ux`, +); + +async function ensureOwnerBootstrappedAndSignedIn(page: import('@playwright/test').Page) { + await page.goto('/en/setup'); + const setupForm = page.locator('[data-testid="setup-form"]'); + if (await setupForm.isVisible({ timeout: 1500 }).catch(() => false)) { + await page.fill('[data-testid="setup-token-input"]', VALID_SETUP_TOKEN); + await page.fill('[data-testid="name-input"]', 'Studio Owner'); + await page.fill('[data-testid="email-input"]', E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="setup-submit-btn"]'); + await expect(page).not.toHaveURL(/\/setup/); + return; + } + + await page.goto('/en/sign-in'); + const emailInput = page.locator('[data-testid="email-input"]'); + if (await emailInput.isVisible({ timeout: 1500 }).catch(() => false)) { + await emailInput.fill(E2E_OWNER_EMAIL); + await page.fill('[data-testid="password-input"]', E2E_OWNER_PASSWORD); + await page.click('[data-testid="signin-submit-btn"]'); + await expect(page).not.toHaveURL(/\/sign-in$/); + } +} + +test.describe('TASK-0062: Public Interactive Proposal Acceptance E2E Tests', () => { + let createdProjectId = ''; + let proposalRawToken = ''; + let publicProposalUrl = ''; + + test.beforeAll(async ({ browser }) => { + fs.mkdirSync(UX_RUN_DIR, { recursive: true }); + + // 1. Setup owner page to compose and send a test proposal + const page = await browser.newPage(); + await ensureOwnerBootstrappedAndSignedIn(page); + + // 2. Create isolated project via New Project modal + await page.goto('/en/projects?view=list'); + const newProjectBtn = page.locator('[data-testid="new-project-btn"]').first(); + await expect(newProjectBtn).toBeVisible({ timeout: 10000 }); + await newProjectBtn.click(); + + const dialog = page.locator('[data-testid="new-project-dialog"]'); + await expect(dialog).toBeVisible(); + + await page.locator('[data-testid="new-project-title-input"]').fill('Acceptance E2E Commission'); + await page.locator('[data-testid="new-project-type-select"]').selectOption('commercial'); + await page.locator('[data-testid="new-project-price-input"]').fill('2800.00'); + + await page.locator('[data-testid="new-project-submit-btn"]').click(); + await expect(page).toHaveURL(/\/en\/projects\/[0-9a-f-]{36}/, { timeout: 10000 }); + + const createdUrl = page.url(); + const parsedId = createdUrl.split('/').pop()?.split('?')[0]; + if (parsedId) { + createdProjectId = parsedId; + } + expect(createdProjectId).not.toBe(''); + + // 3. Compose a proposal with base package and optional add-on + await page.goto(`/en/projects/${createdProjectId}/proposals/new`); + await expect(page.locator('[data-testid="proposal-builder-view"]')).toBeVisible({ + timeout: 10000, + }); + + // Select base package + const packageSelect = page.locator('[data-testid="package-select"]'); + await expect(packageSelect).toBeVisible(); + await packageSelect.selectOption(E2E_PACKAGE_WEDDING_ID); + + // Add optional add-on + const addonSelect = page.locator('[data-testid="catalogue-addon-select"]'); + await addonSelect.selectOption(E2E_CATALOGUE_ITEM_1_ID); + await page.locator('[data-testid="add-catalogue-addon-btn"]').click(); + + // Configure add-on as optional with max quantity = 2 + const optionalToggle = page.locator('[data-testid="addon-optional-toggle-0"]'); + await optionalToggle.check(); + const maxQtyInput = page.locator('[data-testid="addon-max-quantity-0"]'); + await maxQtyInput.fill('2'); + + // Save draft + await page.locator('[data-testid="save-draft-btn"]').click(); + await expect(page).toHaveURL( + new RegExp(`.*/projects/${createdProjectId}/proposals/[0-9a-f-]{36}`), + { timeout: 10000 }, + ); + + // Send proposal to client + const openSendBtn = page.locator('[data-testid="open-send-proposal-btn"]'); + await expect(openSendBtn).toBeVisible({ timeout: 10000 }); + await openSendBtn.click(); + + const sendModal = page.locator('[data-testid="send-proposal-modal"]'); + await expect(sendModal).toBeVisible(); + await page.waitForTimeout(300); + + const emailInput = page.locator('[data-testid="send-recipient-email-input"]'); + await emailInput.fill('emma.watson@example.com'); + + await page.locator('[data-testid="submit-send-proposal-btn"]').click(); + + // Read generated proposal URL + const tokenUrlElement = page.locator('[data-testid="proposal-token-url"]'); + await expect(tokenUrlElement).toBeVisible({ timeout: 10000 }); + publicProposalUrl = await tokenUrlElement.inputValue(); + expect(publicProposalUrl).toContain('/p/'); + + const tokenMatch = /\/p\/([a-zA-Z0-9_-]+)/.exec(publicProposalUrl); + if (tokenMatch?.[1]) { + proposalRawToken = tokenMatch[1]; + } + expect(proposalRawToken).not.toBe(''); + + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + // Clean up created project to preserve test isolation across pipeline Kanban tests + if (createdProjectId) { + const page = await browser.newPage(); + await ensureOwnerBootstrappedAndSignedIn(page); + await page.goto('/en/projects?view=list'); + + page.once('dialog', (dialog) => void dialog.accept()); + const trigger = page.locator(`[data-testid="project-actions-trigger-${createdProjectId}"]`); + if (await trigger.isVisible({ timeout: 2000 }).catch(() => false)) { + await trigger.click(); + const deleteBtn = page.locator(`[data-testid="project-action-delete-${createdProjectId}"]`); + await deleteBtn.click(); + await expect( + page.locator(`[data-testid="project-table-row-${createdProjectId}"]`), + ).not.toBeVisible({ timeout: 5000 }); + } + await page.close(); + } + }); + + test('AC-1: Cookieless protection & security headers on public proposal route', async ({ + request, + }) => { + expect(proposalRawToken).toBeDefined(); + const response = await request.get(`/en/p/${proposalRawToken}`); + expect(response.status()).toBe(200); + + const headers = response.headers(); + // Verify X-Robots-Tag prevents search engine indexing + expect(headers['x-robots-tag']).toBe('noindex, nofollow, noarchive'); + // Verify set-cookie is stripped + expect(headers['set-cookie']).toBeUndefined(); + }); + + test('AC-2 & AC-3: Public proposal client acceptance journey, reactive calculation, modal validation, and acceptance confirmation', async ({ + browser, + }) => { + test.setTimeout(90000); + expect(proposalRawToken).toBeDefined(); + + // Client visits in a fresh unauthenticated browser context + const clientContext = await browser.newContext(); + const page = await clientContext.newPage(); + + await page.goto(`/en/p/${proposalRawToken}`); + const proposalView = page.locator('[data-testid="public-proposal-view"]'); + await expect(proposalView).toBeVisible({ timeout: 10000 }); + + // 1. Verify deliverables & initial pricing + const basePkg = page.locator('[data-testid^="base-package-"]'); + await expect(basePkg).toBeVisible(); + await expect(basePkg).toContainText('Intimate Wedding Story'); + await expect(basePkg).toContainText('8 Hours Continuous Coverage'); + + // Initial total is €3,050.00 (€2,800 base + €250 add-on checked by default) + const totalDisplay = page.locator('[data-testid="summary-total"]'); + await expect(totalDisplay).toContainText('3,050.00'); + + // 2. Toggle optional add-on checkbox off + const addonToggle = page.locator('[data-testid^="addon-checkbox-"]'); + await expect(addonToggle).toBeVisible(); + await addonToggle.click(); + + // Subtotal and total reactively update to €2,800.00 + await expect(totalDisplay).toContainText('2,800.00'); + + // Toggle back on + await addonToggle.click(); + await expect(totalDisplay).toContainText('3,050.00'); + + // 3. Increase add-on quantity from 1 to 2 + const increaseBtn = page.locator('[data-testid="addon-qty-increase"]'); + await expect(increaseBtn).toBeVisible(); + await increaseBtn.click(); + + // Subtotal and total reactively update to €3,300.00 (€2,800 + €250 * 2) + await expect(totalDisplay).toContainText('3,300.00'); + + // 4. Decrease back to quantity 1 + const decreaseBtn = page.locator('[data-testid="addon-qty-decrease"]'); + await expect(decreaseBtn).toBeVisible(); + await decreaseBtn.click(); + await expect(totalDisplay).toContainText('3,050.00'); + + // Capture desktop proposal screenshot for UX review + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-public-en.png'), + fullPage: true, + }); + + // 5. Open Accept Proposal modal (WCAG 2.4.3 focus restoration check) + const openAcceptBtn = page.locator('[data-testid="accept-proposal-btn"]'); + await expect(openAcceptBtn).toBeVisible(); + await openAcceptBtn.click(); + + const acceptModal = page.locator('[data-testid="accept-proposal-dialog"]'); + await expect(acceptModal).toBeVisible(); + await page.waitForTimeout(300); // Wait for modal fade-in animation + + // Capture modal screenshot for UX review + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-public-modal.png'), + }); + + // 6. Test form validation on empty submit (WCAG 3.3.1 error association) + const submitBtn = page.locator('[data-testid="confirm-accept-btn"]'); + const nameInput = page.locator('[data-testid="accepted-name-input"]'); + const emailInput = page.locator('[data-testid="accepted-email-input"]'); + const agreementCheckbox = page.locator('[data-testid="agreement-checkbox"]'); + + await nameInput.fill(''); + await emailInput.fill(''); + if (await agreementCheckbox.isChecked()) { + await agreementCheckbox.uncheck(); + } + await submitBtn.click(); + + // Verify name error and programmatic attributes + await expect(nameInput).toHaveAttribute('aria-invalid', 'true'); + await expect(nameInput).toHaveAttribute('aria-describedby', 'accepted-by-name-error'); + await expect(page.locator('#accepted-by-name-error')).toBeVisible(); + + // Verify email error and programmatic attributes + await expect(emailInput).toHaveAttribute('aria-invalid', 'true'); + await expect(emailInput).toHaveAttribute('aria-describedby', 'accepted-by-email-error'); + await expect(page.locator('#accepted-by-email-error')).toBeVisible(); + + // Verify agreement error and programmatic attributes + await expect(agreementCheckbox).toHaveAttribute('aria-invalid', 'true'); + await expect(agreementCheckbox).toHaveAttribute('aria-describedby', 'agreement-error'); + await expect(page.locator('#agreement-error')).toBeVisible(); + + // 7. Fill valid form data + await nameInput.fill('Emma Watson'); + await emailInput.fill('emma.watson@example.com'); + await agreementCheckbox.check(); + + // 8. Submit acceptance + await submitBtn.click(); + + // Verify modal closes + await expect(acceptModal).not.toBeVisible({ timeout: 10000 }); + + // Verify accepted banner is displayed + const acceptedBanner = page.locator('[data-testid="proposal-accepted-banner"]'); + await expect(acceptedBanner).toBeVisible({ timeout: 10000 }); + await expect(acceptedBanner).toContainText('Emma Watson'); + + // Verify "Accept Proposal" button is no longer visible + await expect(openAcceptBtn).not.toBeVisible(); + + // Verify add-on controls are now disabled + await expect(addonToggle).toBeDisabled(); + + // Capture accepted state screenshot for UX review + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-public-accepted.png'), + fullPage: true, + }); + + await clientContext.close(); + }); + + test('AC-7: German localized proposal view /de/p/[token]', async ({ browser }) => { + test.setTimeout(60000); + expect(proposalRawToken).toBeDefined(); + + const clientContext = await browser.newContext(); + const page = await clientContext.newPage(); + + await page.goto(`/de/p/${proposalRawToken}`); + const proposalView = page.locator('[data-testid="public-proposal-view"]'); + await expect(proposalView).toBeVisible({ timeout: 10000 }); + + // Verify German translated badge and text + const statusBadge = page.locator('[data-testid="proposal-lifecycle-badge"]'); + await expect(statusBadge).toContainText('Angenommen'); + + const acceptedBanner = page.locator('[data-testid="proposal-accepted-banner"]'); + await expect(acceptedBanner).toBeVisible(); + await expect(acceptedBanner).toContainText('Angebot angenommen'); + + // Capture German view screenshot for UX review + await page.screenshot({ + path: path.join(UX_RUN_DIR, 'desktop-proposal-public-de.png'), + fullPage: true, + }); + + await clientContext.close(); + }); + + test('AC-2 & AC-7: WCAG 2.2 AA Accessibility (Axe scan across Light and Dark themes)', async ({ + browser, + }) => { + test.setTimeout(90000); + expect(proposalRawToken).toBeDefined(); + + const clientContext = await browser.newContext(); + const page = await clientContext.newPage(); + + // 1. Light Mode Axe Scan on accepted view + await page.goto(`/en/p/${proposalRawToken}`); + await expect(page.locator('[data-testid="public-proposal-view"]')).toBeVisible(); + + const axeLight = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(axeLight.violations).toEqual([]); + + // 2. Dark Mode Axe Scan + await page.emulateMedia({ colorScheme: 'dark' }); + await page.waitForTimeout(300); + + const axeDark = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(axeDark.violations).toEqual([]); + + await clientContext.close(); + }); + + test('AC-4: Non-existent proposal token renders 404 Not Found', async ({ page }) => { + const response = await page.goto( + '/en/p/0000000000000000000000000000000000000000000000000000000000000000', + ); + expect(response?.status()).toBe(404); + }); +}); diff --git a/tests/integration/google-calendar-outbound-sync.test.ts b/tests/integration/google-calendar-outbound-sync.test.ts index db52dbf..ebbfe7b 100644 --- a/tests/integration/google-calendar-outbound-sync.test.ts +++ b/tests/integration/google-calendar-outbound-sync.test.ts @@ -719,6 +719,7 @@ describe('TASK-0046: Google Calendar & Meet Sync Integration (AC-2, AC-3, AC-5, endDate: '2026-11-02', timezone: 'Europe/Berlin', db: dbClient, + now: new Date('2026-11-01T00:00:00Z'), }); // Verify that no available slot overlaps with 10:00 - 12:00 UTC (11:00 - 13:00 Berlin) diff --git a/tests/integration/proposal-acceptance.test.ts b/tests/integration/proposal-acceptance.test.ts new file mode 100644 index 0000000..8917aa5 --- /dev/null +++ b/tests/integration/proposal-acceptance.test.ts @@ -0,0 +1,625 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { createHash } from 'node:crypto'; + +vi.mock('server-only', () => ({})); + +const mockHeadersStore = new Map(); +const mockCookiesStore = new Map(); + +vi.mock('next/headers', () => ({ + headers: () => + Promise.resolve({ + get: (name: string) => mockHeadersStore.get(name.toLowerCase()) ?? null, + }), + cookies: () => + Promise.resolve({ + get: (name: string) => { + const val = mockCookiesStore.get(name); + return val ? { name, value: val } : undefined; + }, + }), +})); + +import { createIsolatedTestDatabase, type TestDatabaseInstance } from '../helpers/db-test-helper'; +import type { DbClient } from '@/server/db/client'; +import { user } from '@/server/db/schema/auth'; +import { clients, clientContacts } from '@/server/db/schema/clients'; +import { projects, pipelineStages } from '@/server/db/schema/projects'; +import { proposals, proposalLineItems } from '@/server/db/schema/proposals'; +import { emailOutbox } from '@/server/db/schema/email-outbox'; +import { auditEvents } from '@/server/db/schema/audit'; +import { clientTimelineEvents } from '@/server/db/schema/timeline'; +import { studioSettings } from '@/server/db/schema/studio-settings'; +import { generateUuidV7 } from '@/lib/id'; +import { seedDefaultPipelineStages } from '@/server/db/seeds/pipeline-stages'; +import { acceptProposalAction } from '@/server/proposals/public-actions'; +import { consumeProposalRateLimit } from '@/server/auth/rate-limiter'; +import { GET as publicProposalGet } from '@/app/api/public/proposals/[token]/route'; +import middleware from '@/middleware'; + +describe('TASK-0062: Proposal Public Acceptance Integration Tests', () => { + let testDb: TestDatabaseInstance; + let dbClient: DbClient; + let inquiryStageId: string; + let bookedStageId: string; + let testClientId: string; + let testProjectId: string; + let activeProposalId: string; + const rawToken = 'test-token-32-byte-length-secure-url-safe-abc123'; + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + + beforeEach(async () => { + testDb = await createIsolatedTestDatabase(); + dbClient = testDb.db as unknown as DbClient; + + mockHeadersStore.clear(); + mockCookiesStore.clear(); + + // 1. Seed pipeline stages + await seedDefaultPipelineStages(dbClient); + const stages = await dbClient.select().from(pipelineStages); + const inq = stages.find((s) => s.category === 'inquiry'); + const bkd = stages.find((s) => s.category === 'booked'); + if (!inq || !bkd) { + throw new Error('Default pipeline stages missing'); + } + inquiryStageId = inq.id; + bookedStageId = bkd.id; + + // 2. Studio settings + await dbClient.insert(studioSettings).values({ + id: generateUuidV7(), + studio_name: 'Solstice Light Studio', + default_locale: 'en', + timezone: 'Europe/Berlin', + currency: 'EUR', + mfa_required: false, + }); + + // 3. Studio Owner + await dbClient.insert(user).values({ + id: generateUuidV7(), + name: 'Studio Owner', + email: 'owner@example.org', + role: 'owner', + }); + + // 4. Client & Contact + testClientId = generateUuidV7(); + await dbClient.insert(clients).values({ + id: testClientId, + kind: 'person', + displayName: 'Emma & Liam Client', + preferredLocale: 'en', + }); + + await dbClient.insert(clientContacts).values({ + id: generateUuidV7(), + clientId: testClientId, + givenName: 'Emma', + familyName: 'Client', + email: 'emma@example.com', + emailNormalized: 'emma@example.com', + isPrimary: true, + }); + + // 5. Project in 'inquiry' stage + testProjectId = generateUuidV7(); + await dbClient.insert(projects).values({ + id: testProjectId, + clientId: testClientId, + stageId: inquiryStageId, + title: 'Emma & Liam Wedding Commission', + currency: 'EUR', + priceMinor: 0, + }); + + // 6. Proposal in 'sent' status + activeProposalId = generateUuidV7(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); + + await dbClient.insert(proposals).values({ + id: activeProposalId, + projectId: testProjectId, + clientId: testClientId, + title: 'Wedding Photography Proposal', + status: 'sent', + version: 1, + currency: 'EUR', + subtotalMinor: 200000, + taxMinor: 31933, + totalMinor: 200000, + pricesIncludeTax: true, + isKleinunternehmer: false, + validUntil: futureDate, + tokenHash, + tokenRevokedAt: null, + sentAt: new Date(), + }); + + // 7. Line items: 1 base package, 1 optional addon + await dbClient.insert(proposalLineItems).values([ + { + id: generateUuidV7(), + proposalId: activeProposalId, + itemType: 'base_package', + name: 'Full Day Wedding Coverage', + unitPriceMinor: 200000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + }, + { + id: generateUuidV7(), + proposalId: activeProposalId, + itemType: 'addon', + name: 'Fine Art Linen Album', + unitPriceMinor: 45000, + quantity: 1, + minQuantity: 0, + maxQuantity: 3, + taxRateBasisPoints: 1900, + isOptional: true, + isSelected: false, + position: 1, + }, + ]); + }); + + afterEach(async () => { + await testDb.destroy(); + }); + + describe('AC-1: Middleware cookieless header verification', () => { + it('AC-1: strips set-cookie and injects X-Robots-Tag: noindex, nofollow, noarchive on /p/[token] and /en/p/[token]', () => { + // Direct /p/[token] + const req1 = new NextRequest(`http://localhost:3000/p/${rawToken}`); + const res1 = middleware(req1); + expect(res1.headers.get('set-cookie')).toBeNull(); + expect(res1.headers.get('x-robots-tag')).toBe('noindex, nofollow, noarchive'); + + // Localized /en/p/[token] + const req2 = new NextRequest(`http://localhost:3000/en/p/${rawToken}`); + const res2 = middleware(req2); + expect(res2.headers.get('set-cookie')).toBeNull(); + expect(res2.headers.get('x-robots-tag')).toBe('noindex, nofollow, noarchive'); + + // Localized /de/p/[token] + const req3 = new NextRequest(`http://localhost:3000/de/p/${rawToken}`); + const res3 = middleware(req3); + expect(res3.headers.get('set-cookie')).toBeNull(); + expect(res3.headers.get('x-robots-tag')).toBe('noindex, nofollow, noarchive'); + }); + }); + + describe('AC-6: Rate limiting integration test on public proposal endpoint', () => { + it('AC-6: allows up to 30 requests/min and returns HTTP 429 on 31st attempt from same IP bucket', async () => { + const testIp = '198.51.100.22'; + + // 30 valid requests + for (let i = 1; i <= 30; i++) { + const req = new NextRequest(`http://localhost:3000/api/public/proposals/${rawToken}`, { + headers: { 'x-forwarded-for': testIp }, + }); + const res = await publicProposalGet(req, { + params: Promise.resolve({ token: rawToken }), + client: dbClient, + }); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + } + + // 31st request from same /24 subnet returns 429 + const req31 = new NextRequest(`http://localhost:3000/api/public/proposals/${rawToken}`, { + headers: { 'x-forwarded-for': '198.51.100.99' }, // Same /24 + }); + const res31 = await publicProposalGet(req31, { + params: Promise.resolve({ token: rawToken }), + client: dbClient, + }); + expect(res31.status).toBe(429); + expect(await res31.text()).toBe('Too Many Requests'); + + // Request from another IP subnet succeeds + const reqOther = new NextRequest(`http://localhost:3000/api/public/proposals/${rawToken}`, { + headers: { 'x-forwarded-for': '203.0.113.88' }, + }); + const resOther = await publicProposalGet(reqOther, { + params: Promise.resolve({ token: rawToken }), + client: dbClient, + }); + expect(resOther.status).toBe(200); + }); + }); + + describe('AC-3: acceptProposalAction serializable execution', () => { + it('AC-3: successfully accepts proposal, freezes snapshot, advances project to booked stage, and enqueues confirmation emails', async () => { + mockHeadersStore.set('x-forwarded-for', '198.51.100.50'); + mockHeadersStore.set('user-agent', 'Mozilla/5.0 Acceptance Test Client'); + + const lineItems = await dbClient + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, activeProposalId)); + + const addonItem = lineItems.find((i) => i.itemType === 'addon'); + expect(addonItem).toBeDefined(); + if (!addonItem) throw new Error('Addon item not found'); + + // Client accepts proposal selecting 2 units of the linen album + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma.client@example.com', + selectedOptions: [{ id: addonItem.id, quantity: 2 }], + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error(result.error); + + expect(result.data.status).toBe('accepted'); + expect(result.data.totalMinor).toBe(290000); // 200000 base + (45000 * 2) + + // 1. Verify proposal row state + const [updatedProposal] = await dbClient + .select() + .from(proposals) + .where(eq(proposals.id, activeProposalId)); + + expect(updatedProposal?.status).toBe('accepted'); + expect(updatedProposal?.acceptedAt).not.toBeNull(); + expect(updatedProposal?.acceptedByName).toBe('Emma Watson'); + expect(updatedProposal?.acceptedByEmail).toBe('emma.client@example.com'); + expect(updatedProposal?.acceptanceIpAnonymized).toBe('198.51.100.0'); // IPv4 /24 mask + expect(updatedProposal?.totalMinor).toBe(290000); + + // 2. Verify frozen immutable snapshot + const snapshot = updatedProposal?.acceptedOptionsSnapshot; + expect(snapshot).not.toBeNull(); + expect(snapshot?.acceptedByName).toBe('Emma Watson'); + expect(snapshot?.acceptedByEmail).toBe('emma.client@example.com'); + expect(snapshot?.totalMinor).toBe(290000); + expect(snapshot?.items).toHaveLength(2); + + // 3. Verify project price sync and atomic stage transition (inquiry -> booked) + const [updatedProject] = await dbClient + .select() + .from(projects) + .where(eq(projects.id, testProjectId)); + + expect(updatedProject?.priceMinor).toBe(290000); + expect(updatedProject?.currency).toBe('EUR'); + expect(updatedProject?.stageId).toBe(bookedStageId); + + // 4. Verify email outbox enqueued items (client confirmation and owner alert) + const outboxRows = await dbClient + .select() + .from(emailOutbox) + .where(eq(emailOutbox.proposalId, activeProposalId)); + + expect(outboxRows).toHaveLength(2); + const clientEmail = outboxRows.find((e) => e.kind === 'proposal_accepted_client'); + const ownerEmail = outboxRows.find((e) => e.kind === 'proposal_accepted_owner'); + + expect(clientEmail).toBeDefined(); + expect(clientEmail?.recipient).toBe('emma.client@example.com'); + expect(ownerEmail).toBeDefined(); + expect(ownerEmail?.recipient).toBe('owner@example.org'); + + // 5. Verify timeline event + const timelineRows = await dbClient + .select() + .from(clientTimelineEvents) + .where(eq(clientTimelineEvents.clientId, testClientId)); + + const acceptedTimeline = timelineRows.find((t) => t.type === 'proposal.accepted'); + expect(acceptedTimeline).toBeDefined(); + expect(acceptedTimeline?.subjectId).toBe(activeProposalId); + + // 6. Verify audit log event + const auditRows = await dbClient + .select() + .from(auditEvents) + .where(eq(auditEvents.targetId, activeProposalId)); + + const acceptedAudit = auditRows.find((a) => a.action === 'proposal.accepted'); + expect(acceptedAudit).toBeDefined(); + expect(acceptedAudit?.outcome).toBe('success'); + expect(acceptedAudit?.metadata).toMatchObject({ + proposal_id: activeProposalId, + total_minor: 290000, + currency: 'EUR', + }); + }); + + it('AC-3: prevents deselecting base package deliverable even if omitted in client payload', async () => { + // Client submits only empty selectedOptions + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + selectedOptions: [], + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error(result.error); + + // Total still includes base package (200000) + expect(result.data.totalMinor).toBe(200000); + + const [updatedProposal] = await dbClient + .select() + .from(proposals) + .where(eq(proposals.id, activeProposalId)); + + const snapshot = updatedProposal?.acceptedOptionsSnapshot; + expect(snapshot?.items.some((i) => i.itemType === 'base_package')).toBe(true); + }); + + it('AC-4: rejects acceptance attempt when validUntil is in the past', async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 2); + + await dbClient + .update(proposals) + .set({ validUntil: pastDate }) + .where(eq(proposals.id, activeProposalId)); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('EXPIRED'); + }); + + it('AC-5: rejects acceptance attempt when proposal token is revoked', async () => { + await dbClient + .update(proposals) + .set({ tokenRevokedAt: new Date() }) + .where(eq(proposals.id, activeProposalId)); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('REVOKED'); + }); + + it('AC-3: rejects acceptance when proposal is already accepted', async () => { + await dbClient + .update(proposals) + .set({ status: 'accepted', acceptedAt: new Date() }) + .where(eq(proposals.id, activeProposalId)); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('ALREADY_ACCEPTED'); + }); + + it('AC-3: rejects acceptance when proposal status is not sent or previewable draft', async () => { + await dbClient + .update(proposals) + .set({ status: 'declined' }) + .where(eq(proposals.id, activeProposalId)); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('INVALID_STATUS'); + }); + + it('AC-3: handles selectedOptionIds object items and preserves non-optional custom deliverables', async () => { + // Reset proposal to sent + await dbClient + .update(proposals) + .set({ status: 'sent', acceptedAt: null }) + .where(eq(proposals.id, activeProposalId)); + + // Add a non-optional custom deliverable to the proposal + await dbClient.insert(proposalLineItems).values({ + id: crypto.randomUUID(), + proposalId: activeProposalId, + itemType: 'custom', + name: 'Extra Photo Retouching', + unitPriceMinor: 10000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 3, + }); + + const lineItems = await dbClient + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, activeProposalId)); + const addon = lineItems.find((i) => i.itemType === 'addon'); + expect(addon).toBeDefined(); + if (!addon) throw new Error('Addon item missing'); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + selectedOptionIds: [{ id: addon.id, quantity: 1 }], + }, + dbClient, + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error(result.error); + expect(result.data.status).toBe('accepted'); + }); + + it('AC-3: handles selectedOptionIds as string array', async () => { + // Reset proposal to sent + await dbClient + .update(proposals) + .set({ status: 'sent', acceptedAt: null }) + .where(eq(proposals.id, activeProposalId)); + + const lineItems = await dbClient + .select() + .from(proposalLineItems) + .where(eq(proposalLineItems.proposalId, activeProposalId)); + const addon = lineItems.find((i) => i.itemType === 'addon'); + expect(addon).toBeDefined(); + if (!addon) throw new Error('Addon item missing'); + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + selectedOptionIds: [addon.id], + }, + dbClient, + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error(result.error); + expect(result.data.status).toBe('accepted'); + }); + + it('AC-3: handles unexpected transaction error and returns INTERNAL_ERROR', async () => { + const txSpy = vi + .spyOn(dbClient, 'transaction') + .mockRejectedValueOnce(new Error('Fatal Postgres connection crash')); + + try { + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('INTERNAL_ERROR'); + expect(result.error).toBe('Fatal Postgres connection crash'); + } finally { + txSpy.mockRestore(); + } + }); + + it('AC-3: returns NOT_FOUND when proposal token does not exist', async () => { + const result = await acceptProposalAction( + { + token: 'non-existent-token-abc-123', + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('NOT_FOUND'); + }); + + it('AC-6: returns RATE_LIMITED when acceptProposalAction rate check fails', async () => { + mockHeadersStore.set('x-forwarded-for', '198.51.100.99'); + // Exhaust rate limit for this IP + for (let i = 0; i < 30; i++) { + await consumeProposalRateLimit('198.51.100.99', dbClient); + } + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + dbClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('RATE_LIMITED'); + }); + + it('AC-6: returns SERVICE_UNAVAILABLE when rate limit check throws an error', async () => { + mockHeadersStore.set('x-forwarded-for', '198.51.100.123'); + const brokenClient = { + insert: vi.fn().mockImplementation(() => { + throw new Error('Redis/PGlite rate table crashed'); + }), + } as unknown as typeof dbClient; + + const result = await acceptProposalAction( + { + token: rawToken, + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + agreementConfirmed: true, + }, + brokenClient, + ); + + expect(result.success).toBe(false); + if (result.success) throw new Error('Expected failure'); + expect(result.code).toBe('SERVICE_UNAVAILABLE'); + }); + }); +}); diff --git a/tests/unit/proposal-public-schema.test.ts b/tests/unit/proposal-public-schema.test.ts new file mode 100644 index 0000000..c5ecdd7 --- /dev/null +++ b/tests/unit/proposal-public-schema.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); +import { acceptProposalSchema } from '@/server/proposals/schema'; +import { anonymizeIp } from '@/server/auth/rate-limiter'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +describe('TASK-0062: Public Proposal Schema & Unit Tests', () => { + describe('acceptProposalSchema input validation', () => { + const validUuid1 = '018f3a2b-4c5d-7e8f-9a0b-1c2d3e4f5a6b'; + const validUuid2 = '018f3a2b-4c5d-7e8f-9a0b-1c2d3e4f5a6c'; + + it('AC-3: accepts valid proposal acceptance payload with selectedOptions', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + selectedOptions: [ + { id: validUuid1, quantity: 2 }, + { id: validUuid2, quantity: 1 }, + ], + agreementConfirmed: true, + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.token).toBe('test-token-123'); + expect(result.data.acceptedByName).toBe('Jane Doe'); + expect(result.data.acceptedByEmail).toBe('jane@example.com'); + expect(result.data.selectedOptions).toHaveLength(2); + } + }); + + it('AC-3: accepts payload with selectedOptionIds as string array', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + selectedOptionIds: [validUuid1, validUuid2], + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.selectedOptionIds).toEqual([validUuid1, validUuid2]); + expect(result.data.agreementConfirmed).toBe(true); + } + }); + + it('AC-3: accepts payload with selectedOptionIds as object array', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + selectedOptionIds: [{ id: validUuid1, quantity: 3 }], + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it('AC-3: rejects empty or missing token', () => { + const input = { + token: ' ', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects empty or missing acceptedByName', () => { + const input = { + token: 'test-token-123', + acceptedByName: ' ', + acceptedByEmail: 'jane@example.com', + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects name exceeding 200 characters', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'A'.repeat(201), + acceptedByEmail: 'jane@example.com', + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects invalid email address', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'not-an-email', + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects agreementConfirmed when explicitly set to false', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + agreementConfirmed: false, + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects negative or zero quantity in selectedOptions', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + selectedOptions: [{ id: validUuid1, quantity: 0 }], + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + it('AC-3: rejects unrecognized keys under strict mode', () => { + const input = { + token: 'test-token-123', + acceptedByName: 'Jane Doe', + acceptedByEmail: 'jane@example.com', + unknownField: 'malicious', + }; + + const result = acceptProposalSchema.safeParse(input); + expect(result.success).toBe(false); + }); + }); + + describe('anonymizeIp data minimisation (GDPR Art. 5 / TDDDG §25)', () => { + it('AC-3: correctly masks IPv4 addresses to /24 subnet (zeroes last octet)', () => { + expect(anonymizeIp('192.168.1.150')).toBe('192.168.1.0'); + expect(anonymizeIp('10.0.50.99')).toBe('10.0.50.0'); + expect(anonymizeIp('172.16.254.1')).toBe('172.16.254.0'); + }); + + it('AC-3: correctly masks IPv6 addresses to /48 subnet', () => { + expect(anonymizeIp('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe('2001:0db8:85a3::/48'); + expect(anonymizeIp('2001:db8:abcd:0012::1')).toBe('2001:db8:abcd::/48'); + }); + + it('AC-3: handles empty and malformed IP gracefully', () => { + expect(anonymizeIp('')).toBe('0.0.0.0'); + expect(anonymizeIp(' ')).toBe('0.0.0.0'); + expect(anonymizeIp('unknown-host')).toBe('unknown-host'); + }); + }); + + describe('AC-7: translation key parity in en.json and de.json', () => { + it('AC-7: proposals.public exists in en.json and de.json with parity', () => { + const enProposals = (enMessages as Record).proposals as Record< + string, + unknown + >; + const deProposals = (deMessages as Record).proposals as Record< + string, + unknown + >; + + expect(enProposals).toBeDefined(); + expect(deProposals).toBeDefined(); + + const enPublic = enProposals.public as Record; + const dePublic = deProposals.public as Record; + + expect(enPublic).toBeDefined(); + expect(dePublic).toBeDefined(); + + const getLeafKeys = (obj: Record, prefix = ''): string[] => { + let keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const currentKey = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'object' && v !== null && !Array.isArray(v)) { + keys = keys.concat(getLeafKeys(v as Record, currentKey)); + } else { + keys.push(currentKey); + } + } + return keys.sort(); + }; + + const enKeys = getLeafKeys(enPublic); + const deKeys = getLeafKeys(dePublic); + + expect(enKeys.length).toBeGreaterThan(15); + expect(enKeys).toEqual(deKeys); + }); + }); +}); diff --git a/tests/unit/public-proposal-components.test.tsx b/tests/unit/public-proposal-components.test.tsx new file mode 100644 index 0000000..5c624b8 --- /dev/null +++ b/tests/unit/public-proposal-components.test.tsx @@ -0,0 +1,870 @@ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +vi.mock('server-only', () => ({})); + +import { + type PublicLineItem, + type ClientItemSelection, + resolveProposalLifecycleState, + formatProposalCurrency, + formatProposalDate, + calculateLiveTotals, + validateAcceptanceForm, + applyAddonToggle, + applyQuantityChange, + buildAcceptProposalPayload, + createInitialModalFormState, + modalFormReducer, + createAcceptanceModalController, + createProposalViewController, +} from '@/components/proposals/public/public-proposal-helpers'; +import { PublicProposalView } from '@/components/proposals/public/public-proposal-view'; +import { AcceptProposalModal } from '@/components/proposals/public/accept-proposal-modal'; +import enMessages from '@/../messages/en.json'; +import deMessages from '@/../messages/de.json'; + +// Helper to resolve translation string from json or throw if key is missing +function createStrictMockTranslator(messagesObj: Record, baseNamespace: string) { + return (key: string, params?: Record) => { + const fullPath = `${baseNamespace}.${key}`; + const parts = fullPath.split('.'); + let current: unknown = messagesObj; + for (const part of parts) { + if (!current || typeof current !== 'object') { + throw new Error(`Missing translation key: ${fullPath}`); + } + if (!Object.prototype.hasOwnProperty.call(current, part)) { + throw new Error(`Missing translation key: ${fullPath}`); + } + current = Reflect.get(current as Record, part); + } + if (typeof current !== 'string') { + throw new Error(`Missing translation key: ${fullPath}`); + } + let res = current; + if (params) { + for (const [pk, pv] of Object.entries(params)) { + res = res.replaceAll(`{${pk}}`, String(pv)); + } + } + return res; + }; +} + +let currentLocale = 'en'; + +vi.mock('next-intl', () => ({ + useTranslations: (ns: string) => { + const messages = currentLocale === 'de' ? deMessages : enMessages; + return createStrictMockTranslator(messages, ns); + }, +})); + +vi.mock('@/server/proposals/public-actions', () => ({ + acceptProposalAction: vi.fn(), +})); + +describe('TASK-0062: Public Proposal Components & Helpers', () => { + const mockLineItems: PublicLineItem[] = [ + { + id: 'item-base-1', + itemType: 'base_package', + name: 'Full Day Wedding Coverage', + description: '8 hours of shooting, 400+ high-res images', + unitPriceMinor: 200000, // €2,000.00 gross + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 0, + }, + { + id: 'item-addon-drone', + itemType: 'addon', + name: 'Drone Aerial Footage', + description: '4K aerial ceremony coverage', + unitPriceMinor: 35000, // €350.00 gross + quantity: 1, + minQuantity: 0, + maxQuantity: 2, + taxRateBasisPoints: 1900, + isOptional: true, + isSelected: false, + position: 1, + }, + { + id: 'item-addon-album', + itemType: 'addon', + name: 'Handcrafted Linen Album', + description: '30 pages 30x30cm fine art print', + unitPriceMinor: 45000, // €450.00 gross + quantity: 1, + minQuantity: 0, + maxQuantity: 5, + taxRateBasisPoints: 1900, + isOptional: true, + isSelected: true, + position: 2, + }, + ]; + + describe('resolveProposalLifecycleState', () => { + it('AC-3: resolves to "accepted" when status is accepted', () => { + const state = resolveProposalLifecycleState({ + status: 'accepted', + tokenRevokedAt: null, + validUntil: '2026-12-31T00:00:00Z', + }); + expect(state).toBe('accepted'); + }); + + it('AC-5: resolves to "revoked" when tokenRevokedAt is set', () => { + const state = resolveProposalLifecycleState({ + status: 'sent', + tokenRevokedAt: '2026-11-01T12:00:00Z', + validUntil: '2026-12-31T00:00:00Z', + }); + expect(state).toBe('revoked'); + }); + + it('AC-4: resolves to "expired" when validUntil is in the past', () => { + const past = new Date('2026-10-01T00:00:00Z'); + const state = resolveProposalLifecycleState( + { + status: 'sent', + tokenRevokedAt: null, + validUntil: past.toISOString(), + }, + new Date('2026-11-01T00:00:00Z'), + ); + expect(state).toBe('expired'); + }); + + it('AC-1: resolves to "active" when sent and unexpired without revocation', () => { + const future = new Date('2026-12-01T00:00:00Z'); + const state = resolveProposalLifecycleState( + { + status: 'sent', + tokenRevokedAt: null, + validUntil: future.toISOString(), + }, + new Date('2026-11-01T00:00:00Z'), + ); + expect(state).toBe('active'); + }); + }); + + describe('calculateLiveTotals pure helper', () => { + it('AC-2: reactively recalculates subtotal, VAT, and total matching integer minor units', () => { + // Base package (200000) + Linen album (45000 * 2) = 290000 gross + const selections = { + 'item-base-1': { isSelected: true, quantity: 1 }, + 'item-addon-drone': { isSelected: false, quantity: 1 }, + 'item-addon-album': { isSelected: true, quantity: 2 }, + }; + + const result = calculateLiveTotals(mockLineItems, selections, { + pricesIncludeTax: true, + isKleinunternehmer: false, + }); + + expect(result.totalMinor).toBe(290000); // €2,900.00 + expect(result.subtotalMinor).toBe(290000); + expect(result.taxMinor).toBeGreaterThan(0); + expect(result.breakdown).toHaveLength(1); + const b0 = result.breakdown[0]; + expect(b0).toBeDefined(); + if (!b0) throw new Error('Breakdown element missing'); + expect(b0.taxRateBasisPoints).toBe(1900); + expect(b0.grossMinor).toBe(290000); + expect(b0.netMinor + b0.taxMinor).toBe(290000); + }); + + it('AC-2: enforces zero VAT in Kleinunternehmer mode (§19 UStG)', () => { + const selections = { + 'item-base-1': { isSelected: true, quantity: 1 }, + 'item-addon-drone': { isSelected: true, quantity: 1 }, + 'item-addon-album': { isSelected: false, quantity: 1 }, + }; + + const result = calculateLiveTotals(mockLineItems, selections, { + pricesIncludeTax: true, + isKleinunternehmer: true, + }); + + expect(result.totalMinor).toBe(235000); // 200000 + 35000 + expect(result.subtotalMinor).toBe(235000); + expect(result.taxMinor).toBe(0); + expect(result.breakdown).toHaveLength(0); + }); + }); + + describe('validateAcceptanceForm', () => { + const t = (k: string) => `err_${k}`; + + it('AC-3: validates required name and valid email and agreement', () => { + const res = validateAcceptanceForm('Emma Watson', 'emma@example.com', true, t); + expect(res.valid).toBe(true); + expect(res.errors).toEqual({}); + }); + + it('AC-3: flags missing name, invalid email, and unconfirmed agreement', () => { + const res = validateAcceptanceForm('', 'not-an-email', false, t); + expect(res.valid).toBe(false); + expect(res.errors.name).toBe('err_nameRequired'); + expect(res.errors.email).toBe('err_emailInvalid'); + expect(res.errors.agreement).toBe('err_agreementRequired'); + }); + }); + + describe('applyAddonToggle & applyQuantityChange & buildAcceptProposalPayload', () => { + it('AC-2: toggles addon selection state', () => { + const init = { + 'item-1': { isSelected: false, quantity: 1 }, + }; + const toggledOn = applyAddonToggle(init, 'item-1', true); + expect(toggledOn['item-1']?.isSelected).toBe(true); + + const toggledOff = applyAddonToggle(toggledOn, 'item-1', false); + expect(toggledOff['item-1']?.isSelected).toBe(false); + }); + + it('AC-2: clamps quantity between minQuantity and maxQuantity', () => { + const init = { + 'item-addon-album': { isSelected: true, quantity: 1 }, + }; + const albumItem = mockLineItems[2]; + expect(albumItem).toBeDefined(); + if (!albumItem) throw new Error('Album line item missing'); + + // Increment + const incremented = applyQuantityChange(init, albumItem, 2); + expect(incremented['item-addon-album']?.quantity).toBe(3); + + // Decrement below min + const decremented = applyQuantityChange(incremented, albumItem, -10); + expect(decremented['item-addon-album']?.quantity).toBe(albumItem.minQuantity); + + // Increment above max + const maxed = applyQuantityChange(incremented, albumItem, 10); + expect(maxed['item-addon-album']?.quantity).toBe(albumItem.maxQuantity); + }); + + it('AC-3: buildAcceptProposalPayload filters selected add-ons and structures payload', () => { + const selections = { + 'item-base-1': { isSelected: true, quantity: 1 }, + 'item-addon-drone': { isSelected: true, quantity: 2 }, + 'item-addon-album': { isSelected: false, quantity: 1 }, + }; + + const payload = buildAcceptProposalPayload( + 'tok-xyz', + 'Emma Watson', + 'emma@example.com', + selections, + mockLineItems, + ); + + expect(payload.token).toBe('tok-xyz'); + expect(payload.acceptedByName).toBe('Emma Watson'); + expect(payload.acceptedByEmail).toBe('emma@example.com'); + expect(payload.agreementConfirmed).toBe(true); + expect(payload.selectedOptions).toEqual([{ id: 'item-addon-drone', quantity: 2 }]); + }); + }); + + describe('formatProposalCurrency & formatProposalDate', () => { + it('formats currency correctly in en and de locales', () => { + const enVal = formatProposalCurrency(250000, 'EUR', 'en'); + const deVal = formatProposalCurrency(250000, 'EUR', 'de'); + expect(enVal).toContain('2,500.00'); + expect(deVal).toContain('2.500,00'); + }); + + it('formats dates consistently', () => { + const date = new Date('2026-11-20T10:00:00Z'); + expect(formatProposalDate(date, 'en')).toBeTruthy(); + expect(formatProposalDate(date, 'de')).toBeTruthy(); + }); + + it('handles invalid date values gracefully in formatProposalDate', () => { + expect(formatProposalDate('not-a-date')).toBe('not-a-date'); + }); + + it('handles fallback formatting when currency is invalid in formatProposalCurrency', () => { + const res = formatProposalCurrency(10000, 'INVALID_CURRENCY_CODE', 'en'); + expect(res).toBe('€100.00'); + }); + }); + + describe('calculateLiveTotals edge cases', () => { + it('includes non-optional custom deliverables by default', () => { + const customItem: PublicLineItem = { + id: 'item-custom-1', + itemType: 'custom', + name: 'Extra Hour', + unitPriceMinor: 15000, + quantity: 1, + minQuantity: 1, + maxQuantity: 1, + taxRateBasisPoints: 1900, + isOptional: false, + isSelected: true, + position: 3, + }; + + const result = calculateLiveTotals( + [customItem], + {}, + { + pricesIncludeTax: true, + isKleinunternehmer: false, + }, + ); + + expect(result.totalMinor).toBe(15000); + }); + + it('resolveProposalLifecycleState handles null validUntil and undefined tokenRevokedAt', () => { + const state = resolveProposalLifecycleState({ + status: 'sent', + tokenRevokedAt: null, + validUntil: null, + }); + expect(state).toBe('active'); + }); + }); + + describe('Static rendering verification in EN and DE', () => { + const activeProposalData = { + id: 'prop-123', + title: 'Emma & Liam Wedding Coverage', + status: 'sent', + currency: 'EUR', + subtotalMinor: 200000, + taxMinor: 31933, + totalMinor: 200000, + pricesIncludeTax: true, + isKleinunternehmer: false, + validUntil: '2026-12-31T23:59:59Z', + tokenRevokedAt: null, + acceptedAt: null, + acceptedByName: null, + acceptedByEmail: null, + lineItems: mockLineItems, + }; + + it('AC-1: renders active proposal view in EN with all deliverables without missing keys', () => { + currentLocale = 'en'; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Light & Shadow Studios'); + expect(html).toContain('Emma & Liam Wedding Coverage'); + expect(html).toContain('Full Day Wedding Coverage'); + expect(html).toContain('Drone Aerial Footage'); + expect(html).toContain('Handcrafted Linen Album'); + expect(html).toContain('accept-proposal-btn'); + }); + + it('AC-7: renders active proposal view in DE with all translations without missing keys', () => { + currentLocale = 'de'; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Licht & Schatten Fotografie'); + expect(html).toContain('Basispaket'); + expect(html).toContain('Optionale Zusatzleistungen'); + expect(html).toContain('Preisübersicht'); + expect(html).toContain('Angebot annehmen'); + expect(html).not.toContain('undefined'); + }); + + it('AC-4: renders expired proposal view with expired banner', () => { + currentLocale = 'en'; + const expiredData = { + ...activeProposalData, + validUntil: '2026-10-01T00:00:00Z', + }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('proposal-expired-banner'); + expect(html).toContain('Proposal Expired'); + }); + + it('AC-5: renders revoked proposal view with revoked banner', () => { + currentLocale = 'en'; + const revokedData = { + ...activeProposalData, + tokenRevokedAt: '2026-11-01T00:00:00Z', + }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('proposal-revoked-banner'); + expect(html).toContain('Link No Longer Active'); + }); + + it('AC-3: renders accepted proposal view with confirmation banner', () => { + currentLocale = 'en'; + const acceptedData = { + ...activeProposalData, + status: 'accepted', + acceptedAt: '2026-11-02T15:30:00Z', + acceptedByName: 'Emma Watson', + acceptedByEmail: 'emma@example.com', + }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('proposal-accepted-banner'); + expect(html).toContain('Proposal Accepted'); + expect(html).toContain('Emma Watson'); + expect(html).toContain('emma@example.com'); + }); + + it('AC-3 / AC-7: renders AcceptProposalModal in open state in EN and DE', () => { + currentLocale = 'en'; + const enHtml = renderToStaticMarkup( + { + /* no-op */ + }} + proposalTitle="Wedding Photography" + formattedTotal="€2,000.00" + isSubmitting={false} + defaultName="Emma Watson" + defaultEmail="emma@example.com" + onSubmit={() => { + /* no-op */ + }} + />, + ); + + expect(enHtml).toContain('accept-proposal-dialog'); + expect(enHtml).toContain('accepted-name-input'); + expect(enHtml).toContain('accepted-email-input'); + expect(enHtml).toContain('agreement-checkbox'); + expect(enHtml).toContain('Confirm & Accept Proposal'); + + currentLocale = 'de'; + const deHtml = renderToStaticMarkup( + { + /* no-op */ + }} + proposalTitle="Hochzeitsfotografie" + formattedTotal="2.000,00 €" + isSubmitting={false} + defaultName="Emma Watson" + defaultEmail="emma@example.com" + onSubmit={() => { + /* no-op */ + }} + />, + ); + + expect(deHtml).toContain('accept-proposal-dialog'); + expect(deHtml).toContain('Verbindlich annehmen'); + expect(deHtml).toContain('Vollständiger Name'); + expect(deHtml).toContain('E-Mail-Adresse'); + }); + + it('renders AcceptProposalModal with serverError and isSubmitting state', () => { + currentLocale = 'en'; + const html = renderToStaticMarkup( + { + /* no-op */ + }} + proposalTitle="Wedding Photography" + formattedTotal="€2,000.00" + isSubmitting={true} + serverError="Database connection error occurred" + defaultName="Emma Watson" + defaultEmail="emma@example.com" + onSubmit={() => { + /* no-op */ + }} + />, + ); + + expect(html).toContain('accept-proposal-error'); + expect(html).toContain('Database connection error occurred'); + expect(html).toContain('disabled'); + }); + + it('renders AcceptProposalModal with initial validation errors', () => { + currentLocale = 'en'; + const html = renderToStaticMarkup( + { + /* no-op */ + }} + proposalTitle="Wedding Photography" + formattedTotal="€2,000.00" + isSubmitting={false} + initialErrors={{ + name: 'Name is required.', + email: 'Please provide a valid email.', + agreement: 'You must agree before accepting.', + }} + onSubmit={() => { + /* no-op */ + }} + />, + ); + + expect(html).toContain('accepted-by-name-error'); + expect(html).toContain('Name is required.'); + expect(html).toContain('accepted-by-email-error'); + expect(html).toContain('Please provide a valid email.'); + expect(html).toContain('agreement-error'); + expect(html).toContain('You must agree before accepting.'); + }); + + it('renders PublicProposalView in Kleinunternehmer mode with custom tax notice', () => { + currentLocale = 'en'; + const kleinData = { + ...activeProposalData, + isKleinunternehmer: true, + taxNotice: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', + }; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'); + }); + }); + + describe('modalFormReducer & createAcceptanceModalController unit tests', () => { + const dummyT = (k: string) => `trans_${k}`; + + it('modalFormReducer handles CHANGE_NAME, CHANGE_EMAIL, CHANGE_AGREEMENT when untouched and touched', () => { + let state = createInitialModalFormState(); + expect(state.name).toBe(''); + expect(state.touched).toBe(false); + + // Untouched changes: errors not populated + state = modalFormReducer(state, { type: 'CHANGE_NAME', value: 'Alice', t: dummyT }); + expect(state.name).toBe('Alice'); + expect(state.errors).toEqual({}); + + state = modalFormReducer(state, { type: 'CHANGE_EMAIL', value: 'invalid-email', t: dummyT }); + expect(state.email).toBe('invalid-email'); + expect(state.errors).toEqual({}); + + state = modalFormReducer(state, { type: 'CHANGE_AGREEMENT', value: true, t: dummyT }); + expect(state.agreement).toBe(true); + + // Submit attempt sets touched to true and populates validation errors + state = modalFormReducer(state, { type: 'SUBMIT_ATTEMPT', t: dummyT }); + expect(state.touched).toBe(true); + expect(state.errors.email).toBe('trans_emailInvalid'); + + // Now changes update errors live + state = modalFormReducer(state, { + type: 'CHANGE_EMAIL', + value: 'alice@example.com', + t: dummyT, + }); + expect(state.errors.email).toBeUndefined(); + + state = modalFormReducer(state, { type: 'CHANGE_NAME', value: '', t: dummyT }); + expect(state.errors.name).toBe('trans_nameRequired'); + + state = modalFormReducer(state, { type: 'CHANGE_AGREEMENT', value: false, t: dummyT }); + expect(state.errors.agreement).toBe('trans_agreementRequired'); + + // SET_DEFAULTS action + state = modalFormReducer(state, { + type: 'SET_DEFAULTS', + defaultName: 'Default Bob', + defaultEmail: 'bob@example.com', + }); + expect(state.name).toBe('Default Bob'); // name was empty, so updated + + // Default fallback + // @ts-expect-error test invalid action type fallback + const unchanged = modalFormReducer(state, { type: 'UNKNOWN_ACTION' }); + expect(unchanged).toBe(state); + }); + + it('createAcceptanceModalController executes actions, focus restore, and submit', async () => { + const dispatchedActions: unknown[] = []; + const dispatch = (action: unknown) => dispatchedActions.push(action); + const focusSpy = vi.fn(); + const triggerRef = { current: { focus: focusSpy } }; + const onSubmitSpy = vi.fn(); + + const state = { + name: 'Emma Watson', + email: 'emma@example.com', + agreement: true, + touched: true, + errors: {}, + }; + + const onCloseSpy = vi.fn(); + const controller = createAcceptanceModalController({ + state, + dispatch, + t: dummyT, + triggerRef, + onClose: onCloseSpy, + onSubmit: onSubmitSpy, + }); + + controller.handleNameChange('Emma'); + expect(dispatchedActions[0]).toEqual({ type: 'CHANGE_NAME', value: 'Emma', t: dummyT }); + + controller.handleNameChange({ target: { value: 'Emma 2' } }); + expect(dispatchedActions[1]).toEqual({ type: 'CHANGE_NAME', value: 'Emma 2', t: dummyT }); + + controller.handleEmailChange('emma2@example.com'); + expect(dispatchedActions[2]).toEqual({ + type: 'CHANGE_EMAIL', + value: 'emma2@example.com', + t: dummyT, + }); + + controller.handleEmailChange({ target: { value: 'emma3@example.com' } }); + expect(dispatchedActions[3]).toEqual({ + type: 'CHANGE_EMAIL', + value: 'emma3@example.com', + t: dummyT, + }); + + controller.handleAgreementChange(false); + expect(dispatchedActions[4]).toEqual({ type: 'CHANGE_AGREEMENT', value: false, t: dummyT }); + + controller.handleAgreementChange({ target: { checked: true } }); + expect(dispatchedActions[5]).toEqual({ type: 'CHANGE_AGREEMENT', value: true, t: dummyT }); + + controller.handleOpenChange(false); + expect(onCloseSpy).toHaveBeenCalled(); + controller.handleOpenChange(true); + + const preventDefaultSpy = vi.fn(); + controller.handleCloseAutoFocus({ preventDefault: preventDefaultSpy }); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + + // Submit when valid + const success = await controller.handleSubmit(); + expect(success).toBe(true); + expect(onSubmitSpy).toHaveBeenCalledWith({ name: 'Emma Watson', email: 'emma@example.com' }); + + // Submit when invalid + const invalidController = createAcceptanceModalController({ + state: { ...state, name: '' }, + dispatch, + t: dummyT, + triggerRef, + onSubmit: onSubmitSpy, + }); + const invalidSuccess = await invalidController.handleSubmit({ preventDefault: vi.fn() }); + expect(invalidSuccess).toBe(false); + }); + }); + + describe('createProposalViewController unit tests', () => { + it('handles addon toggling and quantity adjustments correctly', () => { + let currentSelections: Record = { + 'item-base-1': { isSelected: true, quantity: 1 }, + }; + const setSelections = ( + updater: (prev: Record) => Record, + ) => { + currentSelections = updater(currentSelections); + }; + const setModalOpen = vi.fn(); + + const controller = createProposalViewController({ + token: 'token-123', + lineItems: mockLineItems, + setSelections, + setIsSubmitting: vi.fn(), + setServerError: vi.fn(), + setModalOpen, + setCurrentStatus: vi.fn(), + setAcceptedDetails: vi.fn(), + acceptAction: vi.fn(), + }); + + // Modal open/close + controller.handleOpenModal(); + expect(setModalOpen).toHaveBeenCalledWith(true); + controller.handleCloseModal(); + expect(setModalOpen).toHaveBeenCalledWith(false); + + // Toggle addon on + controller.handleToggleAddon('item-addon-drone', true); + expect(currentSelections['item-addon-drone']?.isSelected).toBe(true); + + // Toggle addon by DOM event + controller.handleToggleAddonById({ + currentTarget: { + getAttribute: () => 'item-addon-drone', + checked: false, + }, + }); + expect(currentSelections['item-addon-drone']?.isSelected).toBe(false); + + // Change quantity on existing item + controller.handleQuantityChange('item-addon-drone', 1); + expect(currentSelections['item-addon-drone']?.quantity).toBe(2); + + // Quantity change by DOM events + controller.handleIncreaseQuantity({ + currentTarget: { getAttribute: () => 'item-addon-drone' }, + }); + expect(currentSelections['item-addon-drone']?.quantity).toBe(2); // capped at max 2 + + controller.handleDecreaseQuantity({ + currentTarget: { getAttribute: () => 'item-addon-drone' }, + }); + expect(currentSelections['item-addon-drone']?.quantity).toBe(1); + + // Null attribute handlers + controller.handleToggleAddonById({ + currentTarget: { getAttribute: () => null, checked: true }, + }); + controller.handleIncreaseQuantity({ + currentTarget: { getAttribute: () => null }, + }); + controller.handleDecreaseQuantity({ + currentTarget: { getAttribute: () => null }, + }); + + // Change quantity on non-existent item (no crash) + controller.handleQuantityChange('non-existent-id', 1); + }); + + it('handles accept submission success, business failure, and network error', async () => { + const setIsSubmitting = vi.fn(); + const setServerError = vi.fn(); + const setModalOpen = vi.fn(); + const setCurrentStatus = vi.fn(); + const setAcceptedDetails = vi.fn(); + + // Case 1: Success + const mockSuccessAction = vi.fn().mockResolvedValue({ success: true }); + const controller = createProposalViewController({ + token: 'token-123', + lineItems: mockLineItems, + setSelections: vi.fn(), + setIsSubmitting, + setServerError, + setModalOpen, + setCurrentStatus, + setAcceptedDetails, + acceptAction: mockSuccessAction, + }); + + const resSuccess = await controller.handleAcceptSubmit( + { name: 'Emma Watson', email: 'emma@example.com' }, + { 'item-base-1': { isSelected: true, quantity: 1 } }, + ); + expect(resSuccess.success).toBe(true); + expect(setModalOpen).toHaveBeenCalledWith(false); + expect(setCurrentStatus).toHaveBeenCalledWith('accepted'); + expect(setAcceptedDetails).toHaveBeenCalled(); + + // Case 2: Business failure (e.g. expired or invalid) + const mockFailAction = vi + .fn() + .mockResolvedValue({ success: false, error: 'Proposal expired' }); + const controllerFail = createProposalViewController({ + token: 'token-123', + lineItems: mockLineItems, + setSelections: vi.fn(), + setIsSubmitting, + setServerError, + setModalOpen, + setCurrentStatus, + setAcceptedDetails, + acceptAction: mockFailAction, + }); + + const resFail = await controllerFail.handleAcceptSubmit( + { name: 'Emma Watson', email: 'emma@example.com' }, + { 'item-base-1': { isSelected: true, quantity: 1 } }, + ); + expect(resFail.success).toBe(false); + expect(resFail.error).toBe('Proposal expired'); + expect(setServerError).toHaveBeenCalledWith('Proposal expired'); + + // Case 3: Thrown exception (e.g. network failure) + const mockThrowAction = vi.fn().mockRejectedValue(new Error('Network drop')); + const controllerThrow = createProposalViewController({ + token: 'token-123', + lineItems: mockLineItems, + setSelections: vi.fn(), + setIsSubmitting, + setServerError, + setModalOpen, + setCurrentStatus, + setAcceptedDetails, + acceptAction: mockThrowAction, + }); + + const resThrow = await controllerThrow.handleAcceptSubmit( + { name: 'Emma Watson', email: 'emma@example.com' }, + { 'item-base-1': { isSelected: true, quantity: 1 } }, + ); + expect(resThrow.success).toBe(false); + expect(resThrow.error).toBe('Network drop'); + expect(setServerError).toHaveBeenCalledWith('Network drop'); + }); + }); +});