diff --git a/app/[locale]/admin/sellers/create/page.tsx b/app/[locale]/admin/sellers/create/page.tsx index 09d5f283..eb82e2bb 100644 --- a/app/[locale]/admin/sellers/create/page.tsx +++ b/app/[locale]/admin/sellers/create/page.tsx @@ -122,7 +122,7 @@ export default function CreateSellerPage() { const updateField = (field: keyof FormState, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); - if (errors[field]) { + if (Object.hasOwn(errors, field)) { setErrors((prev) => { const next = { ...prev }; delete next[field]; @@ -158,9 +158,9 @@ export default function CreateSellerPage() { router.push(`/${locale}/admin/sellers`); router.refresh(); - } catch (err: unknown) { + } catch (error: unknown) { setServerError( - err instanceof Error ? err.message : dict.admin.createSellerError, + error instanceof Error ? error.message : dict.admin.createSellerError, ); } finally { setLoading(false); diff --git a/app/[locale]/admin/sellers/page.tsx b/app/[locale]/admin/sellers/page.tsx index 86e48842..3bfc1743 100644 --- a/app/[locale]/admin/sellers/page.tsx +++ b/app/[locale]/admin/sellers/page.tsx @@ -165,8 +165,8 @@ export default async function AdminSellersPage({ defaultValue={filter.q ?? ''} hiddenFields={{ pageSize: String(pageSize), - ...(filter.sortBy ? { sortBy: filter.sortBy } : {}), - ...(filter.sortDir ? { sortDir: filter.sortDir } : {}), + ...(filter.sortBy && { sortBy: filter.sortBy }), + ...(filter.sortDir && { sortDir: filter.sortDir }), }} /> @@ -195,8 +195,10 @@ export default async function AdminSellersPage({ prevLabel={dict.admin.pagePrev} nextLabel={dict.admin.pageNext} pageInfo={dict.admin.pageXofY - .replace('{current}', String(currentPage)) - .replace('{total}', String(totalPages))} + .split('{current}') + .join(currentPage.toString()) + .split('{total}') + .join(totalPages.toString())} /> )} diff --git a/app/[locale]/auth/change-password/page.tsx b/app/[locale]/auth/change-password/page.tsx index fd8735ef..01d705b5 100644 --- a/app/[locale]/auth/change-password/page.tsx +++ b/app/[locale]/auth/change-password/page.tsx @@ -65,9 +65,11 @@ export default function ChangePasswordPage() { setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); - } catch (err: unknown) { + } catch (error_: unknown) { setError( - err instanceof Error ? err.message : dict.auth.failedToChangePassword, + error_ instanceof Error + ? error_.message + : dict.auth.failedToChangePassword, ); } finally { setLoading(false); diff --git a/app/[locale]/auth/reset-password/page.tsx b/app/[locale]/auth/reset-password/page.tsx index 82796180..1ac9a254 100644 --- a/app/[locale]/auth/reset-password/page.tsx +++ b/app/[locale]/auth/reset-password/page.tsx @@ -20,7 +20,7 @@ export default function ResetPasswordPage() { const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(() => - !token ? dict.auth.tokenExpired : null, + token ? null : dict.auth.tokenExpired, ); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); @@ -64,9 +64,11 @@ export default function ResetPasswordPage() { } setSuccess(true); router.push('/'); - } catch (err: unknown) { + } catch (error_: unknown) { setError( - err instanceof Error ? err.message : dict.auth.failedToResetPassword, + error_ instanceof Error + ? error_.message + : dict.auth.failedToResetPassword, ); } finally { setLoading(false); diff --git a/app/[locale]/auth/signup/page.tsx b/app/[locale]/auth/signup/page.tsx index 04d92175..5bd18a4e 100644 --- a/app/[locale]/auth/signup/page.tsx +++ b/app/[locale]/auth/signup/page.tsx @@ -61,7 +61,7 @@ function applyIssue(errors: FormErrors, issue: ZodIssue) { } default: { if (path.startsWith('address.')) { - const addrField = path.split('.')[1] as keyof AddressFields; + const addrField = path.split('.', 2)[1] as keyof AddressFields; if (!errors.address) errors.address = {}; errors.address[addrField] = issue.message; } @@ -125,7 +125,7 @@ export default function SignUpPage() { const updateField = (field: keyof FormState, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); - if (errors[field]) { + if (Object.hasOwn(errors, field)) { setErrors((prev) => { const next = { ...prev }; delete next[field]; @@ -139,7 +139,7 @@ export default function SignUpPage() { ...prev, address: { ...prev.address, [field]: value }, })); - if (errors.address?.[field]) { + if (errors.address?.[field] !== undefined) { setErrors((prev) => { const next = { ...prev, @@ -210,9 +210,9 @@ export default function SignUpPage() { // Fallback: redirect to sign-in if auto-login fails router.push(`/${locale}/auth/signin?registered=true`); } - } catch (err: unknown) { + } catch (error: unknown) { setServerError( - err instanceof Error ? err.message : 'An unexpected error occurred', + error instanceof Error ? error.message : 'An unexpected error occurred', ); } finally { setLoading(false); diff --git a/app/[locale]/auth/verify-email/page.tsx b/app/[locale]/auth/verify-email/page.tsx index 96ce664c..06f29d32 100644 --- a/app/[locale]/auth/verify-email/page.tsx +++ b/app/[locale]/auth/verify-email/page.tsx @@ -12,7 +12,7 @@ export default function VerifyEmailPage() { const token = searchParams.get('token'); const [status, setStatus] = useState< 'loading' | 'success' | 'expired' | 'invalid' - >(() => (!token ? 'invalid' : 'loading')); + >(() => (token ? 'loading' : 'invalid')); const dict = useDictionary(); useEffect(() => { @@ -22,11 +22,15 @@ export default function VerifyEmailPage() { const controller = new AbortController(); - fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`, { - signal: controller.signal, - }) - .then((res) => res.json()) - .then((data) => { + (async () => { + try { + const res = await fetch( + `/api/auth/verify-email?token=${encodeURIComponent(token)}`, + { + signal: controller.signal, + }, + ); + const data = await res.json(); if (data.success) { setStatus('success'); } else if (data.error?.includes('expired')) { @@ -34,10 +38,10 @@ export default function VerifyEmailPage() { } else { setStatus('invalid'); } - }) - .catch(() => { + } catch { setStatus('invalid'); - }); + } + })(); return () => controller.abort(); }, [token]); diff --git a/app/[locale]/cart/design-preview.tsx b/app/[locale]/cart/design-preview.tsx index 33876fe7..f422824b 100644 --- a/app/[locale]/cart/design-preview.tsx +++ b/app/[locale]/cart/design-preview.tsx @@ -32,7 +32,7 @@ export function DesignPreview({ const canvasRef = useRef(null); useEffect(() => { - let cancelled = false; + let isCancelled = false; async function draw() { const c = canvasRef.current?.getContext('2d'); @@ -40,7 +40,7 @@ export function DesignPreview({ const productImg = await loadImage(productImageUrl); const designImg = await loadImage(designImageUrl); - if (cancelled) return; + if (isCancelled) return; c.clearRect(0, 0, width, height); c.fillStyle = '#f4f2e6'; @@ -63,7 +63,7 @@ export function DesignPreview({ draw(); return () => { - cancelled = true; + isCancelled = true; }; }, [productImageUrl, designImageUrl, designPosition, width, height]); diff --git a/app/[locale]/checkout/page.tsx b/app/[locale]/checkout/page.tsx index 0ba640fa..3de38f8e 100644 --- a/app/[locale]/checkout/page.tsx +++ b/app/[locale]/checkout/page.tsx @@ -209,7 +209,7 @@ export default async function CheckoutPage({ {[ ...item.customizations.flatMap((c) => [ - c.size && + c.size != null && `${dict.common.customizationSize}: ${c.size}`, c.color && `${dict.common.customizationColor}: ${c.color}`, @@ -260,10 +260,9 @@ export default async function CheckoutPage({ {isFirstPurchase && (
- {dict.common.firstPurchaseDiscount.replace( - '{rate}', - String(FIRST_PURCHASE_DISCOUNT_RATE * 100), - )} + {dict.common.firstPurchaseDiscount + .split('{rate}') + .join((FIRST_PURCHASE_DISCOUNT_RATE * 100).toString())} −{Money.format(discount, currency)} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 52abbba2..3e2830b8 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -94,12 +94,12 @@ export default async function RootLayout({ // ADMIN/DESIGNER and have no orders const role = (session?.user as { role?: string } | undefined)?.role; const isInternal = role === ADMIN_ROLE || role === DESIGNER_ROLE; - let showBanner = !isInternal; - if (showBanner && session?.user?.id) { + let isShowBanner = !isInternal; + if (isShowBanner && session?.user?.id) { const orderCount = await prisma.order.count({ where: { userId: session.user.id }, }); - showBanner = orderCount === 0; + isShowBanner = orderCount === 0; } return ( @@ -142,7 +142,7 @@ export default async function RootLayout({
- {showBanner && } + {isShowBanner && }
diff --git a/app/[locale]/orders/[orderId]/page.tsx b/app/[locale]/orders/[orderId]/page.tsx index 8785dadb..5ee793fc 100644 --- a/app/[locale]/orders/[orderId]/page.tsx +++ b/app/[locale]/orders/[orderId]/page.tsx @@ -89,9 +89,9 @@ export default async function OrderDetailPage({ )?.imageUrl; const displayImage = customImage ?? item.productImageUrl; const lineTotal = - item.unitPrice != null - ? item.unitPrice * item.quantity - : undefined; + item.unitPrice == null + ? undefined + : item.unitPrice * item.quantity; return (
@@ -113,7 +113,7 @@ export default async function OrderDetailPage({ {item.customizationSnapshot .flatMap((c) => [ - c.size && + c.size != null && `${dict.common.customizationSize}: ${c.size}`, c.color && `${dict.common.customizationColor}: ${c.color}`, diff --git a/app/[locale]/orders/page.tsx b/app/[locale]/orders/page.tsx index 7b47b385..85607983 100644 --- a/app/[locale]/orders/page.tsx +++ b/app/[locale]/orders/page.tsx @@ -135,7 +135,7 @@ export default async function CustomerOrdersPage({ if (filter.pageSize !== 20) params.set('pageSize', String(filter.pageSize)); if (filter.q) params.set('q', filter.q); const qs = params.toString(); - return `/${locale}/orders${qs ? `?${qs}` : ''}`; + return qs ? `/${locale}/orders?${qs}` : `/${locale}/orders`; }; return ( @@ -153,7 +153,7 @@ export default async function CustomerOrdersPage({ ariaLabel={dict.orders?.searchItems ?? 'Search by product'} defaultValue={filter.q} hiddenFields={ - filter.status !== 'all' ? { status: filter.status } : undefined + filter.status === 'all' ? undefined : { status: filter.status } } />
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c238f04c..95a2055c 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -47,16 +47,16 @@ export default async function HomePage({ // Server-backed recent suggestions for authenticated users only. // v1 spec: guests receive null; no localStorage / sessionStorage / cookies // are ever written by the search feature. - const recent: RecentSearchSuggestion[] | null = session - ? await new GetRecentSearchesUseCase(container.getSearchHistoryRepository()) - .execute({ userId: session.id, locale }) - .then((entries) => - entries.map((e) => ({ - term: e.term, - searchedAt: e.searchedAt.toISOString(), - })), - ) - : null; + let recent: RecentSearchSuggestion[] | null = null; + if (session) { + const entries = await new GetRecentSearchesUseCase( + container.getSearchHistoryRepository(), + ).execute({ userId: session.id, locale }); + recent = entries.map((e) => ({ + term: e.term, + searchedAt: e.searchedAt.toISOString(), + })); + } // Client island receives a stable JSON shape. We pre-format // the price string on the server so the client never receives diff --git a/app/[locale]/products/[id]/customization-experience.tsx b/app/[locale]/products/[id]/customization-experience.tsx index ec27ae24..bfb42095 100644 --- a/app/[locale]/products/[id]/customization-experience.tsx +++ b/app/[locale]/products/[id]/customization-experience.tsx @@ -94,7 +94,7 @@ function CustomizationExperienceInner({ const { draft, setImage, setDesignPosition } = useCustomizationDraft(); const customizationModel = ProductCustomizationConfig.fromJson(customizationConfig); - const allowsPhoto = customizationModel.allowPhotoDesign !== false; + const isAllowsPhoto = customizationModel.allowPhotoDesign !== false; const activeProductImageUrl = productImages.find((img) => img.alt === draft.color)?.url ?? previewBaseImageUrl; @@ -146,7 +146,7 @@ function CustomizationExperienceInner({
- {allowsPhoto && previewBaseImageUrl && ( + {isAllowsPhoto && previewBaseImageUrl && (
{productImages.map((img) => { - const selected = draft.color === img.alt; + const isSelected = draft.color === img.alt; return (
setColor(img.alt)} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setColor(img.alt); + if (!(e.key === 'Enter' || e.key === ' ')) { + return; } + + e.preventDefault(); + setColor(img.alt); }} title={img.alt} > @@ -131,7 +134,7 @@ export function CustomizationForm({ - {errors.size && ( + {errors.size != null && ( diff --git a/app/[locale]/products/[id]/customization-preview.tsx b/app/[locale]/products/[id]/customization-preview.tsx index 7cc31a37..040138a1 100644 --- a/app/[locale]/products/[id]/customization-preview.tsx +++ b/app/[locale]/products/[id]/customization-preview.tsx @@ -62,11 +62,7 @@ export function CustomizationPreview({ {labels.customizationPreviewDisclaimer}

- {!canPreview ? ( -

- {labels.customizationPreviewUnavailable} -

- ) : ( + {canPreview ? (
+ ) : ( +

+ {labels.customizationPreviewUnavailable} +

)} {customizationConfig.mode === 'description' && ( diff --git a/app/[locale]/products/[id]/page.tsx b/app/[locale]/products/[id]/page.tsx index 7b01520c..8a4857e1 100644 --- a/app/[locale]/products/[id]/page.tsx +++ b/app/[locale]/products/[id]/page.tsx @@ -18,15 +18,15 @@ export default async function ProductDetailPage({ const useCase = new GetProductByIdUseCase(repository); let product = null; - let error = false; + let isError = false; try { product = await useCase.execute(id, locale); } catch { - error = true; + isError = true; } - if (error || !product) { + if (isError || !product) { return
{dict.common.productDetailsError}
; } diff --git a/app/[locale]/products/[id]/photo-upload-field.tsx b/app/[locale]/products/[id]/photo-upload-field.tsx index fef0d6a0..d54319fa 100644 --- a/app/[locale]/products/[id]/photo-upload-field.tsx +++ b/app/[locale]/products/[id]/photo-upload-field.tsx @@ -54,10 +54,9 @@ export function PhotoUploadField({ } setError(null); - startTransition(() => { - onUpload(file).then((result) => { - onUploaded?.(result); - }); + startTransition(async () => { + const result = await onUpload(file); + onUploaded?.(result); }); }; diff --git a/app/[locale]/profile/page.tsx b/app/[locale]/profile/page.tsx index a46ea82f..cd77a80b 100644 --- a/app/[locale]/profile/page.tsx +++ b/app/[locale]/profile/page.tsx @@ -36,7 +36,7 @@ export default function ProfilePage() { const { locale } = useParams<{ locale: string }>(); const dict = useDictionary(); const role = session?.user?.role; - const showAddress = role === 'CUSTOMER'; + const isShowAddress = role === 'CUSTOMER'; const [form, setForm] = useState({ firstName: '', lastName: '', @@ -56,7 +56,7 @@ export default function ProfilePage() { } if (status !== 'authenticated') return; - let cancelled = false; + let isCancelled = false; (async () => { setLoading(true); setError(null); @@ -66,7 +66,7 @@ export default function ProfilePage() { if (!res.ok) { throw new Error(data.error || 'Failed to load profile'); } - if (!cancelled) { + if (!isCancelled) { setForm({ firstName: data.firstName || '', lastName: data.lastName || '', @@ -79,20 +79,20 @@ export default function ProfilePage() { }, }); } - } catch (err: unknown) { - if (!cancelled) { + } catch (error_: unknown) { + if (!isCancelled) { setError( - err instanceof Error ? err.message : 'Failed to load profile', + error_ instanceof Error ? error_.message : 'Failed to load profile', ); } } finally { - if (!cancelled) { + if (!isCancelled) { setLoading(false); } } })(); return () => { - cancelled = true; + isCancelled = true; }; }, [status, locale, router]); @@ -110,7 +110,7 @@ export default function ProfilePage() { const body: Record = {}; if (form.firstName) body.firstName = form.firstName; if (form.lastName) body.lastName = form.lastName; - if (showAddress && hasAddress) body.address = form.address; + if (isShowAddress && hasAddress) body.address = form.address; try { const res = await fetch('/api/users/me', { @@ -123,8 +123,10 @@ export default function ProfilePage() { throw new Error(data.error || 'Failed to update profile'); } setSuccess(dict.profile.updateSuccess); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to update profile'); + } catch (error_: unknown) { + setError( + error_ instanceof Error ? error_.message : 'Failed to update profile', + ); } finally { setSaving(false); } @@ -140,9 +142,11 @@ export default function ProfilePage() { throw new Error(data.error || 'Failed to delete account'); } // Redirect to home after soft-delete - window.location.assign('/'); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to delete account'); + globalThis.location.assign('/'); + } catch (error_: unknown) { + setError( + error_ instanceof Error ? error_.message : 'Failed to delete account', + ); } finally { setSaving(false); setShowDeleteModal(false); @@ -184,7 +188,7 @@ export default function ProfilePage() { disabled /> - {showAddress && ( + {isShowAddress && (

{dict.auth.address}

diff --git a/app/[locale]/seller/orders/page.tsx b/app/[locale]/seller/orders/page.tsx index 465538fb..26fdc057 100644 --- a/app/[locale]/seller/orders/page.tsx +++ b/app/[locale]/seller/orders/page.tsx @@ -163,7 +163,7 @@ export default async function SellerOrdersPage({ if (filter.sortDir !== 'desc') params.set('sortDir', filter.sortDir); if (filter.pageSize !== 20) params.set('pageSize', String(filter.pageSize)); const qs = params.toString(); - return `/${locale}/seller/orders${qs ? `?${qs}` : ''}`; + return qs ? `/${locale}/seller/orders?${qs}` : `/${locale}/seller/orders`; }; return ( diff --git a/app/[locale]/seller/products/page.tsx b/app/[locale]/seller/products/page.tsx index 61f05b8b..a1262a47 100644 --- a/app/[locale]/seller/products/page.tsx +++ b/app/[locale]/seller/products/page.tsx @@ -60,8 +60,9 @@ export default async function SellerProductsPage({ totalPages: 0, }; - const result = (await useCase - .execute({ + let result: PaginatedResult; + try { + result = (await useCase.execute({ userId: session?.id ?? '', q: filter.q, page: filter.page, @@ -70,14 +71,14 @@ export default async function SellerProductsPage({ sortBy: filter.sortBy, sortDir: filter.sortDir, audience: 'seller', - }) - .catch((error: unknown) => { - if (error instanceof NotFoundError) { - return fallbackResult; - } - - throw error; })) as PaginatedResult; + } catch (error: unknown) { + if (error instanceof NotFoundError) { + result = fallbackResult; + } else { + throw error; + } + } const hasProducts = result.items.length > 0; const currentPage = diff --git a/app/[locale]/seller/products/product-form.tsx b/app/[locale]/seller/products/product-form.tsx index 2367345b..3b737b22 100644 --- a/app/[locale]/seller/products/product-form.tsx +++ b/app/[locale]/seller/products/product-form.tsx @@ -198,7 +198,7 @@ async function uploadPhoto(file: File, defaultName: string) { id: result.id, url: toAbsoluteUrl(result.publicUrl), alt: normalizePhotoName( - file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' '), + file.name.replace(/\.[^.]+$/, '').replaceAll(/[-_]+/g, ' '), defaultName, ), size: file.size, @@ -258,7 +258,7 @@ export function ProductForm({ ) => { setForm((current) => ({ ...current, [field]: value })); setErrors((current) => { - if (!current[field]) return current; + if (!Object.hasOwn(current, field)) return current; const next = { ...current }; delete next[field]; return next; @@ -337,9 +337,9 @@ export function ProductForm({ null, }; }); - } catch (e) { + } catch (error) { setPhotoError( - e instanceof Error ? e.message : labels.gallery.uploadError, + error instanceof Error ? error.message : labels.gallery.uploadError, ); } finally { setUploading(false); @@ -351,7 +351,7 @@ export function ProductForm({ for (const issue of error.issues) { const path = issue.path[0] as keyof FormErrors | undefined; - if (path && !next[path]) { + if (path !== undefined && !Object.hasOwn(next, path)) { next[path] = issue.message; } } @@ -382,9 +382,12 @@ export function ProductForm({ }); if (!response.ok) { - const data = (await response.json().catch(() => null)) as { - error?: string; - } | null; + let data: { error?: string } | null = null; + try { + data = (await response.json()) as { error?: string } | null; + } catch { + data = null; + } throw new Error(data?.error || labels.error); } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 1da78889..e681fd74 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,8 @@ import NextAuth from 'next-auth'; import { authOptions } from '@/shared/infrastructure/auth-options'; -export { authOptions }; - const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; + +export { authOptions } from '@/shared/infrastructure/auth-options'; diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts index 45e2ee84..2e27aeb7 100644 --- a/app/api/auth/forgot-password/route.ts +++ b/app/api/auth/forgot-password/route.ts @@ -17,7 +17,7 @@ export async function POST(req: NextRequest) { // Rate limit by IP — prevent abuse of this public endpoint const ip = - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-forwarded-for')?.split(',', 1)[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; const rateLimiter = container.getRateLimiter(); @@ -49,7 +49,7 @@ export async function POST(req: NextRequest) { // Record rate-limiting on errors too — every request should count try { const ip = - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-forwarded-for')?.split(',', 1)[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; await container.getRateLimiter().recordLoginAttempt('unknown', ip, false); diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts index 6a059917..f0ff5599 100644 --- a/app/api/auth/reset-password/route.ts +++ b/app/api/auth/reset-password/route.ts @@ -15,7 +15,7 @@ export async function POST(req: NextRequest) { // Rate limit by IP — prevent abuse of this public endpoint const ip = - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-forwarded-for')?.split(',', 1)[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; const rateLimiter = container.getRateLimiter(); @@ -54,7 +54,7 @@ export async function POST(req: NextRequest) { // Record rate-limiting on errors too try { const ip = - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + req.headers.get('x-forwarded-for')?.split(',', 1)[0]?.trim() ?? req.headers.get('x-real-ip') ?? 'unknown'; await container.getRateLimiter().recordLoginAttempt(ip, ip, false); diff --git a/app/api/customizations/route.ts b/app/api/customizations/route.ts index 0e07d505..85e8a581 100644 --- a/app/api/customizations/route.ts +++ b/app/api/customizations/route.ts @@ -21,7 +21,7 @@ export const GET = requireRole('DESIGNER')(async function GET() { return NextResponse.json( { - items: customizations.map(toCustomizationResponse), + items: customizations.map((c) => toCustomizationResponse(c)), }, { status: 200 }, ); diff --git a/app/api/sellers/route.ts b/app/api/sellers/route.ts index 24877016..fcc20ed5 100644 --- a/app/api/sellers/route.ts +++ b/app/api/sellers/route.ts @@ -38,7 +38,7 @@ export const GET = requireRole('ADMIN')(async function GET(req: NextRequest) { return NextResponse.json( { - items: result.items.map(toSellerResponse), + items: result.items.map((s) => toSellerResponse(s)), total: result.total, page: result.page, pageSize: result.pageSize, diff --git a/app/api/uploads/guest/presigned-url/route.ts b/app/api/uploads/guest/presigned-url/route.ts index 34e7574b..4d475f52 100644 --- a/app/api/uploads/guest/presigned-url/route.ts +++ b/app/api/uploads/guest/presigned-url/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { function getClientIp(req: NextRequest): string { const forwardedFor = req.headers.get('x-forwarded-for'); if (forwardedFor) { - return forwardedFor.split(',')[0]?.trim() || '0.0.0.0'; + return forwardedFor.split(',', 1)[0]?.trim() || '0.0.0.0'; } return req.headers.get('x-real-ip') ?? '0.0.0.0'; diff --git a/app/api/uploads/local/[...storageKey]/route.ts b/app/api/uploads/local/[...storageKey]/route.ts index 654441ca..3feb83ad 100644 --- a/app/api/uploads/local/[...storageKey]/route.ts +++ b/app/api/uploads/local/[...storageKey]/route.ts @@ -34,7 +34,7 @@ async function resolveStoragePath( return null; } - const sanitized = segments.map((s) => s.replace(/[:*?"<>|]/g, '_')); + const sanitized = segments.map((s) => s.replaceAll(/[:*?"<>|]/g, '_')); const filePath = path.resolve(root, ...sanitized); return filePath; diff --git a/components/products/infinite-product-list.tsx b/components/products/infinite-product-list.tsx index 26d29906..fb2dfd8e 100644 --- a/components/products/infinite-product-list.tsx +++ b/components/products/infinite-product-list.tsx @@ -111,12 +111,12 @@ export function InfiniteProductList({ if (next.length > 0) { const template = next.length === 1 ? labels.itemsLoadedOne : labels.itemsLoadedMany; - setAnnouncement(template.replace('{count}', String(next.length))); + setAnnouncement(template.split('{count}').join(String(next.length))); } else { setAnnouncement(''); } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Unknown error'); + } catch (error_: unknown) { + setError(error_ instanceof Error ? error_.message : 'Unknown error'); } finally { setIsLoading(false); } @@ -145,7 +145,7 @@ export function InfiniteProductList({ return (

{q.trim().length > 0 - ? labels.noSearchResults.replace('{term}', q) + ? labels.noSearchResults.replace('{term}', () => q) : labels.noProducts}

); diff --git a/components/products/search-input-with-suggestions.tsx b/components/products/search-input-with-suggestions.tsx index c755ea1f..186357f5 100644 --- a/components/products/search-input-with-suggestions.tsx +++ b/components/products/search-input-with-suggestions.tsx @@ -225,10 +225,12 @@ export function SearchInputWithSuggestions({ className={styles.searchToggle} aria-label={showInput ? labels.submitLabel : labels.ariaLabel} onMouseDown={(e) => { - if (!showInput) { - e.preventDefault(); - setShowInput(true); + if (showInput) { + return; } + + e.preventDefault(); + setShowInput(true); }} >
  • { - // mousedown (not click) so it fires before the input's blur. - e.preventDefault(); - handleSelect(s.term); - }} - > - {s.term} -
  • - )) - ) : showEmptySuggestions ? ( -
  • - {labels.noRecentSearches} -
  • - ) : null} + {hasSuggestions + ? suggestions.map((s, idx) => ( +
  • { + // mousedown (not click) so it fires before the input's blur. + e.preventDefault(); + handleSelect(s.term); + }} + > + {s.term} +
  • + )) + : showEmptySuggestions && ( +
  • + {labels.noRecentSearches} +
  • + )} )}
    diff --git a/composition-root/container.ts b/composition-root/container.ts index 3784f13b..19228785 100644 --- a/composition-root/container.ts +++ b/composition-root/container.ts @@ -102,47 +102,12 @@ import { RecordSearchUseCase } from '@/modules/search-history/application/record // State // --------------------------------------------------------------------------- -let _emailSender: EmailSender | null = null; -let _outboxRepository: OutboxRepository | null = null; -let _passwordHasher: PasswordHasher | null = null; -let _rateLimiter: RateLimiter | null = null; -let _resetTokenCodec: ResetTokenCodec | null = null; -let _eventBus: EventBusPort | null = null; -let _secrets: SecretsPort | null = null; -let _session: SessionPort | null = null; -let _userRepository: UserRepository | null = null; -let _roleRepository: RoleRepository | null = null; -let _orderRepository: OrderRepository | null = null; -let _productRepository: ProductRepository | null = null; -let _emailQueueRepository: EmailQueueRepository | null = null; -let _userLookup: UserLookupPort | null = null; -let _forgotPasswordEmailPort: ForgotPasswordEmailPort | null = null; -let _usedResetTokenStore: UsedResetTokenStorePort | null = null; -let _checkoutGroupLookup: CheckoutGroupLookupPort | null = null; -let _checkoutGroupPaymentPort: CheckoutGroupPaymentPort | null = null; -let _sellerRepository: SellerRepository | null = null; -let _sellerLookup: SellerLookupPort | null = null; -let _transactionRunner: TransactionRunner | null = null; -let _userVerification: UserVerificationPort | null = null; -let _roleValidator: RoleValidatorPort | null = null; -let _storagePort: StoragePort | null = null; -let _uploadRepository: UploadRepository | null = null; -let _cartRepository: CartRepository | null = null; -let _cartProductRepository: CartProductRepository | null = null; -let _paidOrderCountPort: PaidOrderCountPort | null = null; -let _customizationLookup: CartCustomizationLookupPort | null = null; -let _customizationRepository: CustomizationRepository | null = null; -let _searchHistoryRepository: SearchHistoryRepository | null = null; +const state: Record = {}; function isLocalUploadStorage(): boolean { return process.env.SEED_PRODUCT_ASSET_LOCAL_STORAGE === 'true'; } -// Idempotency flag for event subscriptions — prevents double registration -// during HMR in development. -let _cartEventsSubscribed = false; -let _searchHistoryEventsSubscribed = false; - // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- @@ -152,191 +117,72 @@ let _searchHistoryEventsSubscribed = false; * Idempotent — calling it again is a no-op because every binding is * short-circuited by a null check. */ -export function initContainer(): void { - // --- EmailSender: env-dependent (Brevo in production, console otherwise) --- - if (!_emailSender) { - _emailSender = - process.env.NODE_ENV === 'production' - ? new BrevoEmailSender() - : new ConsoleEmailSender(); - } - - // --- OutboxRepository: single Prisma adapter works in every env --- - if (!_outboxRepository) { - _outboxRepository = new PrismaOutboxRepository(); - } - - // --- PasswordHasher: bcrypt adapter wrapped to match the port --- - if (!_passwordHasher) { - _passwordHasher = { - hash: hashPassword, - verify: verifyPassword, - }; - } - - // --- RateLimiter: Prisma-backed adapter (works in every env) --- - if (!_rateLimiter) { - _rateLimiter = new PrismaRateLimiter(); - } - - // --- EventBus: process-wide in-memory bus (single-process default) --- - if (!_eventBus) { - _eventBus = eventBus; - } - - // --- SecretsPort: process.env with fail-fast validation --- - if (!_secrets) { - _secrets = new ProcessEnvSecrets(); - } - - // --- SessionPort: NextAuth adapter --- - if (!_session) { - _session = new NextAuthSessionAdapter(); - } - - // --- UserRepository: Prisma adapter --- - if (!_userRepository) { - _userRepository = new PrismaUserRepository(); - } - - // --- RoleRepository: Prisma adapter + seed --- - if (!_roleRepository) { - _roleRepository = new PrismaRoleRepository(); - // Seed default roles on first boot (idempotent, no-op if roles exist). - const seedRoles = new SeedRolesUseCase(_roleRepository); - seedRoles.execute().catch((err) => { - console.error('[container] Role seed failed:', err); - }); - } - - // --- OrderRepository: Prisma adapter --- - if (!_orderRepository) { - _orderRepository = new PrismaOrderRepository(); - } - - // --- CheckoutGroup payment wiring: Prisma adapters --- - if (!_checkoutGroupLookup) { - _checkoutGroupLookup = new PrismaCheckoutGroupLookup(); - } - if (!_checkoutGroupPaymentPort) { - _checkoutGroupPaymentPort = new PrismaCheckoutGroupPaymentPort(); - } - - // --- ProductRepository: Prisma adapter --- - if (!_productRepository) { - _productRepository = new PrismaProductRepository(); - } - - // --- EmailQueueRepository: Prisma adapter --- - if (!_emailQueueRepository) { - _emailQueueRepository = new PrismaEmailQueueRepository(); - } - - // --- UserLookupPort: Prisma adapter --- - if (!_userLookup) { - _userLookup = new PrismaUserLookup(); - } - - // --- ForgotPasswordEmailPort: console mock in dev (real email sender in prod via EmailSender) --- - if (!_forgotPasswordEmailPort) { - _forgotPasswordEmailPort = new ConsoleForgotPasswordEmail(); - } - - // --- UsedResetTokenStore: in-memory adapter (single process) --- - if (!_usedResetTokenStore) { - _usedResetTokenStore = new MemoryUsedResetTokenStore(); - } - - // --- SellerRepository: Prisma adapter --- - if (!_sellerRepository) { - _sellerRepository = new PrismaSellerRepository(); - } - // --- SellerLookupPort: adapter bridging orders' port to sellers infrastructure --- - if (!_sellerLookup) { - _sellerLookup = new SellerLookupAdapter(_sellerRepository!); - } - - // --- TransactionRunner: Prisma-backed atomic unit-of-work --- - if (!_transactionRunner) { - _transactionRunner = new PrismaTransactionRunner(); - } - - // --- UserVerificationPort: adapter bridging auth's port to users infrastructure --- - if (!_userVerification) { - _userVerification = new UserVerificationAdapter(_userRepository!); - } - - // --- RoleValidatorPort: adapter bridging users' port to roles infrastructure --- - if (!_roleValidator) { - _roleValidator = new RoleValidatorAdapter(_roleRepository!); - } - - // --- StoragePort: R2 or local adapter for uploads --- - if (!_storagePort) { - _storagePort = isLocalUploadStorage() - ? new LocalStorageAdapter() - : new R2StorageAdapter(); - } - - // --- UploadRepository: Prisma adapter --- - if (!_uploadRepository) { - _uploadRepository = new PrismaUploadRepository(); - } - - // --- CartRepository: Prisma adapter --- - if (!_cartRepository) { - _cartRepository = new PrismaCartRepository(); - } - - // --- CartProductRepository: adapter bridging cart's port to the products module --- - if (!_cartProductRepository) { - _cartProductRepository = new CartProductRepositoryAdapter( - _productRepository!, - ); - } - - // --- PaidOrderCountPort: adapter bridging cart's port to the orders module --- - if (!_paidOrderCountPort) { - _paidOrderCountPort = new PrismaPaidOrderCountAdapter(_orderRepository!); - } - - // --- CustomizationRepository: Prisma adapter --- - if (!_customizationRepository) { - _customizationRepository = new PrismaCustomizationRepository(); - } - - // --- CustomizationLookupPort: shared adapter for cart and orders ports --- - if (!_customizationLookup) { - _customizationLookup = new CustomizationLookupAdapter( - _customizationRepository!, - ); - } - - // --- SearchHistoryRepository: Prisma adapter --- - if (!_searchHistoryRepository) { - _searchHistoryRepository = new PrismaSearchHistoryRepository(); - } +/** + * Initialize all dependency bindings for the current environment. + * Idempotent — calling it again is a no-op because each getter is guarded + * by a null check. + * + * Each getter lazily initializes its own dependency so this function simply + * calls every getter to trigger first-time initialization. + */ +export function initContainer(): void { + getEmailSender(); + getOutboxRepository(); + getPasswordHasher(); + getRateLimiter(); + getEventBus(); + getSecrets(); + getSession(); + getUserRepository(); + getRoleRepository(); + getOrderRepository(); + getCheckoutGroupLookup(); + getCheckoutGroupPaymentPort(); + getProductRepository(); + getEmailQueueRepository(); + getUserLookup(); + getForgotPasswordEmailPort(); + getUsedResetTokenStore(); + getSellerRepository(); + getSellerLookup(); + getTransactionRunner(); + getUserVerification(); + getRoleValidator(); + getStoragePort(); + getUploadRepository(); + getCartRepository(); + getCartProductRepository(); + getPaidOrderCountPort(); + getCustomizationRepository(); + getCustomizationLookup(); + getSearchHistoryRepository(); + getResetTokenCodec(); // --- Cart event subscriptions (idempotent for HMR) --- - if (!_cartEventsSubscribed) { + if (!state.isCartEventsSubscribed) { const handler = new HandleCartCheckedOut( - _orderRepository!, - _outboxRepository!, - _transactionRunner!, - _customizationLookup!, + state.orderRepository as OrderRepository, + state.outboxRepository as OutboxRepository, + state.transactionRunner as TransactionRunner, + state.customizationLookup as CartCustomizationLookupPort, ); - HandleCartCheckedOut.subscribe(_eventBus!, handler); - _cartEventsSubscribed = true; + HandleCartCheckedOut.subscribe(state.eventBus as EventBusPort, handler); + state.isCartEventsSubscribed = true; } // --- Search-history event subscriptions (idempotent for HMR) --- - if (!_searchHistoryEventsSubscribed) { + if (!state.isSearchHistoryEventsSubscribed) { const subscriber = new HandleProductSearchExecuted( - new RecordSearchUseCase(_searchHistoryRepository!), + new RecordSearchUseCase( + state.searchHistoryRepository as SearchHistoryRepository, + ), + ); + HandleProductSearchExecuted.subscribe( + state.eventBus as EventBusPort, + subscriber, ); - HandleProductSearchExecuted.subscribe(_eventBus!, subscriber); - _searchHistoryEventsSubscribed = true; + state.isSearchHistoryEventsSubscribed = true; } } @@ -344,292 +190,207 @@ export function initContainer(): void { // Getters (auto-initialize on first access) // --------------------------------------------------------------------------- -/** - * Returns the EmailSender bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getEmailSender(): EmailSender { - if (!_emailSender) initContainer(); - return _emailSender!; + if (!state.emailSender) { + state.emailSender = + process.env.NODE_ENV === 'production' + ? new BrevoEmailSender() + : new ConsoleEmailSender(); + } + return state.emailSender as EmailSender; } -/** - * Returns the OutboxRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getOutboxRepository(): OutboxRepository { - if (!_outboxRepository) initContainer(); - return _outboxRepository!; + state.outboxRepository ??= new PrismaOutboxRepository(); + return state.outboxRepository as OutboxRepository; } -/** - * Returns the PasswordHasher bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getPasswordHasher(): PasswordHasher { - if (!_passwordHasher) initContainer(); - return _passwordHasher!; + if (!state.passwordHasher) { + state.passwordHasher = { + hash: hashPassword, + verify: verifyPassword, + }; + } + return state.passwordHasher as PasswordHasher; } -/** - * Returns the RateLimiter bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getRateLimiter(): RateLimiter { - if (!_rateLimiter) initContainer(); - return _rateLimiter!; + state.rateLimiter ??= new PrismaRateLimiter(); + return state.rateLimiter as RateLimiter; } -/** - * Returns the ResetTokenCodec bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - * Lazy-creates a JwtResetTokenCodec using the secret from SecretsPort. - * In tests, call `container.setResetTokenCodec()` BEFORE any getter to - * inject a Base64ResetTokenCodec that doesn't need NEXTAUTH_SECRET. - */ export function getResetTokenCodec(): ResetTokenCodec { - if (!_resetTokenCodec) { - initContainer(); - _resetTokenCodec = new JwtResetTokenCodec(_secrets!.getAuthSecret()); + if (!state.resetTokenCodec) { + state.resetTokenCodec = new JwtResetTokenCodec( + getSecrets().getAuthSecret(), + ); } - return _resetTokenCodec; + return state.resetTokenCodec as ResetTokenCodec; } -/** - * Returns the EventBus bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - * Default binding is the in-memory `eventBus` singleton. - */ export function getEventBus(): EventBusPort { - if (!_eventBus) initContainer(); - return _eventBus!; + state.eventBus ??= eventBus; + return state.eventBus as EventBusPort; } -/** - * Returns the SecretsPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getSecrets(): SecretsPort { - if (!_secrets) initContainer(); - return _secrets!; + state.secrets ??= new ProcessEnvSecrets(); + return state.secrets as SecretsPort; } -/** - * Returns the SessionPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getSession(): SessionPort { - if (!_session) initContainer(); - return _session!; + state.session ??= new NextAuthSessionAdapter(); + return state.session as SessionPort; } -/** - * Returns the UserRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getUserRepository(): UserRepository { - if (!_userRepository) initContainer(); - return _userRepository!; + state.userRepository ??= new PrismaUserRepository(); + return state.userRepository as UserRepository; } -/** - * Returns the RoleRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getRoleRepository(): RoleRepository { - if (!_roleRepository) initContainer(); - return _roleRepository!; + if (!state.roleRepository) { + state.roleRepository = new PrismaRoleRepository(); + const seedRoles = new SeedRolesUseCase( + state.roleRepository as RoleRepository, + ); + (async () => { + try { + await seedRoles.execute(); + } catch (error) { + console.error('[container] Role seed failed:', error); + } + })(); + } + return state.roleRepository as RoleRepository; } -/** - * Returns the OrderRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getOrderRepository(): OrderRepository { - if (!_orderRepository) initContainer(); - return _orderRepository!; + state.orderRepository ??= new PrismaOrderRepository(); + return state.orderRepository as OrderRepository; } -/** - * Returns the CheckoutGroupLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getCheckoutGroupLookup(): CheckoutGroupLookupPort { - if (!_checkoutGroupLookup) initContainer(); - return _checkoutGroupLookup!; + state.checkoutGroupLookup ??= new PrismaCheckoutGroupLookup(); + return state.checkoutGroupLookup as CheckoutGroupLookupPort; } -/** - * Returns the CheckoutGroupPaymentPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getCheckoutGroupPaymentPort(): CheckoutGroupPaymentPort { - if (!_checkoutGroupPaymentPort) initContainer(); - return _checkoutGroupPaymentPort!; + state.checkoutGroupPaymentPort ??= new PrismaCheckoutGroupPaymentPort(); + return state.checkoutGroupPaymentPort as CheckoutGroupPaymentPort; } -/** - * Returns the ProductRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getProductRepository(): ProductRepository { - if (!_productRepository) initContainer(); - return _productRepository!; + state.productRepository ??= new PrismaProductRepository(); + return state.productRepository as ProductRepository; } -/** - * Returns the EmailQueueRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getEmailQueueRepository(): EmailQueueRepository { - if (!_emailQueueRepository) initContainer(); - return _emailQueueRepository!; + state.emailQueueRepository ??= new PrismaEmailQueueRepository(); + return state.emailQueueRepository as EmailQueueRepository; } -/** - * Returns the UserLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getUserLookup(): UserLookupPort { - if (!_userLookup) initContainer(); - return _userLookup!; + state.userLookup ??= new PrismaUserLookup(); + return state.userLookup as UserLookupPort; } -/** - * Returns the ForgotPasswordEmailPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getForgotPasswordEmailPort(): ForgotPasswordEmailPort { - if (!_forgotPasswordEmailPort) initContainer(); - return _forgotPasswordEmailPort!; + state.forgotPasswordEmailPort ??= new ConsoleForgotPasswordEmail(); + return state.forgotPasswordEmailPort as ForgotPasswordEmailPort; } -/** - * Returns the UsedResetTokenStore bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getUsedResetTokenStore(): UsedResetTokenStorePort { - if (!_usedResetTokenStore) initContainer(); - return _usedResetTokenStore!; + state.usedResetTokenStore ??= new MemoryUsedResetTokenStore(); + return state.usedResetTokenStore as UsedResetTokenStorePort; } -/** - * Returns the SellerRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getSellerRepository(): SellerRepository { - if (!_sellerRepository) initContainer(); - return _sellerRepository!; + state.sellerRepository ??= new PrismaSellerRepository(); + return state.sellerRepository as SellerRepository; } -/** - * Returns the SellerLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getSellerLookup(): SellerLookupPort { - if (!_sellerLookup) initContainer(); - return _sellerLookup!; + if (!state.sellerLookup) { + state.sellerLookup = new SellerLookupAdapter(getSellerRepository()); + } + return state.sellerLookup as SellerLookupPort; } -/** - * Returns the TransactionRunner bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - * Use this in use cases that need to persist multiple writes atomically - * (e.g. user + seller in one go). - */ export function getTransactionRunner(): TransactionRunner { - if (!_transactionRunner) initContainer(); - return _transactionRunner!; + state.transactionRunner ??= new PrismaTransactionRunner(); + return state.transactionRunner as TransactionRunner; } -/** - * Returns the UserVerificationPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getUserVerification(): UserVerificationPort { - if (!_userVerification) initContainer(); - return _userVerification!; + if (!state.userVerification) { + state.userVerification = new UserVerificationAdapter(getUserRepository()); + } + return state.userVerification as UserVerificationPort; } -/** - * Returns the RoleValidatorPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getRoleValidator(): RoleValidatorPort { - if (!_roleValidator) initContainer(); - return _roleValidator!; + if (!state.roleValidator) { + state.roleValidator = new RoleValidatorAdapter(getRoleRepository()); + } + return state.roleValidator as RoleValidatorPort; } -/** - * Returns the StoragePort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getStoragePort(): StoragePort { - if (!_storagePort) initContainer(); - return _storagePort!; + if (!state.storagePort) { + state.storagePort = isLocalUploadStorage() + ? new LocalStorageAdapter() + : new R2StorageAdapter(); + } + return state.storagePort as StoragePort; } -/** - * Returns the UploadRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getUploadRepository(): UploadRepository { - if (!_uploadRepository) initContainer(); - return _uploadRepository!; + state.uploadRepository ??= new PrismaUploadRepository(); + return state.uploadRepository as UploadRepository; } -/** - * Returns the CartRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getCartRepository(): CartRepository { - if (!_cartRepository) initContainer(); - return _cartRepository!; + state.cartRepository ??= new PrismaCartRepository(); + return state.cartRepository as CartRepository; } -/** - * Returns the CartProductRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getCartProductRepository(): CartProductRepository { - if (!_cartProductRepository) initContainer(); - return _cartProductRepository!; + if (!state.cartProductRepository) { + state.cartProductRepository = new CartProductRepositoryAdapter( + getProductRepository(), + ); + } + return state.cartProductRepository as CartProductRepository; } -/** - * Returns the PaidOrderCountPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getPaidOrderCountPort(): PaidOrderCountPort { - if (!_paidOrderCountPort) initContainer(); - return _paidOrderCountPort!; + if (!state.paidOrderCountPort) { + state.paidOrderCountPort = new PrismaPaidOrderCountAdapter( + getOrderRepository(), + ); + } + return state.paidOrderCountPort as PaidOrderCountPort; } -/** - * Returns the CustomizationLookupPort bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ -export function getCustomizationLookup(): CartCustomizationLookupPort { - if (!_customizationLookup) initContainer(); - return _customizationLookup!; +export function getCustomizationRepository(): CustomizationRepository { + state.customizationRepository ??= new PrismaCustomizationRepository(); + return state.customizationRepository as CustomizationRepository; } -/** - * Returns the CustomizationRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ -export function getCustomizationRepository(): CustomizationRepository { - if (!_customizationRepository) initContainer(); - return _customizationRepository!; +export function getCustomizationLookup(): CartCustomizationLookupPort { + if (!state.customizationLookup) { + state.customizationLookup = new CustomizationLookupAdapter( + getCustomizationRepository(), + ); + } + return state.customizationLookup as CartCustomizationLookupPort; } -/** - * Returns the SearchHistoryRepository bound for the current environment. - * Auto-initializes the container on first call if not already initialized. - */ export function getSearchHistoryRepository(): SearchHistoryRepository { - if (!_searchHistoryRepository) initContainer(); - return _searchHistoryRepository!; + state.searchHistoryRepository ??= new PrismaSearchHistoryRepository(); + return state.searchHistoryRepository as SearchHistoryRepository; } // --------------------------------------------------------------------------- @@ -675,135 +436,103 @@ export const container = { getCustomizationLookup, getCustomizationRepository, getSearchHistoryRepository, - /** Override — useful in tests to inject a mock without touching env vars. */ setEmailSender(sender: EmailSender): void { - _emailSender = sender; + state.emailSender = sender; }, - /** Override — useful in tests to inject an in-memory outbox. */ setOutboxRepository(repo: OutboxRepository): void { - _outboxRepository = repo; + state.outboxRepository = repo; }, - /** Override — useful in tests to inject a fake/stub hasher. */ setPasswordHasher(hasher: PasswordHasher): void { - _passwordHasher = hasher; + state.passwordHasher = hasher; }, - /** Override — useful in tests to inject an in-memory rate limiter. */ setRateLimiter(limiter: RateLimiter): void { - _rateLimiter = limiter; + state.rateLimiter = limiter; }, - /** Override — useful in tests to inject a Base64ResetTokenCodec. */ setResetTokenCodec(codec: ResetTokenCodec): void { - _resetTokenCodec = codec; + state.resetTokenCodec = codec; }, - /** Override — useful in tests to inject a fresh event bus (avoids handler leakage). */ setEventBus(bus: EventBusPort): void { - _eventBus = bus; + state.eventBus = bus; }, - /** Override — useful in tests to inject mock secrets. */ setSecrets(secrets: SecretsPort): void { - _secrets = secrets; + state.secrets = secrets; }, - /** Override — useful in tests to simulate authenticated/unauthenticated sessions. */ setSession(session: SessionPort): void { - _session = session; + state.session = session; }, - /** Override — useful in tests to inject an in-memory user repository. */ setUserRepository(repo: UserRepository): void { - _userRepository = repo; + state.userRepository = repo; }, - /** Override — useful in tests to inject an in-memory role repository. */ setRoleRepository(repo: RoleRepository): void { - _roleRepository = repo; + state.roleRepository = repo; }, - /** Override — useful in tests to inject an in-memory order repository. */ setOrderRepository(repo: OrderRepository): void { - _orderRepository = repo; + state.orderRepository = repo; }, - /** Override — useful in tests to inject a mock checkout-group lookup port. */ setCheckoutGroupLookup(port: CheckoutGroupLookupPort): void { - _checkoutGroupLookup = port; + state.checkoutGroupLookup = port; }, - /** Override — useful in tests to inject a mock checkout-group payment port. */ setCheckoutGroupPaymentPort(port: CheckoutGroupPaymentPort): void { - _checkoutGroupPaymentPort = port; + state.checkoutGroupPaymentPort = port; }, - /** Override — useful in tests to inject an in-memory product repository. */ setProductRepository(repo: ProductRepository): void { - _productRepository = repo; + state.productRepository = repo; }, - /** Override — useful in tests to inject an in-memory email queue. */ setEmailQueueRepository(repo: EmailQueueRepository): void { - _emailQueueRepository = repo; + state.emailQueueRepository = repo; }, - /** Override — useful in tests to inject an in-memory user lookup. */ setUserLookup(port: UserLookupPort): void { - _userLookup = port; + state.userLookup = port; }, - /** Override — useful in tests to inject a mock ForgotPasswordEmailPort. */ setForgotPasswordEmailPort(port: ForgotPasswordEmailPort): void { - _forgotPasswordEmailPort = port; + state.forgotPasswordEmailPort = port; }, - /** Override — useful in tests to inject a fresh UsedResetTokenStore. */ setUsedResetTokenStore(store: UsedResetTokenStorePort): void { - _usedResetTokenStore = store; + state.usedResetTokenStore = store; }, - /** Override — useful in tests to inject an in-memory seller repository. */ setSellerRepository(repo: SellerRepository): void { - _sellerRepository = repo; + state.sellerRepository = repo; }, - /** Override — useful in tests to inject a mock seller lookup port. */ setSellerLookup(port: SellerLookupPort): void { - _sellerLookup = port; + state.sellerLookup = port; }, - /** Override — useful in tests to inject a fake/stub transaction runner. */ setTransactionRunner(runner: TransactionRunner): void { - _transactionRunner = runner; + state.transactionRunner = runner; }, - /** Override — useful in tests to inject a mock UserVerificationPort. */ setUserVerification(port: UserVerificationPort): void { - _userVerification = port; + state.userVerification = port; }, - /** Override — useful in tests to inject a mock RoleValidatorPort. */ setRoleValidator(port: RoleValidatorPort): void { - _roleValidator = port; + state.roleValidator = port; }, - /** Override — useful in tests to inject a mock StoragePort. */ setStoragePort(port: StoragePort): void { - _storagePort = port; + state.storagePort = port; }, - /** Override — useful in tests to inject an in-memory upload repository. */ setUploadRepository(repo: UploadRepository): void { - _uploadRepository = repo; + state.uploadRepository = repo; }, - /** Override — useful in tests to inject an in-memory cart repository. */ setCartRepository(repo: CartRepository): void { - _cartRepository = repo; + state.cartRepository = repo; }, - /** Override — useful in tests to inject a mock cart product repository. */ setCartProductRepository(repo: CartProductRepository): void { - _cartProductRepository = repo; + state.cartProductRepository = repo; }, - /** Override — useful in tests to inject a mock paid order count port. */ setPaidOrderCountPort(port: PaidOrderCountPort): void { - _paidOrderCountPort = port; + state.paidOrderCountPort = port; }, - /** Override — useful in tests to inject a mock customization lookup port. */ setCustomizationLookup(port: CartCustomizationLookupPort): void { - _customizationLookup = port; + state.customizationLookup = port; }, - /** Override — useful in tests to inject a mock customization repository. */ setCustomizationRepository(repo: CustomizationRepository): void { - _customizationRepository = repo; + state.customizationRepository = repo; }, - /** Override — useful in tests to inject an in-memory search-history repository. */ setSearchHistoryRepository(repo: SearchHistoryRepository): void { - _searchHistoryRepository = repo; + state.searchHistoryRepository = repo; }, - /** Reset the search-history event subscription flag — useful in tests to allow re-subscription. */ resetSearchHistoryEventSubscriptions(): void { - _searchHistoryEventsSubscribed = false; - } /** Reset the event subscription flag — useful in tests to allow re-subscription. */, + state.isSearchHistoryEventsSubscribed = false; + }, resetCartEventSubscriptions(): void { - _cartEventsSubscribed = false; + state.isCartEventsSubscribed = false; }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index fd5f2cbe..65ad5fd6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -91,7 +91,7 @@ export default [ scopeToCodeFiles(eslintJs.configs.recommended), // TypeScript recommended + type-checked rules - ...tseslint.configs.recommended.map(scopeToCodeFiles), + ...tseslint.configs.recommended.map((c) => scopeToCodeFiles(c)), // ESLint React for TypeScript (replaces eslint-plugin-react) scopeToCodeFiles(eslintReact.configs['recommended-typescript']), @@ -152,31 +152,31 @@ export default [ 'unicorn/name-replacements': 'off', 'unicorn/import-style': 'off', - 'unicorn/no-top-level-assignment-in-function': 'off', - 'unicorn/consistent-class-member-order': 'off', - 'unicorn/no-negated-condition': 'off', - 'unicorn/consistent-boolean-name': 'off', - 'unicorn/prefer-await': 'off', - 'unicorn/prefer-export-from': 'off', - 'unicorn/catch-error-name': 'off', - 'unicorn/prefer-global-this': 'off', - 'unicorn/explicit-length-check': 'off', - 'unicorn/prefer-split-limit': 'off', - 'unicorn/no-array-callback-reference': 'off', - 'unicorn/prefer-string-replace-all': 'off', - 'unicorn/consistent-conditional-object-spread': 'off', - 'sonarjs/no-nested-conditional': 'off', - 'unicorn/prefer-type-error': 'off', - 'unicorn/no-computed-property-existence-check': 'off', - 'unicorn/switch-case-braces': 'off', - 'unicorn/no-unsafe-string-replacement': 'off', - 'unicorn/prefer-unicode-code-point-escapes': 'off', - 'unicorn/prefer-early-return': 'off', - 'unicorn/require-array-sort-compare': 'off', - 'sonarjs/cognitive-complexity': 'off', - 'sonarjs/no-nested-template-literals': 'off', - 'unicorn/no-nested-ternary': 'off', - 'unicorn/no-array-sort': 'off', + //'unicorn/no-top-level-assignment-in-function': 'off', + //'unicorn/consistent-class-member-order': 'off', + //'unicorn/no-negated-condition': 'off', + //'unicorn/consistent-boolean-name': 'off', + //'unicorn/prefer-await': 'off', + //'unicorn/prefer-export-from': 'off', + //'unicorn/catch-error-name': 'off', + //'unicorn/prefer-global-this': 'off', + //'unicorn/explicit-length-check': 'off', + //'unicorn/prefer-split-limit': 'off', + //'unicorn/no-array-callback-reference': 'off', + //'unicorn/prefer-string-replace-all': 'off', + //'unicorn/consistent-conditional-object-spread': 'off', + //'sonarjs/no-nested-conditional': 'off', + //'unicorn/prefer-type-error': 'off', + //'unicorn/no-computed-property-existence-check': 'off', + //'unicorn/switch-case-braces': 'off', + //'unicorn/no-unsafe-string-replacement': 'off', + //'unicorn/prefer-unicode-code-point-escapes': 'off', + //'unicorn/prefer-early-return': 'off', + //'unicorn/require-array-sort-compare': 'off', + //'sonarjs/cognitive-complexity': 'off', + //'sonarjs/no-nested-template-literals': 'off', + //'unicorn/no-nested-ternary': 'off', + //'unicorn/no-array-sort': 'off', // SonarJS: ajustar el umbral de complejidad cognitiva si el default es muy estricto // 'sonarjs/cognitive-complexity': ['warn', 15], diff --git a/modules/auth/domain/rate-limiter.ts b/modules/auth/domain/rate-limiter.ts index 0a65c361..9f3a0ed2 100644 --- a/modules/auth/domain/rate-limiter.ts +++ b/modules/auth/domain/rate-limiter.ts @@ -1,7 +1,5 @@ import type { RateLimitResult } from './entities/rate-limit-result'; -export type { RateLimitResult }; - /** * RateLimiter — the port for login attempt rate limiting. * @@ -36,6 +34,8 @@ export interface RateLimiter { recordLoginAttempt( email: string, ip: string, - success: boolean, + isSuccess: boolean, ): Promise; } + +export { type RateLimitResult } from './entities/rate-limit-result'; diff --git a/modules/auth/domain/session.ts b/modules/auth/domain/session.ts index aa5f35d1..3d65e8cb 100644 --- a/modules/auth/domain/session.ts +++ b/modules/auth/domain/session.ts @@ -1,7 +1,5 @@ import type { SessionUser } from './entities/session-user'; -export type { SessionUser }; - /** * SessionPort — the port for retrieving the current user's session. * @@ -23,3 +21,5 @@ export interface SessionPort { */ getSession(): Promise; } + +export { type SessionUser } from './entities/session-user'; diff --git a/modules/auth/domain/user-lookup.ts b/modules/auth/domain/user-lookup.ts index 262fd4c2..62b4932c 100644 --- a/modules/auth/domain/user-lookup.ts +++ b/modules/auth/domain/user-lookup.ts @@ -1,7 +1,5 @@ import type { UserLookupResult } from './entities/user-lookup-result'; -export type { UserLookupResult }; - /** * UserLookupPort — the port for fetching a minimal user record used by * authorization proxy. @@ -18,3 +16,5 @@ export type { UserLookupResult }; export interface UserLookupPort { findById(userId: string): Promise; } + +export { type UserLookupResult } from './entities/user-lookup-result'; diff --git a/modules/auth/domain/value-objects/verification-token.ts b/modules/auth/domain/value-objects/verification-token.ts index 254856c3..c9fd77fd 100644 --- a/modules/auth/domain/value-objects/verification-token.ts +++ b/modules/auth/domain/value-objects/verification-token.ts @@ -1,10 +1,4 @@ export class VerificationToken { - readonly value: string; - - private constructor(value: string) { - this.value = value; - } - static create(value: string): VerificationToken { const trimmed = value.trim(); @@ -15,6 +9,12 @@ export class VerificationToken { return new VerificationToken(trimmed); } + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + equals(other: VerificationToken): boolean { return other instanceof VerificationToken && this.value === other.value; } diff --git a/modules/auth/infrastructure/prisma-rate-limiter.ts b/modules/auth/infrastructure/prisma-rate-limiter.ts index fe96e4dd..6679c3c4 100644 --- a/modules/auth/infrastructure/prisma-rate-limiter.ts +++ b/modules/auth/infrastructure/prisma-rate-limiter.ts @@ -56,10 +56,10 @@ export class PrismaRateLimiter implements RateLimiter { async recordLoginAttempt( email: string, ip: string, - success: boolean, + isSuccess: boolean, ): Promise { await prisma.loginAttempt.create({ - data: { email, ipAddress: ip, success }, + data: { email, ipAddress: ip, success: isSuccess }, }); } } diff --git a/modules/cart/application/add-item-to-cart.ts b/modules/cart/application/add-item-to-cart.ts index 6e0aee97..7677d316 100644 --- a/modules/cart/application/add-item-to-cart.ts +++ b/modules/cart/application/add-item-to-cart.ts @@ -54,6 +54,44 @@ export class AddItemToCart { private customizationLookup: CustomizationLookupPort, ) {} + /** + * Validates that all customization IDs exist and belong to the + * target product (and therefore the same seller). Throws + * InvalidCustomizationError if any ID is missing or belongs to + * a different product. + */ + private async validateCustomizations( + customizationIdList: string[], + productId: string, + _sellerId: string, + ): Promise { + const snapshots = + await this.customizationLookup.findByIds(customizationIdList); + + // Check all IDs were found + if (snapshots.length !== customizationIdList.length) { + throw new InvalidCustomizationError( + `Some customization IDs do not exist`, + `One or more selected customizations are not available`, + ); + } + + // Check all customizations belong to the product + for (const snapshot of snapshots) { + if (snapshot.productId !== productId) { + throw new InvalidCustomizationError( + `Customization ${snapshot.id} does not belong to product ${productId}`, + `One or more selected customizations are not available for this product`, + ); + } + } + + // Note: sellerId validation is implicit — customizations derive + // their sellerId from the Product. If productId matches, the + // sellerId is guaranteed to match (enforced at customization + // creation time by the customizations module). + } + async execute(dto: AddItemToCartDTO): Promise { // 1. Validate quantity (throws InvalidQuantityError on out-of-range) const quantity = Quantity.create(dto.quantity); @@ -128,7 +166,9 @@ export class AddItemToCart { sellerId: product.sellerId, quantity: quantity.value, unitPriceSnapshot: Money.create(product.basePrice, product.currency), - customizationIdList: [...customizationIdList].sort(), + customizationIdList: [...customizationIdList].toSorted((a, b) => + a.localeCompare(b), + ), }; updatedItems = [...cart!.items, updatedItem]; } @@ -160,44 +200,6 @@ export class AddItemToCart { return updatedItem; } - - /** - * Validates that all customization IDs exist and belong to the - * target product (and therefore the same seller). Throws - * InvalidCustomizationError if any ID is missing or belongs to - * a different product. - */ - private async validateCustomizations( - customizationIdList: string[], - productId: string, - _sellerId: string, - ): Promise { - const snapshots = - await this.customizationLookup.findByIds(customizationIdList); - - // Check all IDs were found - if (snapshots.length !== customizationIdList.length) { - throw new InvalidCustomizationError( - `Some customization IDs do not exist`, - `One or more selected customizations are not available`, - ); - } - - // Check all customizations belong to the product - for (const snapshot of snapshots) { - if (snapshot.productId !== productId) { - throw new InvalidCustomizationError( - `Customization ${snapshot.id} does not belong to product ${productId}`, - `One or more selected customizations are not available for this product`, - ); - } - } - - // Note: sellerId validation is implicit — customizations derive - // their sellerId from the Product. If productId matches, the - // sellerId is guaranteed to match (enforced at customization - // creation time by the customizations module). - } } // --- helpers --- @@ -210,7 +212,9 @@ function isSameVariant( if (!item.productId.equals(productId)) return false; // Compare sorted customization ID lists — same IDs in any order // means the same variant. - const a = [...item.customizationIdList].sort(); - const b = [...customizationIdList].sort(); + const a = [...item.customizationIdList].toSorted((a, b) => + a.localeCompare(b), + ); + const b = [...customizationIdList].toSorted((a, b) => a.localeCompare(b)); return JSON.stringify(a) === JSON.stringify(b); } diff --git a/modules/cart/application/checkout-cart.ts b/modules/cart/application/checkout-cart.ts index 4f241ca8..e23f13f8 100644 --- a/modules/cart/application/checkout-cart.ts +++ b/modules/cart/application/checkout-cart.ts @@ -66,7 +66,7 @@ export interface CheckoutResult { * surface a 409 with the diff. * - Never mutates state and never emits events. * - * confirm(userId, acceptPriceChanges) + * confirm(userId, shouldAcceptPriceChanges) * - Atomically: * 1. Mark the cart CHECKED_OUT * 2. Emit CART_CHECKED_OUT with the snapshot payload @@ -76,7 +76,7 @@ export interface CheckoutResult { * versa) — this is the Transactional Outbox Pattern (REQ-CART-022). * - The actual Order creation is the Orders module's job (handled * by HandleCartCheckedOut subscribed to CART_CHECKED_OUT). - * - `acceptPriceChanges=true` updates each item's unitPriceSnapshot + * - `shouldAcceptPriceChanges=true` updates each item's unitPriceSnapshot * to the current product price before locking the cart. * * The use case depends only on ports (CartRepository, ProductRepository, @@ -94,106 +94,18 @@ export class CheckoutCart { private customizationLookup: CustomizationLookupPort, ) {} - async preview(userId: string): Promise { - const { totals } = await this.buildTotals(userId, false); - return totals; - } - - async confirm( - userId: string, - acceptPriceChanges: boolean, - ): Promise { - const { cart, totals, priceChanges } = await this.buildTotals( - userId, - acceptPriceChanges, - ); - - // Resolve customization snapshots for all items. We collect all - // unique customization IDs, fetch them in one batch, then build a - // per-item snapshot map. Deleted customization IDs are filtered out; - // null snapshot only when original list is empty — the order module - // will handle the missing data gracefully. - const allCustomizationIds = cart.items.flatMap( - (item) => item.customizationIdList, - ); - const uniqueCustomizationIds = [...new Set(allCustomizationIds)]; - const customizationSnapshots = - uniqueCustomizationIds.length > 0 - ? await this.customizationLookup.findByIds(uniqueCustomizationIds) - : []; - const customizationMap = new Map( - customizationSnapshots.map((s) => [s.id, s]), - ); - - // Build the event payload. Each item carries the live snapshot - // (already updated if `acceptPriceChanges` was true) plus a - // frozen customization snapshot for the order module. - const itemsPayload = cart.items.map((item) => ({ - productId: item.productId.value, - sellerId: item.sellerId.value, - quantity: item.quantity, - unitPrice: item.unitPriceSnapshot.amount, - customizationIdList: item.customizationIdList, - customizationSnapshot: buildCustomizationSnapshot( - item.customizationIdList, - customizationMap, - ), - })); - - const eventPayload: Record = { - cartId: cart.id, - userId: cart.userId, - items: itemsPayload, - subtotal: totals.subtotal, - discountApplied: totals.discount, - shippingCost: totals.shipping, - totalAmount: totals.total, - currency: 'EUR', - isFirstPurchase: totals.isFirstPurchase, - occurredAt: new Date().toISOString(), - }; - - // Mark the cart as CHECKED_OUT and emit CART_CHECKED_OUT in a single - // atomic unit of work (Transactional Outbox Pattern, REQ-CART-022). - // If either write fails, both are rolled back so the database never - // holds a CHECKED_OUT cart without its corresponding outbox event. - await this.transactionRunner.run(async (tx) => { - await this.cartRepository.markCheckedOut(CartId.create(cart.id), tx); - await this.outboxRepository.saveEvent( - GlobalEvents.CART_CHECKED_OUT, - eventPayload, - tx, - ); - }); - - // The Orders module is responsible for creating one Order per - // seller. It subscribes to CART_CHECKED_OUT and emits ORDER_CREATED - // per seller. The use case returns an empty list — the API can - // poll the orders endpoint after the redirect to surface the ids. - return { - cart: { ...cart, status: CartStatus.CheckedOut }, - totals, - eventPayload, - orderIds: [], - // priceChanges is included for API responses that want to log - // the diff alongside the result (not part of the public response). - // Keep the linter happy when the value is empty. - ...(priceChanges.length > 0 ? { priceChanges } : {}), - } as CheckoutResult & { priceChanges?: PriceChange[] }; - } - // --- internals --- /** * Loads the cart, fetches the current product prices, computes * totals, and (optionally) updates snapshots. Returns the cart - * (with updated snapshots when `acceptPriceChanges` is true) and + * (with updated snapshots when `shouldAcceptPriceChanges` is true) and * the computed totals. Throws PriceChangedError on price drift - * when `acceptPriceChanges` is false. + * when `shouldAcceptPriceChanges` is false. */ private async buildTotals( userId: string, - acceptPriceChanges: boolean, + shouldAcceptPriceChanges: boolean, ): Promise<{ cart: CartEntity; totals: CheckoutTotals; @@ -243,7 +155,7 @@ export class CheckoutCart { ), }; priceChanges.push(change); - if (acceptPriceChanges) { + if (shouldAcceptPriceChanges) { return { ...item, unitPriceSnapshot: Money.create( @@ -256,7 +168,7 @@ export class CheckoutCart { return item; }); - if (priceChanges.length > 0 && !acceptPriceChanges) { + if (priceChanges.length > 0 && !shouldAcceptPriceChanges) { throw new PriceChangedError( `${priceChanges.length} item(s) have a different price than when added`, priceChanges, @@ -266,7 +178,7 @@ export class CheckoutCart { // Persist the updated snapshots (if any) so the cart reflects the // new prices. Status stays ACTIVE — checkout hasn't run yet. const liveCart = - acceptPriceChanges && priceChanges.length > 0 + shouldAcceptPriceChanges && priceChanges.length > 0 ? await this.cartRepository.save({ ...cart, items: updatedItems, @@ -274,7 +186,7 @@ export class CheckoutCart { }) : cart; - // Compute totals from the live items (so acceptPriceChanges reflects + // Compute totals from the live items (so shouldAcceptPriceChanges reflects // the updated snapshot prices in subtotal/discount/total). const subtotal = round2( liveCart.items.reduce( @@ -304,6 +216,94 @@ export class CheckoutCart { priceChanges, }; } + + async preview(userId: string): Promise { + const { totals } = await this.buildTotals(userId, false); + return totals; + } + + async confirm( + userId: string, + shouldAcceptPriceChanges: boolean, + ): Promise { + const { cart, totals, priceChanges } = await this.buildTotals( + userId, + shouldAcceptPriceChanges, + ); + + // Resolve customization snapshots for all items. We collect all + // unique customization IDs, fetch them in one batch, then build a + // per-item snapshot map. Deleted customization IDs are filtered out; + // null snapshot only when original list is empty — the order module + // will handle the missing data gracefully. + const allCustomizationIds = cart.items.flatMap( + (item) => item.customizationIdList, + ); + const uniqueCustomizationIds = [...new Set(allCustomizationIds)]; + const customizationSnapshots = + uniqueCustomizationIds.length > 0 + ? await this.customizationLookup.findByIds(uniqueCustomizationIds) + : []; + const customizationMap = new Map( + customizationSnapshots.map((s) => [s.id, s]), + ); + + // Build the event payload. Each item carries the live snapshot + // (already updated if `shouldAcceptPriceChanges` was true) plus a + // frozen customization snapshot for the order module. + const itemsPayload = cart.items.map((item) => ({ + productId: item.productId.value, + sellerId: item.sellerId.value, + quantity: item.quantity, + unitPrice: item.unitPriceSnapshot.amount, + customizationIdList: item.customizationIdList, + customizationSnapshot: buildCustomizationSnapshot( + item.customizationIdList, + customizationMap, + ), + })); + + const eventPayload: Record = { + cartId: cart.id, + userId: cart.userId, + items: itemsPayload, + subtotal: totals.subtotal, + discountApplied: totals.discount, + shippingCost: totals.shipping, + totalAmount: totals.total, + currency: 'EUR', + isFirstPurchase: totals.isFirstPurchase, + occurredAt: new Date().toISOString(), + }; + + // Mark the cart as CHECKED_OUT and emit CART_CHECKED_OUT in a single + // atomic unit of work (Transactional Outbox Pattern, REQ-CART-022). + // If either write fails, both are rolled back so the database never + // holds a CHECKED_OUT cart without its corresponding outbox event. + await this.transactionRunner.run(async (tx) => { + await this.cartRepository.markCheckedOut(CartId.create(cart.id), tx); + await this.outboxRepository.saveEvent( + GlobalEvents.CART_CHECKED_OUT, + eventPayload, + tx, + ); + }); + + // The Orders module is responsible for creating one Order per + // seller. It subscribes to CART_CHECKED_OUT and emits ORDER_CREATED + // per seller. The use case returns an empty list — the API can + // poll the orders endpoint after the redirect to surface the ids. + return { + cart: { ...cart, status: CartStatus.CheckedOut }, + totals, + eventPayload, + orderIds: [], + // priceChanges is included for API responses that want to log + // the diff alongside the result (not part of the public response). + // Keep the linter happy when the value is empty. + ...(priceChanges.length > 0 && { priceChanges }), + } as CheckoutResult & { priceChanges?: PriceChange[] }; + } } // --- helpers --- diff --git a/modules/cart/application/migrate-guest-cart.ts b/modules/cart/application/migrate-guest-cart.ts index 185aa5ad..2815084d 100644 --- a/modules/cart/application/migrate-guest-cart.ts +++ b/modules/cart/application/migrate-guest-cart.ts @@ -67,19 +67,51 @@ export class MigrateGuestCart { private customizationCreator?: CustomizationCreatePort, ) {} - async execute(dto: MigrateGuestCartDTO): Promise { - const serverCart = await this.cartRepository.findActiveByUserId(dto.userId); + private buildItem( + g: GuestCartItem, + cartId: string, + productMap: Map< + string, + { basePrice: number; currency: Currency; sellerId: SellerId } + >, + customizationIdList: string[], + ): CartItemEntity { + const product = productMap.get(g.productId)!; + return { + id: crypto.randomUUID(), + cartId, + productId: ProductId.create(g.productId), + sellerId: product.sellerId, + quantity: g.quantity, + unitPriceSnapshot: Money.create(product.basePrice, product.currency), + customizationIdList: [...customizationIdList].toSorted((a, b) => + a.localeCompare(b), + ), + }; + } - if (dto.guestItems.length === 0) { - return { - cart: serverCart ?? this.emptyCart(dto.userId), - migratedCount: 0, - skippedProductIds: [], - skippedCustomizationProductIds: [], - }; - } + private emptyCart(userId: string): CartEntity { + const now = new Date(); + return { + id: '', + userId, + status: CartStatus.Active, + items: [], + createdAt: now, + updatedAt: now, + }; + } - const productIds = dto.guestItems.map((g) => ProductId.create(g.productId)); + private async fetchRelatedData(guestItems: GuestCartItem[]): Promise<{ + productMap: Map< + string, + { basePrice: number; currency: Currency; sellerId: SellerId } + >; + customizationByProductId: Map; + capabilityByProductId: Map; + createdCustomizationCache: Map; + }> { + const productIds = guestItems.map((g) => ProductId.create(g.productId)); const uniqueIds = uniqueBy(productIds, (p) => p.value); const productMap = await this.productRepository.findByIds(uniqueIds); @@ -87,8 +119,9 @@ export class MigrateGuestCart { const capabilityByProductId = new Map(); const createdCustomizationCache = new Map(); const availableProductIds = [ - ...new Set(dto.guestItems.map((g) => g.productId)), + ...new Set(guestItems.map((g) => g.productId)), ].filter((productId) => productMap.has(productId)); + for (const productId of availableProductIds) { customizationByProductId.set( productId, @@ -106,6 +139,31 @@ export class MigrateGuestCart { } } + return { + productMap, + customizationByProductId, + capabilityByProductId, + createdCustomizationCache, + }; + } + + private async filterAvailableItems( + dto: MigrateGuestCartDTO, + productMap: Map< + string, + { basePrice: number; currency: Currency; sellerId: SellerId } + >, + customizationByProductId: Map, + capabilityByProductId: Map, + createdCustomizationCache: Map, + ): Promise<{ + availableGuest: Array<{ + item: GuestCartItem; + customizationIdList: string[]; + }>; + skippedProductIds: string[]; + skippedCustomizationProductIds: string[]; + }> { const availableGuest: Array<{ item: GuestCartItem; customizationIdList: string[]; @@ -115,17 +173,13 @@ export class MigrateGuestCart { for (const g of dto.guestItems) { if (!productMap.has(g.productId)) { - if (!skippedProductIds.includes(g.productId)) { - skippedProductIds.push(g.productId); - } + skipIfMissing(skippedProductIds, g.productId); continue; } const capability = capabilityByProductId.get(g.productId); if (capability && !isCustomizationAllowed(g, capability)) { - if (!skippedCustomizationProductIds.includes(g.productId)) { - skippedCustomizationProductIds.push(g.productId); - } + skipIfMissing(skippedCustomizationProductIds, g.productId); continue; } @@ -136,41 +190,46 @@ export class MigrateGuestCart { createdCustomizationCache, ); if (customizationIdList === null) { - if (!skippedCustomizationProductIds.includes(g.productId)) { - skippedCustomizationProductIds.push(g.productId); - } + skipIfMissing(skippedCustomizationProductIds, g.productId); continue; } availableGuest.push({ item: g, customizationIdList }); } - if (availableGuest.length === 0) { - return { - cart: serverCart ?? this.emptyCart(dto.userId), - migratedCount: 0, - skippedProductIds, - skippedCustomizationProductIds, - }; - } + return { + availableGuest, + skippedProductIds, + skippedCustomizationProductIds, + }; + } - let resultCart: CartEntity; - let isNewCart = false; - - if (!serverCart) { - const now = new Date(); - resultCart = { - id: crypto.randomUUID(), - userId: dto.userId, - status: CartStatus.Active, - items: [], - createdAt: now, - updatedAt: now, - }; - isNewCart = true; - } else { - resultCart = serverCart; - } + private resolveCartAndStrategy( + dto: MigrateGuestCartDTO, + serverCart: CartEntity | null, + availableGuest: Array<{ + item: GuestCartItem; + customizationIdList: string[]; + }>, + productMap: Map< + string, + { basePrice: number; currency: Currency; sellerId: SellerId } + >, + ): { + cart: CartEntity; + isNewCart: boolean; + items: CartItemEntity[]; + touchedIds: Set; + } { + const isNewCart = !serverCart; + const resultCart: CartEntity = serverCart ?? { + id: crypto.randomUUID(), + userId: dto.userId, + status: CartStatus.Active, + items: [], + createdAt: new Date(), + updatedAt: new Date(), + }; let items: CartItemEntity[] = isNewCart ? [] : [...resultCart.items]; const touchedIds = new Set(); @@ -189,36 +248,109 @@ export class MigrateGuestCart { return built; }); } else { - for (const { item: g, customizationIdList } of availableGuest) { - const productSnap = productMap.get(g.productId)!; - const existing = items.find((i) => - isSameVariant(i, g, productSnap, customizationIdList), + items = this.mergeItems( + items, + availableGuest, + resultCart.id, + productMap, + touchedIds, + ); + } + + const cart = { ...resultCart, items, updatedAt: new Date() }; + return { cart, isNewCart, items, touchedIds }; + } + + private mergeItems( + initial: CartItemEntity[], + availableGuest: Array<{ + item: GuestCartItem; + customizationIdList: string[]; + }>, + cartId: string, + productMap: Map< + string, + { basePrice: number; currency: Currency; sellerId: SellerId } + >, + touchedIds: Set, + ): CartItemEntity[] { + let items = [...initial]; + for (const { item: g, customizationIdList } of availableGuest) { + const productSnap = productMap.get(g.productId)!; + const existing = items.find((i) => + isSameVariant(i, g, productSnap, customizationIdList), + ); + if (existing) { + items = items.map((i) => + i.id === existing.id + ? { ...i, quantity: i.quantity + g.quantity } + : i, + ); + touchedIds.add(existing.id); + } else { + const built = this.buildItem( + g, + cartId, + productMap, + customizationIdList, ); - if (existing) { - items = items.map((i) => - i.id === existing.id - ? { ...i, quantity: i.quantity + g.quantity } - : i, - ); - touchedIds.add(existing.id); - } else { - const built = this.buildItem( - g, - resultCart.id, - productMap, - customizationIdList, - ); - items.push(built); - touchedIds.add(built.id); - } + items.push(built); + touchedIds.add(built.id); } } + return items; + } - resultCart = { - ...resultCart, - items, - updatedAt: new Date(), - }; + async execute(dto: MigrateGuestCartDTO): Promise { + const serverCart = await this.cartRepository.findActiveByUserId(dto.userId); + + if (dto.guestItems.length === 0) { + return { + cart: serverCart ?? this.emptyCart(dto.userId), + migratedCount: 0, + skippedProductIds: [], + skippedCustomizationProductIds: [], + }; + } + + const { + productMap, + customizationByProductId, + capabilityByProductId, + createdCustomizationCache, + } = await this.fetchRelatedData(dto.guestItems); + + const { + availableGuest, + skippedProductIds, + skippedCustomizationProductIds, + } = await this.filterAvailableItems( + dto, + productMap, + customizationByProductId, + capabilityByProductId, + createdCustomizationCache, + ); + + if (availableGuest.length === 0) { + return { + cart: serverCart ?? this.emptyCart(dto.userId), + migratedCount: 0, + skippedProductIds, + skippedCustomizationProductIds, + }; + } + + const { + cart: resultCart, + isNewCart, + touchedIds, + } = this.resolveCartAndStrategy( + dto, + serverCart, + availableGuest, + productMap, + ); const saved = await this.cartRepository.save(resultCart); @@ -257,39 +389,6 @@ export class MigrateGuestCart { skippedCustomizationProductIds, }; } - - private buildItem( - g: GuestCartItem, - cartId: string, - productMap: Map< - string, - { basePrice: number; currency: Currency; sellerId: SellerId } - >, - customizationIdList: string[], - ): CartItemEntity { - const product = productMap.get(g.productId)!; - return { - id: crypto.randomUUID(), - cartId, - productId: ProductId.create(g.productId), - sellerId: product.sellerId, - quantity: g.quantity, - unitPriceSnapshot: Money.create(product.basePrice, product.currency), - customizationIdList: [...customizationIdList].sort(), - }; - } - - private emptyCart(userId: string): CartEntity { - const now = new Date(); - return { - id: '', - userId, - status: CartStatus.Active, - items: [], - createdAt: now, - updatedAt: now, - }; - } } // --- helpers --- @@ -304,7 +403,7 @@ type CustomizationRecord = { designPosition: unknown; }; -function resolveGuestCustomizationIds( +async function resolveGuestCustomizationIds( g: GuestCartItem, customizations: CustomizationRecord[], customizationCreator: CustomizationCreatePort | undefined, @@ -319,11 +418,8 @@ function resolveGuestCustomizationIds( (g.customizationDesignPosition !== undefined && g.customizationDesignPosition !== null); - if (!hasCustomization) return Promise.resolve([]); + if (!hasCustomization) return []; - // Canvas-only customizations are matched by the imageUrl baked into - // designPosition. We still keep the text/color/size matchers so legacy - // guest items continue to dedupe. const matches = customizations.filter( (customization) => customization.text === (g.customizationText ?? null) && @@ -332,26 +428,24 @@ function resolveGuestCustomizationIds( customization.imageUrl === (g.customizationImageUrl ?? null), ); - if (matches.length === 1) return Promise.resolve([matches[0].id]); - if (matches.length > 1 || !customizationCreator) return Promise.resolve(null); + if (matches.length === 1) return [matches[0].id]; + if (matches.length > 1 || !customizationCreator) return null; const cacheKey = guestCustomizationKey(g); const cached = createdCustomizationCache.get(cacheKey); - if (cached) return Promise.resolve([cached]); - - return customizationCreator - .create({ - productId: g.productId, - text: g.customizationText ?? null, - color: g.customizationColor ?? null, - size: g.customizationSize ?? null, - imageUrl: g.customizationImageUrl ?? null, - designPosition: g.customizationDesignPosition ?? null, - }) - .then((created) => { - createdCustomizationCache.set(cacheKey, created.id); - return [created.id]; - }); + if (cached) return [cached]; + + const created = await customizationCreator.create({ + productId: g.productId, + text: g.customizationText ?? null, + color: g.customizationColor ?? null, + size: g.customizationSize ?? null, + imageUrl: g.customizationImageUrl ?? null, + designPosition: g.customizationDesignPosition ?? null, + }); + + createdCustomizationCache.set(cacheKey, created.id); + return [created.id]; } function guestCustomizationKey(g: GuestCartItem): string { @@ -396,11 +490,20 @@ function isSameVariant( if (item.unitPriceSnapshot.amount !== g.unitPriceSnapshot) return false; if (item.unitPriceSnapshot.currency !== productSnap.currency) return false; return ( - JSON.stringify([...item.customizationIdList].sort()) === - JSON.stringify([...customizationIdList].sort()) + JSON.stringify( + [...item.customizationIdList].toSorted((a, b) => a.localeCompare(b)), + ) === + JSON.stringify( + [...customizationIdList].toSorted((a, b) => a.localeCompare(b)), + ) ); } +function skipIfMissing(arr: string[], item: string): void { + if (arr.includes(item)) return; + arr.push(item); +} + function uniqueBy(items: T[], keyFn: (t: T) => string): T[] { const seen = new Set(); const out: T[] = []; diff --git a/modules/cart/application/update-cart-item.ts b/modules/cart/application/update-cart-item.ts index 0976a6ba..c59f75f5 100644 --- a/modules/cart/application/update-cart-item.ts +++ b/modules/cart/application/update-cart-item.ts @@ -82,9 +82,9 @@ export class UpdateCartItemQuantity { const updatedItem: CartItemEntity = { ...item, quantity: quantity.value, - ...(dto.customizationIdList !== undefined - ? { customizationIdList: dto.customizationIdList } - : {}), + ...(dto.customizationIdList !== undefined && { + customizationIdList: dto.customizationIdList, + }), }; const updatedItems = cart.items.map((i) => i.id === item.id ? updatedItem : i, diff --git a/modules/cart/domain/cart-repository.ts b/modules/cart/domain/cart-repository.ts index d7a53da5..b31f8594 100644 --- a/modules/cart/domain/cart-repository.ts +++ b/modules/cart/domain/cart-repository.ts @@ -3,8 +3,6 @@ import type { CartItemEntity } from './entities/cart-item'; import type { CartId } from './value-objects/cart-id'; import type { CartItemId } from './value-objects/cart-item-id'; -export type { CartEntity, CartItemEntity }; - /** * CartRepository — persistence port for the Cart aggregate. * @@ -52,3 +50,6 @@ export interface CartRepository { /** Returns all items for a cart. Empty array when the cart has none. */ findItemsByCartId(cartId: CartId): Promise; } + +export { type CartEntity } from './entities/cart'; +export { type CartItemEntity } from './entities/cart-item'; diff --git a/modules/cart/domain/value-objects/cart-id.ts b/modules/cart/domain/value-objects/cart-id.ts index 7a5353af..eb90bfc8 100644 --- a/modules/cart/domain/value-objects/cart-id.ts +++ b/modules/cart/domain/value-objects/cart-id.ts @@ -8,11 +8,11 @@ import { EntityId } from '@/shared/kernel/domain/value-objects/entity-id'; * never considered equal. */ export class CartId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): CartId { return new CartId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/modules/cart/domain/value-objects/cart-item-id.ts b/modules/cart/domain/value-objects/cart-item-id.ts index e9c11395..f5aee3a3 100644 --- a/modules/cart/domain/value-objects/cart-item-id.ts +++ b/modules/cart/domain/value-objects/cart-item-id.ts @@ -8,11 +8,11 @@ import { EntityId } from '@/shared/kernel/domain/value-objects/entity-id'; * EntityId (not a free string alias). */ export class CartItemId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): CartItemId { return new CartItemId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/modules/cart/domain/value-objects/quantity.ts b/modules/cart/domain/value-objects/quantity.ts index f31e31e1..fe71d2d3 100644 --- a/modules/cart/domain/value-objects/quantity.ts +++ b/modules/cart/domain/value-objects/quantity.ts @@ -13,12 +13,6 @@ const MAX_QUANTITY = 99; * Quantity rather than mutating the receiver. */ export class Quantity { - readonly value: number; - - private constructor(value: number) { - this.value = value; - } - static create(amount: number): Quantity { if (!Number.isSafeInteger(amount)) { throw new InvalidQuantityError( @@ -33,6 +27,12 @@ export class Quantity { return new Quantity(amount); } + readonly value: number; + + private constructor(value: number) { + this.value = value; + } + equals(other: Quantity): boolean { return other instanceof Quantity && this.value === other.value; } diff --git a/modules/cart/infrastructure/customization-lookup-adapter.ts b/modules/cart/infrastructure/customization-lookup-adapter.ts index 297bafae..aa70f6fb 100644 --- a/modules/cart/infrastructure/customization-lookup-adapter.ts +++ b/modules/cart/infrastructure/customization-lookup-adapter.ts @@ -20,12 +20,12 @@ export class CustomizationLookupAdapter implements CustomizationLookupPort { if (ids.length === 0) return []; const entities = await this.delegate.findByIds(ids); - return entities.map(toSnapshot); + return entities.map((e) => toSnapshot(e)); } async findByProductId(productId: string): Promise { const entities = await this.delegate.findByProductId(productId); - return entities.map(toSnapshot); + return entities.map((e) => toSnapshot(e)); } } diff --git a/modules/cart/infrastructure/prisma-cart-repository.ts b/modules/cart/infrastructure/prisma-cart-repository.ts index ce935b77..8339b457 100644 --- a/modules/cart/infrastructure/prisma-cart-repository.ts +++ b/modules/cart/infrastructure/prisma-cart-repository.ts @@ -95,10 +95,10 @@ export class PrismaCartRepository implements CartRepository { throw new Error(`Cart ${cart.id} not found after save`); } return toDomain(saved); - } catch (err) { + } catch (error) { if ( - err instanceof Prisma.PrismaClientKnownRequestError && - err.code === 'P2002' + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' ) { // Unique constraint violation — most likely the partial // unique index `Cart_userId_active_unique`. @@ -106,7 +106,7 @@ export class PrismaCartRepository implements CartRepository { `User ${cart.userId} already has an active cart`, ); } - throw err; + throw error; } } @@ -139,7 +139,7 @@ export class PrismaCartRepository implements CartRepository { const rows = await prisma.cartItem.findMany({ where: { cartId: cartId.value }, }); - return rows.map(toItemDomain); + return rows.map((r) => toItemDomain(r)); } } @@ -171,7 +171,7 @@ function toDomain(row: PrismaCartWithItems): CartEntity { id: row.id, userId: row.userId, status: row.status as CartStatus, - items: row.items.map(toItemDomain), + items: row.items.map((r) => toItemDomain(r)), createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/modules/cart/presentation/components/add-to-cart-button.tsx b/modules/cart/presentation/components/add-to-cart-button.tsx index 2acdce3e..2a7176e8 100644 --- a/modules/cart/presentation/components/add-to-cart-button.tsx +++ b/modules/cart/presentation/components/add-to-cart-button.tsx @@ -11,6 +11,8 @@ import { } from './customization-draft-schema'; import styles from './add-to-cart-button.module.css'; +const hasText = (value?: string | null) => value != null && value.length > 0; + export interface CartButtonLabels { addToCart: string; removeFromCart: string; @@ -53,7 +55,7 @@ type ButtonState = 'idle' | 'adding' | 'success' | 'error'; const MAX_QUANTITY = 99; const CART_UPDATED_EVENT = 'cart:updated'; -function guestCustomizationMatches( +function isGuestCustomizationMatching( item: { customizationText?: string | null; customizationColor?: string | null; @@ -71,7 +73,7 @@ function guestCustomizationMatches( ); } -function authCustomizationMatches( +function isAuthCustomizationMatching( customizations: Array<{ text?: string | null; color?: string | null; @@ -81,13 +83,12 @@ function authCustomizationMatches( draft: CustomizationDraftPayload | null, ): boolean { const norm = normalizeCustomizationDraft(draft); - const hasDraftContent = Boolean( - norm.text || - norm.color || - norm.size || - norm.imageUrl || - norm.designPosition, - ); + const hasDraftContent = + hasText(norm.text) || + hasText(norm.color) || + hasText(norm.size) || + hasText(norm.imageUrl) || + Boolean(norm.designPosition); if (!hasDraftContent) { return customizations.length === 0; @@ -103,7 +104,7 @@ function authCustomizationMatches( } function dispatchCartUpdated() { - window.dispatchEvent(new Event(CART_UPDATED_EVENT)); + globalThis.dispatchEvent(new Event(CART_UPDATED_EVENT)); } /** @@ -148,25 +149,24 @@ export function AddToCartButton({ () => normalizeCustomizationDraft(customization), [customization], ); - const customizationHasContent = Boolean( - normalizedCustomization.text || - normalizedCustomization.color || - normalizedCustomization.size || - normalizedCustomization.imageUrl || - normalizedCustomization.designPosition, - ); + const isCustomizationHasContent = + hasText(normalizedCustomization.text) || + hasText(normalizedCustomization.color) || + hasText(normalizedCustomization.size) || + hasText(normalizedCustomization.imageUrl) || + Boolean(normalizedCustomization.designPosition); // Fetch cart for authenticated users. useEffect(() => { if (!isAuthenticated) return; - let cancelled = false; + let isCancelled = false; async function fetchCart() { try { const res = await fetch('/api/cart'); - if (cancelled || !res.ok) return; + if (isCancelled || !res.ok) return; const data = await res.json(); - if (cancelled) return; + if (isCancelled) return; const items = data.items ?? []; const anyInCart = items.find( (item: { productId: string }) => item.productId === productId, @@ -182,7 +182,7 @@ export function AddToCartButton({ }>; }) => item.productId === productId && - authCustomizationMatches( + isAuthCustomizationMatching( item.customizations ?? [], normalizedCustomization, ), @@ -198,30 +198,30 @@ export function AddToCartButton({ fetchCart(); return () => { - cancelled = true; + isCancelled = true; }; }, [isAuthenticated, productId, normalizedCustomization]); // Determine current quantity (match by productId + customization). - const guestMatch = !isAuthenticated - ? items.find( + const guestMatch = isAuthenticated + ? undefined + : items.find( (i) => i.productId === productId && - guestCustomizationMatches(i, normalizedCustomization), - ) - : undefined; + isGuestCustomizationMatching(i, normalizedCustomization), + ); const guestDiffMatch = !isAuthenticated && !guestMatch ? items.find((i) => i.productId === productId) : undefined; - const currentQuantity = !isAuthenticated - ? (guestMatch?.quantity ?? 0) - : (cartItemInfo?.quantity ?? 0); + const currentQuantity = isAuthenticated + ? (cartItemInfo?.quantity ?? 0) + : (guestMatch?.quantity ?? 0); const isInCart = currentQuantity > 0; - const alreadyInCartDifferent = !isAuthenticated - ? !!guestDiffMatch - : hasDifferentCustomization; + const alreadyInCartDifferent = isAuthenticated + ? hasDifferentCustomization + : !!guestDiffMatch; const customizeProductLabel = labels.customizeProduct ?? 'Customize'; const addWithoutCustomizationLabel = @@ -259,7 +259,7 @@ export function AddToCartButton({ }>; }) => item.productId === productId && - authCustomizationMatches( + isAuthCustomizationMatching( item.customizations ?? [], normalizedCustomization, ), @@ -343,102 +343,96 @@ export function AddToCartButton({ updateItemCustomization, ]); - const performAdd = useCallback( + const performAuthenticatedAdd = useCallback( async (draft: CustomizationDraftPayload | null) => { - setState('adding'); - - try { - if (isAuthenticated) { - const customizationValidation = customizationDraftSchema.safeParse( - draft ?? normalizeCustomizationDraft(null), - ); - - if (!customizationValidation.success) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; - } - - let customizationIdList: string[] = []; - - if ( - draft && - (draft.text || - draft.color || - draft.size || - draft.imageUrl || - draft.designPosition) - ) { - const customizationResponse = await fetch( - '/api/customizations/customer', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - productId, - text: draft.text, - color: draft.color, - size: draft.size, - imageUrl: draft.imageUrl, - designPosition: draft.designPosition ?? null, - }), - }, - ); - - if (!customizationResponse.ok) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; - } - - const customizationData = (await customizationResponse.json()) as { - id?: string; - }; - if (customizationData.id) { - customizationIdList = [customizationData.id]; - } - } - - const res = await fetch('/api/cart/items', { + const validation = customizationDraftSchema.safeParse( + draft ?? normalizeCustomizationDraft(null), + ); + if (!validation.success) return false; + + let customizationIdList: string[] = []; + const hasCustomizationContent = + draft && + (hasText(draft.text) || + hasText(draft.color) || + hasText(draft.size) || + hasText(draft.imageUrl) || + Boolean(draft.designPosition)); + + if (hasCustomizationContent) { + const customizationResponse = await fetch( + '/api/customizations/customer', + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId, - quantity: 1, - customizationIdList, + text: draft.text, + color: draft.color, + size: draft.size, + imageUrl: draft.imageUrl, + designPosition: draft.designPosition ?? null, }), - }); - if (!res.ok) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; - } + }, + ); + if (!customizationResponse.ok) return false; + + const customizationData = (await customizationResponse.json()) as { + id?: string; + }; + if (customizationData.id) { + customizationIdList = [customizationData.id]; + } + } + + const res = await fetch('/api/cart/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId, quantity: 1, customizationIdList }), + }); + + return res.ok; + }, + [productId], + ); + + const performAdd = useCallback( + async (draft: CustomizationDraftPayload | null) => { + setState('adding'); + + let isOk = false; + try { + if (isAuthenticated) { + isOk = await performAuthenticatedAdd(draft); } else { const normalized = draft ?? normalizeCustomizationDraft(null); const validation = customizationDraftSchema.safeParse(normalized); - - if (!validation.success) { - setState('error'); - setTimeout(() => setState('idle'), 3000); - return; + if (validation.success) { + addItem({ + productId, + sellerId, + quantity: 1, + unitPriceSnapshot: price, + productName, + sellerName, + productImageUrl: imageUrl, + customizationText: normalized.text, + customizationColor: normalized.color, + customizationSize: normalized.size, + customizationImageUrl: normalized.imageUrl, + customizationImageUploadId: normalized.imageUploadId, + customizationDesignPosition: normalized.designPosition ?? null, + }); + isOk = true; } + } - addItem({ - productId, - sellerId, - quantity: 1, - unitPriceSnapshot: price, - productName, - sellerName, - productImageUrl: imageUrl, - customizationText: normalized.text, - customizationColor: normalized.color, - customizationSize: normalized.size, - customizationImageUrl: normalized.imageUrl, - customizationImageUploadId: normalized.imageUploadId, - customizationDesignPosition: normalized.designPosition ?? null, - }); + if (!isOk) { + setState('error'); + setTimeout(() => setState('idle'), 3000); + return; } + setState('success'); setTimeout(() => setState('idle'), 2000); dispatchCartUpdated(); @@ -458,6 +452,7 @@ export function AddToCartButton({ imageUrl, addItem, refreshCartForProduct, + performAuthenticatedAdd, ], ); @@ -466,7 +461,7 @@ export function AddToCartButton({ e.preventDefault(); if (state === 'adding' || disabled) return; - if (customizationAvailable && !customizationHasContent) { + if (customizationAvailable && !isCustomizationHasContent) { setShowCustomizationChoice(true); return; } @@ -477,7 +472,7 @@ export function AddToCartButton({ state, disabled, customizationAvailable, - customizationHasContent, + isCustomizationHasContent, normalizedCustomization, performAdd, ], @@ -575,14 +570,12 @@ export function AddToCartButton({ } }, [isAuthenticated, cartItemInfo, guestMatch, removeItemById]); - const feedbackLabel = - state === 'adding' - ? labels.adding - : state === 'success' - ? labels.added - : state === 'error' - ? labels.error - : labels.addToCart; + const feedbackLabel = (() => { + if (state === 'adding') return labels.adding; + if (state === 'success') return labels.added; + if (state === 'error') return labels.error; + return labels.addToCart; + })(); // Success / Error feedback. if (state === 'success' || state === 'error') { @@ -625,7 +618,7 @@ export function AddToCartButton({ +
    - {customizationHasContent && labels.saveDesign && ( + {isCustomizationHasContent && labels.saveDesign && ( +
    + ); + } else { + content = ( + <> +
      + {items.map((item) => ( +
    • +
      + {renderThumbnail(item)} +
      + + {item.productName ?? unknownProduct} + + + {labels.soldBy} {item.sellerName ?? unknownSeller} + + {item.customization?.size && ( + + {item.customization.size} + + )} + {item.customization?.text && ( + + {item.customization.text} + + )} +
      +
      +
      + + handleUpdate(item, newQty - item.quantity) + } + variant="compact" + decrementLabel={labels.decreaseQuantity} + incrementLabel={labels.increaseQuantity} + /> + + {Money.format(item.lineTotal, Currency.EUR)} + + +
      +
    • + ))} +
    +
    +
    + {labels.subtotal} + {Money.format(subtotal, Currency.EUR)} +
    + + +
    + + ); + } + return createPortal(
    -
    - {loading ? ( -

    {labels.loading}

    - ) : items.length === 0 ? ( -
    -

    {labels.empty}

    - -
    - ) : ( - <> -
      - {items.map((item) => ( -
    • -
      - {item.customization?.imageUrl != null && - item.customization?.designPosition != null ? ( - - ) : item.productImageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {item.productName} - ) : null} -
      - - {item.productName ?? unknownProduct} - - - {labels.soldBy} {item.sellerName ?? unknownSeller} - - {item.customization?.size && ( - - {item.customization.size} - - )} - {item.customization?.text && ( - - {item.customization.text} - - )} -
      -
      -
      - - handleUpdate(item, newQty - item.quantity) - } - variant="compact" - decrementLabel={labels.decreaseQuantity} - incrementLabel={labels.increaseQuantity} - /> - - {Money.format(item.lineTotal, Currency.EUR)} - - -
      -
    • - ))} -
    -
    -
    - {labels.subtotal} - {Money.format(subtotal, Currency.EUR)} -
    - - -
    - - )} -
    +
    {content}
    , document.body, diff --git a/modules/cart/presentation/components/cart-view.tsx b/modules/cart/presentation/components/cart-view.tsx index a485f5ac..9f2dfb3a 100644 --- a/modules/cart/presentation/components/cart-view.tsx +++ b/modules/cart/presentation/components/cart-view.tsx @@ -96,7 +96,8 @@ function buildCustomizationHref( if (customization.text) params.set('customizationText', customization.text); if (customization.color) params.set('customizationColor', customization.color); - if (customization.size) params.set('customizationSize', customization.size); + if (customization.size != null && customization.size.length > 0) + params.set('customizationSize', customization.size); if (customization.imageUrl) params.set('customizationImageUrl', customization.imageUrl); if (customization.imageUploadId) @@ -108,7 +109,9 @@ function buildCustomizationHref( ); const query = params.toString(); - return `/${locale}/products/${productId}${query ? `?${query}` : ''}`; + return query + ? `/${locale}/products/${productId}?${query}` + : `/${locale}/products/${productId}`; } /** @@ -288,14 +291,16 @@ export function CartView({ height={100} borderRadius={6} /> - ) : previewUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {item.productName} - ) : null} + ) : ( + previewUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {item.productName} + ) + )} {!showCombined && item.customization.imageUrl && ( // eslint-disable-next-line @next/next/no-img-element - {item.customization.size && ( + {item.customization.size != null && ( {labels.customizationSize}: {item.customization.size} diff --git a/modules/cart/presentation/components/checkout-confirm-button.tsx b/modules/cart/presentation/components/checkout-confirm-button.tsx index 46bb3386..13454612 100644 --- a/modules/cart/presentation/components/checkout-confirm-button.tsx +++ b/modules/cart/presentation/components/checkout-confirm-button.tsx @@ -32,7 +32,7 @@ interface AddressFields { * Client component for the checkout confirmation flow. * * 1. POST /api/cart/checkout → preview totals. - * 2. If 200 → POST /api/cart/checkout/confirm with acceptPriceChanges=false. + * 2. If 200 → POST /api/cart/checkout/confirm with shouldAcceptPriceChanges=false. * 3. If 409 → show price-change dialog; user can accept or cancel. * 4. On success → redirect to /orders/{orderId} and clear guest cart. */ @@ -107,7 +107,7 @@ export function CheckoutConfirmButton({ } }; - const confirmCheckout = async (acceptPriceChanges: boolean) => { + const confirmCheckout = async (shouldAcceptPriceChanges: boolean) => { try { const saved = await persistProfileAddress(); if (!saved) { @@ -117,7 +117,7 @@ export function CheckoutConfirmButton({ const confirmRes = await fetch('/api/cart/checkout/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ acceptPriceChanges }), + body: JSON.stringify({ acceptPriceChanges: shouldAcceptPriceChanges }), }); if (confirmRes.ok) { @@ -185,7 +185,7 @@ export function CheckoutConfirmButton({ {priceChanges.map((pc) => (
  • {Money.format(pc.oldPrice, Currency.EUR)} - {'\u00A0→\u00A0'} + {'\u{A0}→\u{A0}'} {Money.format(pc.newPrice, Currency.EUR)}
  • ))} diff --git a/modules/cart/presentation/components/merge-dialog.tsx b/modules/cart/presentation/components/merge-dialog.tsx index ce9479fc..8d6df001 100644 --- a/modules/cart/presentation/components/merge-dialog.tsx +++ b/modules/cart/presentation/components/merge-dialog.tsx @@ -55,7 +55,7 @@ export function MergeDialog({ isOpen, onClose, labels }: MergeDialogProps) { if (res.ok) { clearCart(); - window.dispatchEvent(new Event(CART_UPDATED_EVENT)); + globalThis.dispatchEvent(new Event(CART_UPDATED_EVENT)); router.refresh(); onClose(); } diff --git a/modules/cart/presentation/guest-cart-context.tsx b/modules/cart/presentation/guest-cart-context.tsx index d36243af..3f4200cb 100644 --- a/modules/cart/presentation/guest-cart-context.tsx +++ b/modules/cart/presentation/guest-cart-context.tsx @@ -193,29 +193,29 @@ export function GuestCartProvider({ children }: { children: ReactNode }) { ? { ...item, customizationText: - customization.text !== undefined - ? customization.text - : item.customizationText, + customization.text === undefined + ? item.customizationText + : customization.text, customizationColor: - customization.color !== undefined - ? customization.color - : item.customizationColor, + customization.color === undefined + ? item.customizationColor + : customization.color, customizationSize: - customization.size !== undefined - ? customization.size - : item.customizationSize, + customization.size === undefined + ? item.customizationSize + : customization.size, customizationImageUrl: - customization.imageUrl !== undefined - ? customization.imageUrl - : item.customizationImageUrl, + customization.imageUrl === undefined + ? item.customizationImageUrl + : customization.imageUrl, customizationImageUploadId: - customization.imageUploadId !== undefined - ? customization.imageUploadId - : item.customizationImageUploadId, + customization.imageUploadId === undefined + ? item.customizationImageUploadId + : customization.imageUploadId, customizationDesignPosition: - customization.designPosition !== undefined - ? (customization.designPosition as GuestCartItem['customizationDesignPosition']) - : item.customizationDesignPosition, + customization.designPosition === undefined + ? item.customizationDesignPosition + : (customization.designPosition as GuestCartItem['customizationDesignPosition']), } : item, ), @@ -253,29 +253,29 @@ export function GuestCartProvider({ children }: { children: ReactNode }) { ? { ...item, customizationText: - customization.text !== undefined - ? customization.text - : item.customizationText, + customization.text === undefined + ? item.customizationText + : customization.text, customizationColor: - customization.color !== undefined - ? customization.color - : item.customizationColor, + customization.color === undefined + ? item.customizationColor + : customization.color, customizationSize: - customization.size !== undefined - ? customization.size - : item.customizationSize, + customization.size === undefined + ? item.customizationSize + : customization.size, customizationImageUrl: - customization.imageUrl !== undefined - ? customization.imageUrl - : item.customizationImageUrl, + customization.imageUrl === undefined + ? item.customizationImageUrl + : customization.imageUrl, customizationImageUploadId: - customization.imageUploadId !== undefined - ? customization.imageUploadId - : item.customizationImageUploadId, + customization.imageUploadId === undefined + ? item.customizationImageUploadId + : customization.imageUploadId, customizationDesignPosition: - customization.designPosition !== undefined - ? (customization.designPosition as GuestCartItem['customizationDesignPosition']) - : item.customizationDesignPosition, + customization.designPosition === undefined + ? item.customizationDesignPosition + : (customization.designPosition as GuestCartItem['customizationDesignPosition']), } : item, ), diff --git a/modules/customizations/application/create-customer-customization.ts b/modules/customizations/application/create-customer-customization.ts index 28eee752..53ef6269 100644 --- a/modules/customizations/application/create-customer-customization.ts +++ b/modules/customizations/application/create-customer-customization.ts @@ -23,36 +23,7 @@ export class CreateCustomerCustomization { private readonly productCapability: ProductCapabilityPort, ) {} - async execute( - dto: CreateCustomerCustomizationDTO, - ownerUserId: string, - ): Promise { - if (!ownerUserId) { - throw new ValidationError('Owner user id is required', 'Invalid user'); - } - - const config = - (await this.productCapability.getConfig(dto.productId)) ?? - ProductCustomizationConfig.default(); - - this.assertAllowed(dto, config); - CustomizationOptions.create(dto); - - const entity: CustomizationEntity = { - id: crypto.randomUUID(), - productId: dto.productId, - text: dto.text ?? null, - color: dto.color ?? null, - size: dto.size ?? null, - imageUrl: dto.imageUrl ?? null, - designPosition: dto.designPosition ?? null, - createdAt: new Date(), - }; - - return this.repo.save(entity); - } - - private assertAllowed( + private assertCapability( dto: CreateCustomerCustomizationDTO, config: ProductCustomizationConfig, ): void { @@ -68,9 +39,6 @@ export class CreateCustomerCustomization { const hasImage = dto.imageUrl !== undefined && dto.imageUrl !== null; const hasDesignPosition = dto.designPosition !== undefined && dto.designPosition !== null; - // A canvas-only customization is also a "has image" signal — the - // mockup canvas embeds imageUrl inside designPosition, so a - // canvas-only draft must satisfy the photo requirement. const hasImageForCapability = hasImage || hasDesignPosition; if (hasImageForCapability && !config.allowsPhoto()) { @@ -90,33 +58,68 @@ export class CreateCustomerCustomization { 'Customization is not allowed for this product', ); } + } + + private assertModeRequirement( + dto: CreateCustomerCustomizationDTO, + config: ProductCustomizationConfig, + ): void { + const hasText = dto.text !== undefined && dto.text !== null; + const hasImage = dto.imageUrl !== undefined && dto.imageUrl !== null; + const hasDesignPosition = + dto.designPosition !== undefined && dto.designPosition !== null; + const hasImageForCapability = hasImage || hasDesignPosition; + const mode = config.mode; - switch (config.mode) { - case 'description': - case 'text': - if (!hasText) { - throw new ValidationError( - 'Text customization is required for this product', - 'Customization is not allowed for this product', - ); - } - break; - case 'photo': - if (!hasImageForCapability) { - throw new ValidationError( - 'Photo customization is required for this product', - 'Customization is not allowed for this product', - ); - } - break; - case 'text_photo': - if (!hasText && !hasImageForCapability) { - throw new ValidationError( - 'Text or photo customization is required for this product', - 'Customization is not allowed for this product', - ); - } - break; + if ((mode === 'description' || mode === 'text') && !hasText) { + throw new ValidationError( + 'Text customization is required for this product', + 'Customization is not allowed for this product', + ); + } + + if ((mode === 'photo' || mode === 'text_photo') && !hasImageForCapability) { + throw new ValidationError( + 'Photo customization is required for this product', + 'Customization is not allowed for this product', + ); + } + + if (mode === 'text_photo' && (!hasText || !hasImageForCapability)) { + throw new ValidationError( + 'Both text and photo customization are required for this product', + 'Customization is not allowed for this product', + ); + } + } + + async execute( + dto: CreateCustomerCustomizationDTO, + ownerUserId: string, + ): Promise { + if (!ownerUserId) { + throw new ValidationError('Owner user id is required', 'Invalid user'); } + + const config = + (await this.productCapability.getConfig(dto.productId)) ?? + ProductCustomizationConfig.default(); + + this.assertCapability(dto, config); + this.assertModeRequirement(dto, config); + CustomizationOptions.create(dto); + + const entity: CustomizationEntity = { + id: crypto.randomUUID(), + productId: dto.productId, + text: dto.text ?? null, + color: dto.color ?? null, + size: dto.size ?? null, + imageUrl: dto.imageUrl ?? null, + designPosition: dto.designPosition ?? null, + createdAt: new Date(), + }; + + return this.repo.save(entity); } } diff --git a/modules/customizations/application/update-customization.ts b/modules/customizations/application/update-customization.ts index 532dc006..9a7a6066 100644 --- a/modules/customizations/application/update-customization.ts +++ b/modules/customizations/application/update-customization.ts @@ -53,13 +53,13 @@ export class UpdateCustomization { // Build merged entity with only provided fields changed const merged: CustomizationEntity = { ...existing, - ...(dto.text !== undefined ? { text: dto.text ?? null } : {}), - ...(dto.color !== undefined ? { color: dto.color ?? null } : {}), - ...(dto.size !== undefined ? { size: dto.size ?? null } : {}), - ...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl ?? null } : {}), - ...(dto.designPosition !== undefined - ? { designPosition: dto.designPosition ?? null } - : {}), + ...(dto.text !== undefined && { text: dto.text ?? null }), + ...(dto.color !== undefined && { color: dto.color ?? null }), + ...(dto.size !== undefined && { size: dto.size ?? null }), + ...(dto.imageUrl !== undefined && { imageUrl: dto.imageUrl ?? null }), + ...(dto.designPosition !== undefined && { + designPosition: dto.designPosition ?? null, + }), }; // Re-validate via VO (throws on invalid input) diff --git a/modules/customizations/domain/value-objects/customization-options.ts b/modules/customizations/domain/value-objects/customization-options.ts index 4c89e4eb..7d228851 100644 --- a/modules/customizations/domain/value-objects/customization-options.ts +++ b/modules/customizations/domain/value-objects/customization-options.ts @@ -26,71 +26,52 @@ export interface CustomizationDesignPosition { } export class CustomizationOptions { - readonly text?: string; - readonly color?: string; - readonly size?: string; - readonly imageUrl?: string; - readonly designPosition?: CustomizationDesignPosition | null; - - private constructor(data: { - text?: string; - color?: string; - size?: string; - imageUrl?: string; - designPosition?: CustomizationDesignPosition | null; - }) { - this.text = data.text; - this.color = data.color; - this.size = data.size; - this.imageUrl = data.imageUrl; - this.designPosition = data.designPosition; - } - - static create(data: { - text?: string | null; - color?: string | null; - size?: string | null; - imageUrl?: string | null; - designPosition?: CustomizationDesignPosition | null; - }): CustomizationOptions { - // Normalize null to undefined — DB fields return string | null. - const text = data.text ?? undefined; - + private static validateText(text: string | undefined): void { if (text !== undefined && text.length > 500) { throw new Error('Customization text must be at most 500 characters'); } + } - const color = data.color ?? undefined; - const size = data.size ?? undefined; - const imageUrl = data.imageUrl ?? undefined; + private static validateColor(color: string | undefined): void { + if (color === undefined) { + return; + } - if (color !== undefined) { - if (color.trim().length === 0) { - throw new Error('Customization color cannot be empty if provided'); - } - if (color.length > 50) { - throw new Error('Customization color must be at most 50 characters'); - } + if (color.trim().length === 0) { + throw new Error('Customization color cannot be empty if provided'); + } + if (color.length > 50) { + throw new Error('Customization color must be at most 50 characters'); } + } - if (size !== undefined) { - if (size.trim().length === 0) { - throw new Error('Customization size cannot be empty if provided'); - } - if (size.length > 50) { - throw new Error('Customization size must be at most 50 characters'); - } + private static validateSize(size: string | undefined): void { + if (size === undefined) { + return; } - if (imageUrl !== undefined) { - const urlPattern = /^https?:\/\/.+/; - if (!urlPattern.test(imageUrl)) { - throw new Error('Customization image URL must be a valid URL'); - } + if (size.trim().length === 0) { + throw new Error('Customization size cannot be empty if provided'); + } + if (size.length > 50) { + throw new Error('Customization size must be at most 50 characters'); } + } - const designPosition = data.designPosition ?? undefined; + private static validateImageUrl(imageUrl: string | undefined): void { + if (imageUrl === undefined) { + return; + } + const urlPattern = /^https?:\/\/.+/; + if (!urlPattern.test(imageUrl)) { + throw new Error('Customization image URL must be a valid URL'); + } + } + + private static validateDesignPosition( + designPosition: CustomizationDesignPosition | null | undefined, + ): void { if ( designPosition !== undefined && designPosition !== null && @@ -102,6 +83,26 @@ export class CustomizationOptions { 'Customization designPosition must be an object with a non-empty imageUrl', ); } + } + + static create(data: { + text?: string | null; + color?: string | null; + size?: string | null; + imageUrl?: string | null; + designPosition?: CustomizationDesignPosition | null; + }): CustomizationOptions { + const text = data.text ?? undefined; + const color = data.color ?? undefined; + const size = data.size ?? undefined; + const imageUrl = data.imageUrl ?? undefined; + const designPosition = data.designPosition ?? undefined; + + this.validateText(text); + this.validateColor(color); + this.validateSize(size); + this.validateImageUrl(imageUrl); + this.validateDesignPosition(designPosition); return new CustomizationOptions({ text, @@ -112,6 +113,26 @@ export class CustomizationOptions { }); } + readonly text?: string; + readonly color?: string; + readonly size?: string; + readonly imageUrl?: string; + readonly designPosition?: CustomizationDesignPosition | null; + + private constructor(data: { + text?: string; + color?: string; + size?: string; + imageUrl?: string; + designPosition?: CustomizationDesignPosition | null; + }) { + this.text = data.text; + this.color = data.color; + this.size = data.size; + this.imageUrl = data.imageUrl; + this.designPosition = data.designPosition; + } + equals(other: CustomizationOptions): boolean { return ( other instanceof CustomizationOptions && @@ -119,12 +140,12 @@ export class CustomizationOptions { this.color === other.color && this.size === other.size && this.imageUrl === other.imageUrl && - designPositionEquals(this.designPosition, other.designPosition) + isDesignPositionEqual(this.designPosition, other.designPosition) ); } } -function designPositionEquals( +function isDesignPositionEqual( a: CustomizationDesignPosition | null | undefined, b: CustomizationDesignPosition | null | undefined, ): boolean { diff --git a/modules/customizations/infrastructure/prisma-customization-repository.ts b/modules/customizations/infrastructure/prisma-customization-repository.ts index a6862178..4a4c2a9c 100644 --- a/modules/customizations/infrastructure/prisma-customization-repository.ts +++ b/modules/customizations/infrastructure/prisma-customization-repository.ts @@ -10,11 +10,33 @@ import type { * * No business logic here — pure delegation to Prisma. * - * `designPosition` is stored as JSONB. We deliberately accept the - * Prisma `JsonValue` shape and coerce to our `DesignPositionValue` + * designPosition is stored as JSONB. We deliberately accept the + * Prisma JsonValue shape and coerce to our DesignPositionValue * type at the boundary so the domain layer never depends on Prisma. */ export class PrismaCustomizationRepository implements CustomizationRepository { + private toDomain(row: { + id: string; + productId: string; + text: string | null; + color: string | null; + size: string | null; + imageUrl: string | null; + designPosition: unknown; + createdAt: Date; + }): CustomizationEntity { + return { + id: row.id, + productId: row.productId, + text: row.text, + color: row.color, + size: row.size, + imageUrl: row.imageUrl, + designPosition: coerceDesignPosition(row.designPosition), + createdAt: row.createdAt, + }; + } + async save(entity: CustomizationEntity): Promise { const result = await prisma.customization.upsert({ where: { id: entity.id }, @@ -85,28 +107,6 @@ export class PrismaCustomizationRepository implements CustomizationRepository { }); return count > 0; } - - private toDomain(row: { - id: string; - productId: string; - text: string | null; - color: string | null; - size: string | null; - imageUrl: string | null; - designPosition: unknown; - createdAt: Date; - }): CustomizationEntity { - return { - id: row.id, - productId: row.productId, - text: row.text, - color: row.color, - size: row.size, - imageUrl: row.imageUrl, - designPosition: coerceDesignPosition(row.designPosition), - createdAt: row.createdAt, - }; - } } function coerceDesignPosition(value: unknown): DesignPositionValue | null { diff --git a/modules/email/infrastructure/prisma-email-queue-repository.ts b/modules/email/infrastructure/prisma-email-queue-repository.ts index e99906da..a2eab56b 100644 --- a/modules/email/infrastructure/prisma-email-queue-repository.ts +++ b/modules/email/infrastructure/prisma-email-queue-repository.ts @@ -10,14 +10,42 @@ import type { /** * Prisma adapter for the EmailQueueRepository port. * - * Maps the kernel `EmailQueueEntry` shape to the `EmailQueue` Prisma model + * Maps the kernel EmailQueueEntry shape to the EmailQueue Prisma model * and back. This is the only file that knows about the Prisma model shape. * * The 4 worker methods (claimPending, markSent, markFailed, reschedule) are - * the seam that lets `workers/email-worker.ts` stay free of `prisma.*` + * the seam that lets workers/email-worker.ts stay free of prisma.* * imports — the worker resolves them through the container. */ export class PrismaEmailQueueRepository implements EmailQueueRepository { + private toWorkerEntry(row: { + id: string; + to: string; + subject: string; + htmlBody: string; + template: string | null; + metadata: unknown; + createdAt: Date; + status: string; + retryCount: number; + maxRetries: number; + scheduledAt: Date; + }): EmailQueueWorkerEntry { + return { + id: row.id, + to: row.to, + subject: row.subject, + htmlBody: row.htmlBody, + template: row.template ?? '', + metadata: (row.metadata as Record | null) ?? undefined, + createdAt: row.createdAt, + status: row.status, + retryCount: row.retryCount, + maxRetries: row.maxRetries, + scheduledAt: row.scheduledAt, + }; + } + async create(entry: CreateEmailQueueInput): Promise { const row = await prisma.emailQueue.create({ data: { @@ -72,7 +100,7 @@ export class PrismaEmailQueueRepository implements EmailQueueRepository { // ------------------------------------------------------------------------- /** - * Atomically claim up to `batchSize` entries that are due for processing. + * Atomically claim up to atchSize entries that are due for processing. * Implemented as: find PENDING + scheduledAt <= now, then updateMany * marking them PROCESSING. Both operations hit the same model so the * race window is small; for stricter guarantees a transaction can be @@ -139,32 +167,4 @@ export class PrismaEmailQueueRepository implements EmailQueueRepository { }, }); } - - private toWorkerEntry(row: { - id: string; - to: string; - subject: string; - htmlBody: string; - template: string | null; - metadata: unknown; - createdAt: Date; - status: string; - retryCount: number; - maxRetries: number; - scheduledAt: Date; - }): EmailQueueWorkerEntry { - return { - id: row.id, - to: row.to, - subject: row.subject, - htmlBody: row.htmlBody, - template: row.template ?? '', - metadata: (row.metadata as Record | null) ?? undefined, - createdAt: row.createdAt, - status: row.status, - retryCount: row.retryCount, - maxRetries: row.maxRetries, - scheduledAt: row.scheduledAt, - }; - } } diff --git a/modules/orders/application/assign-to-production-use-case.ts b/modules/orders/application/assign-to-production-use-case.ts index 2ab1289b..2a245759 100644 --- a/modules/orders/application/assign-to-production-use-case.ts +++ b/modules/orders/application/assign-to-production-use-case.ts @@ -28,12 +28,49 @@ export interface AssignToProductionDTO { * State Transition: in_progress → completed * * @example - * ```typescript + * ` ypescript * const useCase = new AssignToProductionUseCase(orderRepo, outboxRepo, transactionalService); * await useCase.execute({ orderId: 'order-1', customizationId: 'custom-1' }); - * ``` + * ` */ export class AssignToProductionUseCase { + /** + * Static method to subscribe to ProductCustomizationCreated events from the event bus. + * + * This method registers a listener that automatically invokes the use case + * when a ProductCustomizationCreated event is emitted by the product-customization module. + * + * Error handling: If the use case execution fails, the error is logged + * but not re-thrown to prevent breaking the event processing pipeline. + * + * @param eventBus - The event bus instance to subscribe to + * @param useCase - The use case instance to invoke on events + * + * @example + * ` ypescript + * const useCase = new AssignToProductionUseCase(orderRepo, outboxRepo); + * AssignToProductionUseCase.subscribe(eventBus, useCase); + * ` + */ + static subscribe( + eventBus: EventBusPort, + useCase: AssignToProductionUseCase, + ): void { + eventBus.on( + GlobalEvents.PRODUCT_CUSTOMIZATION_CREATED, + async (data: unknown) => { + try { + await useCase.execute(data as AssignToProductionDTO); + } catch (error) { + console.error( + 'Error processing ProductCustomizationCreated event:', + error, + ); + } + }, + ); + } + /** * Creates a new AssignToProductionUseCase instance. * @@ -61,12 +98,12 @@ export class AssignToProductionUseCase { * @throws Error if order not found, order not in progress, or invalid state transition * * @example - * ```typescript + * ` ypescript * await useCase.execute({ * orderId: 'order-123', * customizationId: 'custom-456' * }); - * ``` + * ` */ async execute(data: AssignToProductionDTO): Promise { // Find the order @@ -123,41 +160,4 @@ export class AssignToProductionUseCase { ); } } - - /** - * Static method to subscribe to ProductCustomizationCreated events from the event bus. - * - * This method registers a listener that automatically invokes the use case - * when a ProductCustomizationCreated event is emitted by the product-customization module. - * - * Error handling: If the use case execution fails, the error is logged - * but not re-thrown to prevent breaking the event processing pipeline. - * - * @param eventBus - The event bus instance to subscribe to - * @param useCase - The use case instance to invoke on events - * - * @example - * ```typescript - * const useCase = new AssignToProductionUseCase(orderRepo, outboxRepo); - * AssignToProductionUseCase.subscribe(eventBus, useCase); - * ``` - */ - static subscribe( - eventBus: EventBusPort, - useCase: AssignToProductionUseCase, - ): void { - eventBus.on( - GlobalEvents.PRODUCT_CUSTOMIZATION_CREATED, - async (data: unknown) => { - try { - await useCase.execute(data as AssignToProductionDTO); - } catch (error) { - console.error( - 'Error processing ProductCustomizationCreated event:', - error, - ); - } - }, - ); - } } diff --git a/modules/orders/application/handle-cart-checked-out.ts b/modules/orders/application/handle-cart-checked-out.ts index 271e9c92..6d65e5ea 100644 --- a/modules/orders/application/handle-cart-checked-out.ts +++ b/modules/orders/application/handle-cart-checked-out.ts @@ -49,6 +49,24 @@ export interface CartCheckedOutPayload { * - Customization fields are preserved on every line item. */ export class HandleCartCheckedOut { + /** + * Static helper that wires the handler to a CART_CHECKED_OUT listener + * on the event bus. Mirrors the pattern used by MarkAsPaidUseCase + * and AssignToProductionUseCase (events/domain/event-bus-port). + */ + static subscribe( + eventBus: EventBusPort, + useCase: HandleCartCheckedOut, + ): void { + eventBus.on(GlobalEvents.CART_CHECKED_OUT, async (data: unknown) => { + try { + await useCase.execute(data as CartCheckedOutPayload); + } catch (error) { + console.error('Error processing CartCheckedOut event:', error); + } + }); + } + constructor( private orderRepository: OrderRepository, private outboxRepository: OutboxRepository, @@ -127,24 +145,6 @@ export class HandleCartCheckedOut { } }); } - - /** - * Static helper that wires the handler to a CART_CHECKED_OUT listener - * on the event bus. Mirrors the pattern used by MarkAsPaidUseCase - * and AssignToProductionUseCase (events/domain/event-bus-port). - */ - static subscribe( - eventBus: EventBusPort, - useCase: HandleCartCheckedOut, - ): void { - eventBus.on(GlobalEvents.CART_CHECKED_OUT, async (data: unknown) => { - try { - await useCase.execute(data as CartCheckedOutPayload); - } catch (error) { - console.error('Error processing CartCheckedOut event:', error); - } - }); - } } async function buildLineItems( @@ -182,7 +182,7 @@ async function resolveCustomizationSnapshot( const customizationIdList = item.customizationIdList ?? []; if (customizationIdList.length === 0) return null; const lookedUp = await customizationLookup.findByIds(customizationIdList); - return lookedUp.map(toFrozenCustomizationSnapshot); + return lookedUp.map((c) => toFrozenCustomizationSnapshot(c)); } function toFrozenCustomizationSnapshot( diff --git a/modules/orders/application/mark-as-paid-use-case.ts b/modules/orders/application/mark-as-paid-use-case.ts index 979dfcee..3ee5d55e 100644 --- a/modules/orders/application/mark-as-paid-use-case.ts +++ b/modules/orders/application/mark-as-paid-use-case.ts @@ -29,12 +29,40 @@ export interface MarkAsPaidDTO { * State Transition: new → in_progress * * @example - * ```typescript + * ` ypescript * const useCase = new MarkAsPaidUseCase(orderRepo, outboxRepo, transactionalService); * await useCase.execute({ orderId: 'order-1', paymentId: 'pay-1', amount: 100 }); - * ``` + * ` */ export class MarkAsPaidUseCase { + /** + * Static method to subscribe to PaymentCompleted events from the event bus. + * + * This method registers a listener that automatically invokes the use case + * when a PaymentCompleted event is emitted by the payments module. + * + * Error handling: If the use case execution fails, the error is logged + * but not re-thrown to prevent breaking the event processing pipeline. + * + * @param eventBus - The event bus instance to subscribe to + * @param useCase - The use case instance to invoke on events + * + * @example + * ` ypescript + * const useCase = new MarkAsPaidUseCase(orderRepo, outboxRepo); + * MarkAsPaidUseCase.subscribe(eventBus, useCase); + * ` + */ + static subscribe(eventBus: EventBusPort, useCase: MarkAsPaidUseCase): void { + eventBus.on(GlobalEvents.PAYMENT_COMPLETED, async (data: unknown) => { + try { + await useCase.execute(data as MarkAsPaidDTO); + } catch (error) { + console.error('Error processing PaymentCompleted event:', error); + } + }); + } + /** * Creates a new MarkAsPaidUseCase instance. * @@ -62,13 +90,13 @@ export class MarkAsPaidUseCase { * @throws Error if order not found or invalid state transition * * @example - * ```typescript + * ` ypescript * await useCase.execute({ * orderId: 'order-123', * paymentId: 'payment-456', * amount: 99.99 * }); - * ``` + * ` */ async execute(data: MarkAsPaidDTO): Promise { // Find the order @@ -119,32 +147,4 @@ export class MarkAsPaidUseCase { }); } } - - /** - * Static method to subscribe to PaymentCompleted events from the event bus. - * - * This method registers a listener that automatically invokes the use case - * when a PaymentCompleted event is emitted by the payments module. - * - * Error handling: If the use case execution fails, the error is logged - * but not re-thrown to prevent breaking the event processing pipeline. - * - * @param eventBus - The event bus instance to subscribe to - * @param useCase - The use case instance to invoke on events - * - * @example - * ```typescript - * const useCase = new MarkAsPaidUseCase(orderRepo, outboxRepo); - * MarkAsPaidUseCase.subscribe(eventBus, useCase); - * ``` - */ - static subscribe(eventBus: EventBusPort, useCase: MarkAsPaidUseCase): void { - eventBus.on(GlobalEvents.PAYMENT_COMPLETED, async (data: unknown) => { - try { - await useCase.execute(data as MarkAsPaidDTO); - } catch (error) { - console.error('Error processing PaymentCompleted event:', error); - } - }); - } } diff --git a/modules/orders/domain/order-repository.ts b/modules/orders/domain/order-repository.ts index 278563f0..4630795c 100644 --- a/modules/orders/domain/order-repository.ts +++ b/modules/orders/domain/order-repository.ts @@ -3,8 +3,6 @@ import type { OrderLineItemEntity } from './entities/order-line-item'; import type { OrderStatus } from './value-objects/order-status-type'; import type { PaginatedResult } from '@/shared/kernel/domain/value-objects/pagination'; -export type { OrderEntity, OrderLineItemEntity, OrderStatus }; - /** * Repository interface for order persistence operations. * Follows the Repository pattern for data access abstraction. @@ -77,3 +75,7 @@ export interface OrderListFilter { page?: number; pageSize?: number; } + +export { type OrderEntity } from './entities/order'; +export { type OrderLineItemEntity } from './entities/order-line-item'; +export { type OrderStatus } from './value-objects/order-status-type'; diff --git a/modules/presentation/components/design-preview.tsx b/modules/presentation/components/design-preview.tsx index 33876fe7..f422824b 100644 --- a/modules/presentation/components/design-preview.tsx +++ b/modules/presentation/components/design-preview.tsx @@ -32,7 +32,7 @@ export function DesignPreview({ const canvasRef = useRef(null); useEffect(() => { - let cancelled = false; + let isCancelled = false; async function draw() { const c = canvasRef.current?.getContext('2d'); @@ -40,7 +40,7 @@ export function DesignPreview({ const productImg = await loadImage(productImageUrl); const designImg = await loadImage(designImageUrl); - if (cancelled) return; + if (isCancelled) return; c.clearRect(0, 0, width, height); c.fillStyle = '#f4f2e6'; @@ -63,7 +63,7 @@ export function DesignPreview({ draw(); return () => { - cancelled = true; + isCancelled = true; }; }, [productImageUrl, designImageUrl, designPosition, width, height]); diff --git a/modules/products/application/update-product-use-case.ts b/modules/products/application/update-product-use-case.ts index 094541ac..732a1d2d 100644 --- a/modules/products/application/update-product-use-case.ts +++ b/modules/products/application/update-product-use-case.ts @@ -51,9 +51,9 @@ export class UpdateProductUseCase { } const nextPrice = - dto.price !== undefined - ? ProductPrice.create(dto.price, 'EUR' as Currency) - : product.basePrice; + dto.price === undefined + ? product.basePrice + : ProductPrice.create(dto.price, 'EUR' as Currency); const nextStatus = dto.status ?? product.status; if (dto.status && dto.status !== product.status) { @@ -82,9 +82,9 @@ export class UpdateProductUseCase { locale: dto.locale, name: nextName ?? currentTranslation?.name ?? '', description: - dto.description !== undefined - ? dto.description.trim() || null - : (currentTranslation?.description ?? null), + dto.description === undefined + ? (currentTranslation?.description ?? null) + : dto.description.trim() || null, }; const translations = product.translations.some( @@ -102,20 +102,20 @@ export class UpdateProductUseCase { basePrice: nextPrice, status: nextStatus, customizationConfig: - dto.customizationConfig !== undefined - ? ProductCustomizationConfig.fromJson(dto.customizationConfig) - : product.customizationConfig, + dto.customizationConfig === undefined + ? product.customizationConfig + : ProductCustomizationConfig.fromJson(dto.customizationConfig), images: - dto.images !== undefined - ? dto.images.map((image, index) => ({ + dto.images === undefined + ? product.images + : dto.images.map((image, index) => ({ id: randomUUID(), url: image.url, alt: image.alt, position: index, productId: product.id, createdAt: now, - })) - : product.images, + })), updatedAt: now, translations, }; diff --git a/modules/products/domain/value-objects/category-id.ts b/modules/products/domain/value-objects/category-id.ts index ce9efa47..8cae69f9 100644 --- a/modules/products/domain/value-objects/category-id.ts +++ b/modules/products/domain/value-objects/category-id.ts @@ -7,11 +7,11 @@ import { EntityId } from '@/shared/kernel/domain/value-objects/entity-id'; * and equality semantics. Identical pattern to ProductId/SellerId. */ export class CategoryId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): CategoryId { return new CategoryId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/modules/products/domain/value-objects/product-customization-config.ts b/modules/products/domain/value-objects/product-customization-config.ts index f48af852..50dd60bd 100644 --- a/modules/products/domain/value-objects/product-customization-config.ts +++ b/modules/products/domain/value-objects/product-customization-config.ts @@ -99,30 +99,6 @@ const productCustomizationConfigSchema = z .strip(); export class ProductCustomizationConfig { - readonly mode: CustomizationMode; - readonly previewEnabled: boolean; - readonly previewTemplateUrl: string | null; - readonly sizeOptions: string[] | null; - readonly textOffset: PreviewOffset | null; - readonly imageOffset: PreviewOffset | null; - readonly allowPhotoDesign: boolean; - readonly designChangeDescription: string | null; - readonly categoryId: string | null; - readonly tagNames: string[] | null; - - private constructor(data: ProductCustomizationConfigJson) { - this.mode = data.mode; - this.previewEnabled = data.previewEnabled; - this.previewTemplateUrl = data.previewTemplateUrl; - this.sizeOptions = data.sizeOptions; - this.textOffset = data.textOffset; - this.imageOffset = data.imageOffset; - this.allowPhotoDesign = data.allowPhotoDesign ?? false; - this.designChangeDescription = data.designChangeDescription ?? null; - this.categoryId = data.categoryId ?? null; - this.tagNames = data.tagNames ?? null; - } - static default(): ProductCustomizationConfig { return new ProductCustomizationConfig({ mode: 'description', @@ -159,6 +135,30 @@ export class ProductCustomizationConfig { }); } + readonly mode: CustomizationMode; + readonly previewEnabled: boolean; + readonly previewTemplateUrl: string | null; + readonly sizeOptions: string[] | null; + readonly textOffset: PreviewOffset | null; + readonly imageOffset: PreviewOffset | null; + readonly allowPhotoDesign: boolean; + readonly designChangeDescription: string | null; + readonly categoryId: string | null; + readonly tagNames: string[] | null; + + private constructor(data: ProductCustomizationConfigJson) { + this.mode = data.mode; + this.previewEnabled = data.previewEnabled; + this.previewTemplateUrl = data.previewTemplateUrl; + this.sizeOptions = data.sizeOptions; + this.textOffset = data.textOffset; + this.imageOffset = data.imageOffset; + this.allowPhotoDesign = data.allowPhotoDesign ?? false; + this.designChangeDescription = data.designChangeDescription ?? null; + this.categoryId = data.categoryId ?? null; + this.tagNames = data.tagNames ?? null; + } + isDefault(): boolean { return ( JSON.stringify(this.toJson()) === diff --git a/modules/products/domain/value-objects/product-description.ts b/modules/products/domain/value-objects/product-description.ts index 53325e26..d73f8fb9 100644 --- a/modules/products/domain/value-objects/product-description.ts +++ b/modules/products/domain/value-objects/product-description.ts @@ -9,12 +9,6 @@ * - Non-null: max 2000 characters, trimmed */ export class ProductDescription { - readonly value: string; - - private constructor(value: string) { - this.value = value; - } - static create(value: string | null): ProductDescription | null { if (value === null) { return null; @@ -29,6 +23,12 @@ export class ProductDescription { return new ProductDescription(trimmed); } + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + equals(other: ProductDescription): boolean { return other instanceof ProductDescription && this.value === other.value; } diff --git a/modules/products/domain/value-objects/product-name.ts b/modules/products/domain/value-objects/product-name.ts index 2a46a157..51ca1255 100644 --- a/modules/products/domain/value-objects/product-name.ts +++ b/modules/products/domain/value-objects/product-name.ts @@ -9,12 +9,6 @@ * - Input is trimmed before validation */ export class ProductName { - readonly value: string; - - private constructor(value: string) { - this.value = value; - } - static create(value: string): ProductName { const trimmed = value.trim(); @@ -29,6 +23,12 @@ export class ProductName { return new ProductName(trimmed); } + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + equals(other: ProductName): boolean { return other instanceof ProductName && this.value === other.value; } diff --git a/modules/products/domain/value-objects/product-price.ts b/modules/products/domain/value-objects/product-price.ts index a17c1e42..507323d6 100644 --- a/modules/products/domain/value-objects/product-price.ts +++ b/modules/products/domain/value-objects/product-price.ts @@ -2,6 +2,16 @@ import { Money } from '@/shared/kernel/domain/value-objects/money'; import type { Currency } from '@/shared/kernel/domain/value-objects/currency'; export class ProductPrice { + static create(amount: number, currency: Currency): ProductPrice { + const money = Money.create(amount, currency); + + if (money.amount <= 0) { + throw new Error('ProductPrice amount must be greater than zero'); + } + + return new ProductPrice(money); + } + readonly money: Money; private constructor(money: Money) { @@ -16,16 +26,6 @@ export class ProductPrice { return this.money.currency; } - static create(amount: number, currency: Currency): ProductPrice { - const money = Money.create(amount, currency); - - if (money.amount <= 0) { - throw new Error('ProductPrice amount must be greater than zero'); - } - - return new ProductPrice(money); - } - format(): string { return Money.format(this.amount, this.currency); } diff --git a/modules/products/infrastructure/prisma-product-repository.ts b/modules/products/infrastructure/prisma-product-repository.ts index 0f8ed8b2..1ab6eb9c 100644 --- a/modules/products/infrastructure/prisma-product-repository.ts +++ b/modules/products/infrastructure/prisma-product-repository.ts @@ -9,6 +9,106 @@ import { toDomainProduct, toPersistenceProduct } from './mapper'; import { normalizeText } from '@/shared/lib/normalize-text'; export class PrismaProductRepository implements ProductRepository { + /** + * Build Prisma WHERE conditions, optionally skipping the q filter. + * When shouldSkipQ is true, the search term is handled separately via + * `searchByUnaccent()` so the query can use PostgreSQL's unaccent + * extension for accent-insensitive matching. + */ + private buildWhere( + filter: ProductsListFilter, + locale: string, + shouldSkipQ: boolean = false, + ): import('@prisma/client').Prisma.ProductWhereInput { + const conditions: import('@prisma/client').Prisma.ProductWhereInput[] = []; + + // Public audience: force status=ACTIVE. This closes the DRAFT/ARCHIVED + // leak that the public storefront and `/api/products?audience=public` + // inherited from the seller/admin paths. Seller and admin audiences + // see all statuses unchanged. + if (filter.audience === 'public') { + conditions.push({ status: 'ACTIVE' }); + } + + if (filter.category !== undefined && filter.category !== '') { + conditions.push({ category: { slug: filter.category } }); + } + + if (filter.tags !== undefined && filter.tags.length > 0) { + conditions.push({ tags: { some: { slug: { in: filter.tags } } } }); + } + + if (!shouldSkipQ && filter.q !== undefined && filter.q !== '') { + // Case-insensitive fallback when unaccent is not needed or + // when shouldSkipQ is false (e.g. seller queries without search). + conditions.push({ + OR: [ + { + translations: { + some: { + locale: { in: [locale, 'es'] }, + OR: [ + { name: { contains: filter.q, mode: 'insensitive' } }, + { description: { contains: filter.q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + tags: { + some: { + OR: [ + { name: { contains: filter.q, mode: 'insensitive' } }, + { slug: { contains: filter.q, mode: 'insensitive' } }, + ], + }, + }, + }, + ], + }); + } + + if (filter.sellerId !== undefined) { + conditions.push({ sellerId: filter.sellerId }); + } + + return conditions.length > 0 ? { AND: conditions } : {}; + } + + /** + * Accent-insensitive product ID search using PostgreSQL unaccent(). + * Returns distinct product IDs that match the query against + * translation name/description or tag name/slug. + * + * The unaccent extension MUST be enabled (see migration). + */ + private async searchByUnaccent(q: string, locale: string): Promise { + let normQ = normalizeText(q); + // Escape SQL ILIKE wildcards so user input like "100%" or "a_b" is + // treated literally instead of expanding to unintended patterns. + normQ = normQ + .replaceAll('%', String.raw`\%`) + .replaceAll('_', String.raw`\_`); + // Parameterised placeholders ($1, $2) prevent SQL injection. + const rows = await prisma.$queryRawUnsafe>( + `SELECT DISTINCT p.id + FROM "Product" p + LEFT JOIN "ProductTranslation" pt + ON pt."productId" = p.id AND pt.locale IN ($1, 'es') + LEFT JOIN "_ProductTags" ptags + ON ptags."A" = p.id + LEFT JOIN "Tag" t + ON t.id = ptags."B" + WHERE unaccent(pt.name) ILIKE unaccent($2) + OR unaccent(pt.description) ILIKE unaccent($2) + OR unaccent(t.name) ILIKE unaccent($2) + OR unaccent(t.slug) ILIKE unaccent($2)`, + locale, + `%${normQ}%`, + ); + return rows.map((r) => r.id); + } + async findAll(locale: string): Promise { const products = await prisma.product.findMany({ include: { @@ -127,104 +227,6 @@ export class PrismaProductRepository implements ProductRepository { }; } - /** - * Build Prisma WHERE conditions, optionally skipping the q filter. - * When skipQ is true, the search term is handled separately via - * `searchByUnaccent()` so the query can use PostgreSQL's unaccent - * extension for accent-insensitive matching. - */ - private buildWhere( - filter: ProductsListFilter, - locale: string, - skipQ: boolean = false, - ): import('@prisma/client').Prisma.ProductWhereInput { - const conditions: import('@prisma/client').Prisma.ProductWhereInput[] = []; - - // Public audience: force status=ACTIVE. This closes the DRAFT/ARCHIVED - // leak that the public storefront and `/api/products?audience=public` - // inherited from the seller/admin paths. Seller and admin audiences - // see all statuses unchanged. - if (filter.audience === 'public') { - conditions.push({ status: 'ACTIVE' }); - } - - if (filter.category !== undefined && filter.category !== '') { - conditions.push({ category: { slug: filter.category } }); - } - - if (filter.tags !== undefined && filter.tags.length > 0) { - conditions.push({ tags: { some: { slug: { in: filter.tags } } } }); - } - - if (!skipQ && filter.q !== undefined && filter.q !== '') { - // Case-insensitive fallback when unaccent is not needed or - // when skipQ is false (e.g. seller queries without search). - conditions.push({ - OR: [ - { - translations: { - some: { - locale: { in: [locale, 'es'] }, - OR: [ - { name: { contains: filter.q, mode: 'insensitive' } }, - { description: { contains: filter.q, mode: 'insensitive' } }, - ], - }, - }, - }, - { - tags: { - some: { - OR: [ - { name: { contains: filter.q, mode: 'insensitive' } }, - { slug: { contains: filter.q, mode: 'insensitive' } }, - ], - }, - }, - }, - ], - }); - } - - if (filter.sellerId !== undefined) { - conditions.push({ sellerId: filter.sellerId }); - } - - return conditions.length > 0 ? { AND: conditions } : {}; - } - - /** - * Accent-insensitive product ID search using PostgreSQL unaccent(). - * Returns distinct product IDs that match the query against - * translation name/description or tag name/slug. - * - * The unaccent extension MUST be enabled (see migration). - */ - private async searchByUnaccent(q: string, locale: string): Promise { - let normQ = normalizeText(q); - // Escape SQL ILIKE wildcards so user input like "100%" or "a_b" is - // treated literally instead of expanding to unintended patterns. - normQ = normQ.replace(/%/g, String.raw`\%`).replace(/_/g, String.raw`\_`); - // Parameterised placeholders ($1, $2) prevent SQL injection. - const rows = await prisma.$queryRawUnsafe>( - `SELECT DISTINCT p.id - FROM "Product" p - LEFT JOIN "ProductTranslation" pt - ON pt."productId" = p.id AND pt.locale IN ($1, 'es') - LEFT JOIN "_ProductTags" ptags - ON ptags."A" = p.id - LEFT JOIN "Tag" t - ON t.id = ptags."B" - WHERE unaccent(pt.name) ILIKE unaccent($2) - OR unaccent(pt.description) ILIKE unaccent($2) - OR unaccent(t.name) ILIKE unaccent($2) - OR unaccent(t.slug) ILIKE unaccent($2)`, - locale, - `%${normQ}%`, - ); - return rows.map((r) => r.id); - } - async save(entity: ProductEntity): Promise { const data = toPersistenceProduct(entity); await prisma.product.create({ diff --git a/modules/products/presentation/components/product-actions.tsx b/modules/products/presentation/components/product-actions.tsx index 81e533a1..47d00cdc 100644 --- a/modules/products/presentation/components/product-actions.tsx +++ b/modules/products/presentation/components/product-actions.tsx @@ -52,7 +52,12 @@ export function ProductActions({ }); if (!res.ok) { - const errorData = await res.json().catch(() => null); + let errorData: { error?: string } | null = null; + try { + errorData = await res.json(); + } catch { + /* ignore parse errors */ + } throw new Error(errorData?.error || `HTTP ${res.status}`); } diff --git a/modules/roles/infrastructure/prisma-role-repository.ts b/modules/roles/infrastructure/prisma-role-repository.ts index 19b35491..8443956b 100644 --- a/modules/roles/infrastructure/prisma-role-repository.ts +++ b/modules/roles/infrastructure/prisma-role-repository.ts @@ -41,7 +41,7 @@ export class PrismaRoleRepository implements RoleRepository { orderBy: { name: 'asc' }, }); - return rows.map(toEntity); + return rows.map((r) => toEntity(r)); } async findByName(name: string): Promise { diff --git a/modules/search-history/application/handle-product-search-executed.ts b/modules/search-history/application/handle-product-search-executed.ts index 588fe387..deeec07b 100644 --- a/modules/search-history/application/handle-product-search-executed.ts +++ b/modules/search-history/application/handle-product-search-executed.ts @@ -21,6 +21,23 @@ import { RecordSearchUseCase } from './record-search-use-case'; * single failure does not break the in-process bus. */ export class HandleProductSearchExecuted { + /** + * Wire the handler to the event bus. Mirrors the + * `HandleCartCheckedOut.subscribe` pattern so HMR doesn't double-register. + */ + static subscribe( + eventBus: EventBusPort, + handler: HandleProductSearchExecuted, + ): void { + eventBus.on(GlobalEvents.PRODUCT_SEARCH_EXECUTED, async (data: unknown) => { + try { + await handler.handle(data as ProductSearchExecutedPayload); + } catch (error) { + console.error('Error processing ProductSearchExecuted event:', error); + } + }); + } + constructor(private readonly recordSearch: RecordSearchUseCase) {} async handle( @@ -43,21 +60,4 @@ export class HandleProductSearchExecuted { locale: payload.locale, }); } - - /** - * Wire the handler to the event bus. Mirrors the - * `HandleCartCheckedOut.subscribe` pattern so HMR doesn't double-register. - */ - static subscribe( - eventBus: EventBusPort, - handler: HandleProductSearchExecuted, - ): void { - eventBus.on(GlobalEvents.PRODUCT_SEARCH_EXECUTED, async (data: unknown) => { - try { - await handler.handle(data as ProductSearchExecutedPayload); - } catch (error) { - console.error('Error processing ProductSearchExecuted event:', error); - } - }); - } } diff --git a/modules/search-history/domain/search-history-repository.ts b/modules/search-history/domain/search-history-repository.ts index 1ec70533..72e44d28 100644 --- a/modules/search-history/domain/search-history-repository.ts +++ b/modules/search-history/domain/search-history-repository.ts @@ -13,8 +13,6 @@ */ import type { SearchHistoryEntry } from './entities/search-history-entry'; -export type { SearchHistoryEntry }; - export interface RecordSearchInput { readonly userId: string; readonly term: string; @@ -44,3 +42,5 @@ export interface SearchHistoryRepository { */ findRecent(input: FindRecentInput): Promise; } + +export { type SearchHistoryEntry } from './entities/search-history-entry'; diff --git a/modules/sellers/infrastructure/prisma-seller-repository.ts b/modules/sellers/infrastructure/prisma-seller-repository.ts index f70aee1f..c414bb6b 100644 --- a/modules/sellers/infrastructure/prisma-seller-repository.ts +++ b/modules/sellers/infrastructure/prisma-seller-repository.ts @@ -27,6 +27,27 @@ import { prisma } from '@/shared/infrastructure/prisma'; * (Transactional Outbox Pattern). */ export class PrismaSellerRepository implements SellerRepository { + private buildWhere( + filter: SellersListFilter, + ): import('@prisma/client').Prisma.SellerWhereInput { + const where: import('@prisma/client').Prisma.SellerWhereInput = { + deletedAt: null, + }; + + if (filter.status !== undefined) { + where.status = filter.status; + } + + if (filter.q !== undefined && filter.q.trim() !== '') { + where.OR = [ + { name: { contains: filter.q.trim(), mode: 'insensitive' } }, + { description: { contains: filter.q.trim(), mode: 'insensitive' } }, + ]; + } + + return where; + } + async save( seller: SellerEntity, tx: PrismaClient = prisma, @@ -64,14 +85,14 @@ export class PrismaSellerRepository implements SellerRepository { const rows = await prisma.seller.findMany({ where: { deletedAt: null }, }); - return rows.map(toDomain); + return rows.map((r) => toDomain(r)); } async findAllByStatus(status: SellerStatus): Promise { const rows = await prisma.seller.findMany({ where: { status, deletedAt: null }, }); - return rows.map(toDomain); + return rows.map((r) => toDomain(r)); } async findPaginated( @@ -93,7 +114,7 @@ export class PrismaSellerRepository implements SellerRepository { const total = await prisma.seller.count({ where }); return { - items: rows.map(toDomain), + items: rows.map((r) => toDomain(r)), total, page, pageSize, @@ -101,27 +122,6 @@ export class PrismaSellerRepository implements SellerRepository { }; } - private buildWhere( - filter: SellersListFilter, - ): import('@prisma/client').Prisma.SellerWhereInput { - const where: import('@prisma/client').Prisma.SellerWhereInput = { - deletedAt: null, - }; - - if (filter.status !== undefined) { - where.status = filter.status; - } - - if (filter.q !== undefined && filter.q.trim() !== '') { - where.OR = [ - { name: { contains: filter.q.trim(), mode: 'insensitive' } }, - { description: { contains: filter.q.trim(), mode: 'insensitive' } }, - ]; - } - - return where; - } - async update( seller: SellerEntity, tx: PrismaClient = prisma, diff --git a/modules/sellers/presentation/components/seller-detail-form.tsx b/modules/sellers/presentation/components/seller-detail-form.tsx index 67205f67..722aa04b 100644 --- a/modules/sellers/presentation/components/seller-detail-form.tsx +++ b/modules/sellers/presentation/components/seller-detail-form.tsx @@ -50,7 +50,12 @@ export function SellerDetailForm({ }), }); - const responseText = await response.text().catch(() => ''); + let responseText = ''; + try { + responseText = await response.text(); + } catch { + responseText = ''; + } if (!response.ok) { let errorMessage = ''; diff --git a/modules/uploads/application/cleanup-uploads-use-case.ts b/modules/uploads/application/cleanup-uploads-use-case.ts index 4245806d..166a8b38 100644 --- a/modules/uploads/application/cleanup-uploads-use-case.ts +++ b/modules/uploads/application/cleanup-uploads-use-case.ts @@ -30,10 +30,10 @@ export class CleanupUploadsUseCase { // Remove metadata await this.uploadRepo.remove(upload.id); deleted++; - } catch (err) { + } catch (error) { console.error( `[CleanupUploadsUseCase] Failed to clean up upload ${upload.id}:`, - err, + error, ); // Still remove metadata even if R2 delete fails try { diff --git a/modules/uploads/application/create-upload-use-case.ts b/modules/uploads/application/create-upload-use-case.ts index e647c08b..a1e4da09 100644 --- a/modules/uploads/application/create-upload-use-case.ts +++ b/modules/uploads/application/create-upload-use-case.ts @@ -9,6 +9,13 @@ import { import { ValidationError } from '@/shared/kernel/app-error'; import { randomUUID } from 'node:crypto'; +export class InvalidUploadError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidUploadError'; + } +} + const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB const PRIVATE_READ_TTL = 7 * 24 * 3600; // 7 days — presigned URL for private buckets @@ -55,11 +62,10 @@ export class CreateUploadUseCase { } // 3. Validate file size - if (input.size <= 0) { - throw new ValidationError( - `Invalid file size: ${input.size}`, - 'Invalid file size', - ); + const fileSizeInBytes = input.size; + + if (!Number.isFinite(fileSizeInBytes) || fileSizeInBytes <= 0) { + throw new InvalidUploadError('File size must be greater than zero'); } if (input.size > MAX_FILE_SIZE) { throw new ValidationError( diff --git a/modules/uploads/application/delete-upload-use-case.ts b/modules/uploads/application/delete-upload-use-case.ts index 26bb5cf0..de0276d8 100644 --- a/modules/uploads/application/delete-upload-use-case.ts +++ b/modules/uploads/application/delete-upload-use-case.ts @@ -37,10 +37,10 @@ export class DeleteUploadUseCase { // 4. Delete from R2 AFTER transaction succeeds (best-effort) try { await this.storage.delete(upload.storageKey); - } catch (err) { + } catch (error) { console.error( `[DeleteUploadUseCase] R2 delete failed for key ${upload.storageKey}:`, - err, + error, ); } } diff --git a/modules/uploads/domain/upload-repository.ts b/modules/uploads/domain/upload-repository.ts index 30770656..dfd7da29 100644 --- a/modules/uploads/domain/upload-repository.ts +++ b/modules/uploads/domain/upload-repository.ts @@ -1,7 +1,5 @@ import type { UploadEntity } from './entities/upload'; -export type { UploadEntity }; - /** * UploadRepository — the persistence port for uploads. * @@ -14,3 +12,5 @@ export interface UploadRepository { remove(id: string): Promise; findPendingOlderThan(hours: number): Promise; } + +export { type UploadEntity } from './entities/upload'; diff --git a/modules/uploads/domain/value-objects/upload-status.ts b/modules/uploads/domain/value-objects/upload-status.ts index b9c49c0d..fe42c726 100644 --- a/modules/uploads/domain/value-objects/upload-status.ts +++ b/modules/uploads/domain/value-objects/upload-status.ts @@ -4,5 +4,5 @@ * Single source of truth: Prisma schema enum. * Re-exported here so domain code uses a local symbol. */ -import { UploadStatus } from '@prisma/client'; -export { UploadStatus }; + +export { UploadStatus } from '@prisma/client'; diff --git a/modules/uploads/domain/value-objects/upload-type.ts b/modules/uploads/domain/value-objects/upload-type.ts index 1a4c5649..ffc9e4f5 100644 --- a/modules/uploads/domain/value-objects/upload-type.ts +++ b/modules/uploads/domain/value-objects/upload-type.ts @@ -5,5 +5,5 @@ * Re-exported here so domain code uses a local symbol, * not a raw `@prisma/client` import everywhere. */ -import { UploadType } from '@prisma/client'; -export { UploadType }; + +export { UploadType } from '@prisma/client'; diff --git a/modules/uploads/infrastructure/r2-storage-adapter.ts b/modules/uploads/infrastructure/r2-storage-adapter.ts index 1606b9cb..59c82a68 100644 --- a/modules/uploads/infrastructure/r2-storage-adapter.ts +++ b/modules/uploads/infrastructure/r2-storage-adapter.ts @@ -26,14 +26,14 @@ import type { StoragePort } from '../domain/storage-port'; * (e.g. "https://cdn.example.com") */ export class R2StorageAdapter implements StoragePort { + /** Upload types that route to the public bucket. */ + private static readonly PUBLIC_TYPES = new Set(['product', 'avatar']); + private readonly client: S3Client; private readonly publicBucket: string; private readonly privateBucket: string; private readonly publicDomain: string; - /** Upload types that route to the public bucket. */ - private static readonly PUBLIC_TYPES = new Set(['product', 'avatar']); - constructor() { const fallback = requireEnv('R2_BUCKET', 'dummy-bucket'); this.publicBucket = process.env.R2_PUBLIC_BUCKET || fallback; @@ -58,7 +58,7 @@ export class R2StorageAdapter implements StoragePort { /** Resolve which bucket to use based on the storage key prefix. */ private getBucket(key: string): string { - const type = key.split('/')[0]; + const type = key.split('/', 1)[0]; return R2StorageAdapter.PUBLIC_TYPES.has(type) ? this.publicBucket : this.privateBucket; diff --git a/modules/users/domain/user-repository.ts b/modules/users/domain/user-repository.ts index 57cb07ea..41388d66 100644 --- a/modules/users/domain/user-repository.ts +++ b/modules/users/domain/user-repository.ts @@ -1,7 +1,5 @@ import type { UserEntity } from './entities/user'; -export type { UserEntity }; - export interface UserRepository { save(user: UserEntity, tx?: unknown): Promise; findByEmail(email: string): Promise; @@ -11,3 +9,5 @@ export interface UserRepository { /** @deprecated Use soft-delete via `update()` with `deletedAt` set instead. */ delete(id: string): Promise; } + +export { type UserEntity } from './entities/user'; diff --git a/prisma/seed.ts b/prisma/seed.ts index f72b2f67..4994a458 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -601,11 +601,13 @@ async function main() { console.log('\n✅ Seed complete!'); } -main() - .catch((error) => { - console.error('❌ Seed failed:', error); +(async () => { + try { + await main(); + } catch (error) { + console.error('? Seed failed:', error); process.exitCode = 1; - }) - .finally(async () => { + } finally { await prisma.$disconnect(); - }); + } +})(); diff --git a/proxy.ts b/proxy.ts index a555f741..a768e171 100644 --- a/proxy.ts +++ b/proxy.ts @@ -20,7 +20,7 @@ function isKnownLocale(segment: string): boolean { return locales.includes(segment); } -function matchesProtectedPath( +function isProtectedPathMatch( pathname: string, protectedPath: string, ): boolean { @@ -31,19 +31,19 @@ function matchesProtectedPath( return false; } - const directMatch = protectedSegments.every( + const hasDirectMatch = protectedSegments.every( (segment, index) => pathnameSegments[index] === segment, ); - if (directMatch) { + if (hasDirectMatch) { return true; } - const localePrefixedMatch = protectedSegments.every( + const hasLocalePrefixedMatch = protectedSegments.every( (segment, index) => isKnownLocale(pathnameSegments[0] ?? '') && pathnameSegments[index + 1] === segment, ); - return localePrefixedMatch; + return hasLocalePrefixedMatch; } export async function proxy(request: NextRequest) { @@ -51,7 +51,7 @@ export async function proxy(request: NextRequest) { // ---- Auth gate for protected routes ---- const isProtected = protectedPaths.some((path) => - matchesProtectedPath(pathname, path), + isProtectedPathMatch(pathname, path), ); if (isProtected) { @@ -86,11 +86,11 @@ export async function proxy(request: NextRequest) { } // Check if pathname already has a locale - const pathnameHasLocale = locales.some( + const hasPathnameLocale = locales.some( (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`, ); - if (pathnameHasLocale) return NextResponse.next(); + if (hasPathnameLocale) return NextResponse.next(); // Static assets in /public don't need locale prefix if ( @@ -115,7 +115,7 @@ function unauthorizedResponse(request: NextRequest, pathname: string) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const requestedLocale = pathname.split('/')[1]; + const requestedLocale = pathname.split('/', 2)[1]; const locale = locales.includes(requestedLocale) ? requestedLocale : defaultLocale; diff --git a/scripts/optimize-sprites.mjs b/scripts/optimize-sprites.mjs index f401562a..8915cdd2 100644 --- a/scripts/optimize-sprites.mjs +++ b/scripts/optimize-sprites.mjs @@ -56,7 +56,7 @@ function resolveClassFills(svgContent) { const className = rule .slice(dotIdx + 1, braceIdx) .trim() - .split(/\s+/)[0]; + .split(/\s+/, 1)[0]; const fillMatch = rule.slice(braceIdx).match(/fill:\s*([^;}\s]+)/); if (className && fillMatch) { classMap[className] = fillMatch[1].trim(); @@ -68,7 +68,7 @@ function resolveClassFills(svgContent) { let result = svgContent; for (const [cls, fill] of Object.entries(classMap)) { const classAttr = new RegExp(`class="${cls}"`, 'g'); - result = result.replace(classAttr, `fill="${fill}"`); + result = result.replace(classAttr, () => `fill="${fill}"`); } result = result.replace(styleRegex, ''); diff --git a/shared/contracts/email/email-queue-port.ts b/shared/contracts/email/email-queue-port.ts index e5c36328..ae79d93d 100644 --- a/shared/contracts/email/email-queue-port.ts +++ b/shared/contracts/email/email-queue-port.ts @@ -1,8 +1,6 @@ import type { EmailQueueEntry } from '@/modules/email/domain/entities/email-queue-entry'; import type { EmailQueueWorkerEntry } from '@/modules/email/domain/entities/email-queue-worker-entry'; -export type { EmailQueueEntry, EmailQueueWorkerEntry }; - export type CreateEmailQueueInput = Omit; /** @@ -68,3 +66,6 @@ export interface EmailQueueRepository { error: string, ): Promise; } + +export { type EmailQueueEntry } from '@/modules/email/domain/entities/email-queue-entry'; +export { type EmailQueueWorkerEntry } from '@/modules/email/domain/entities/email-queue-worker-entry'; diff --git a/shared/i18n/get-dictionary.ts b/shared/i18n/get-dictionary.ts index 8d4c44bc..3d73a892 100644 --- a/shared/i18n/get-dictionary.ts +++ b/shared/i18n/get-dictionary.ts @@ -12,5 +12,7 @@ const dictionaries: Record Promise> = { export const getDictionary = async ( locale: 'es' | 'cat', ): Promise => { - return dictionaries[locale] ? dictionaries[locale]() : dictionaries.es(); + return Object.hasOwn(dictionaries, locale) + ? dictionaries[locale]() + : dictionaries.es(); }; diff --git a/shared/infrastructure/auth-options.ts b/shared/infrastructure/auth-options.ts index b07e1600..ff6b6e17 100644 --- a/shared/infrastructure/auth-options.ts +++ b/shared/infrastructure/auth-options.ts @@ -25,7 +25,7 @@ export const authOptions: NextAuthOptions = { 'x-forwarded-for' ] ?.toString() - .split(',')[0] + .split(',', 1)[0] ?.trim() || (req?.headers as Record)?.[ 'x-real-ip' diff --git a/shared/infrastructure/prisma.ts b/shared/infrastructure/prisma.ts index a4b702ab..c421cb25 100644 --- a/shared/infrastructure/prisma.ts +++ b/shared/infrastructure/prisma.ts @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; -const globalForPrisma = global as unknown as { prisma: PrismaClient }; +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); diff --git a/shared/kernel/container.ts b/shared/kernel/container.ts index 6101adbe..ccb20767 100644 --- a/shared/kernel/container.ts +++ b/shared/kernel/container.ts @@ -2,7 +2,7 @@ * Composition Root — central place where all dependencies are wired * according to the current environment. * - * Call `initContainer()` once at each process entry point (worker, Next.js + * Call initContainer() once at each process entry point (worker, Next.js * server, test setup). After that, retrieve bindings via the typed getters. * * Architecture: @@ -14,12 +14,14 @@ */ import type { EmailSender } from './email-sender'; +import { BrevoEmailSender } from './brevo-email-sender'; +import { ConsoleEmailSender } from './console-email-sender'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- -let _emailSender: EmailSender | null = null; +const state: Record = {}; // --------------------------------------------------------------------------- // Initialization @@ -27,18 +29,11 @@ let _emailSender: EmailSender | null = null; /** * Initialize all dependency bindings for the current environment. - * Must be called exactly once per process before any getter is used. + * Idempotent — safe to call multiple times. Each getter lazily initializes + * its own dependency on first access. */ -export async function initContainer(): Promise { - if (_emailSender) return; // idempotent — safe to call multiple times - - if (process.env.NODE_ENV === 'production') { - const { BrevoEmailSender } = await import('./brevo-email-sender'); - _emailSender = new BrevoEmailSender(); - } else { - const { ConsoleEmailSender } = await import('./console-email-sender'); - _emailSender = new ConsoleEmailSender(); - } +export function initContainer(): void { + getEmailSender(); } // --------------------------------------------------------------------------- @@ -47,21 +42,22 @@ export async function initContainer(): Promise { /** * Returns the EmailSender bound for the current environment. - * Throws if `initContainer()` has not been called yet. + * Lazily initializes on first call. */ export function getEmailSender(): EmailSender { - if (!_emailSender) { - throw new Error( - '[Container] Not initialized. Call initContainer() at your process entry point before using getters.', - ); + if (!state.emailSender) { + state.emailSender = + process.env.NODE_ENV === 'production' + ? new BrevoEmailSender() + : new ConsoleEmailSender(); } - return _emailSender; + return state.emailSender as EmailSender; } // --------------------------------------------------------------------------- // Testing helpers // --------------------------------------------------------------------------- -// In tests you can call `initContainer()` or override individual bindings: +// In tests you can call initContainer() or override individual bindings: // // import { container } from '@/shared/kernel/container'; // container.setEmailSender(new MockEmailSender()); @@ -73,6 +69,6 @@ export const container = { getEmailSender, /** Override — useful in tests to inject a mock without touching env vars. */ setEmailSender(sender: EmailSender): void { - _emailSender = sender; + state.emailSender = sender; }, }; diff --git a/shared/kernel/domain/identifiers/role-id.ts b/shared/kernel/domain/identifiers/role-id.ts index a7349342..a35b42fe 100644 --- a/shared/kernel/domain/identifiers/role-id.ts +++ b/shared/kernel/domain/identifiers/role-id.ts @@ -1,11 +1,11 @@ import { EntityId } from '@/shared/kernel/domain/value-objects/entity-id'; export class RoleId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): RoleId { return new RoleId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/address.ts b/shared/kernel/domain/value-objects/address.ts index 5d1bcf21..28bd6777 100644 --- a/shared/kernel/domain/value-objects/address.ts +++ b/shared/kernel/domain/value-objects/address.ts @@ -1,21 +1,4 @@ export class Address { - readonly street: string; - readonly city: string; - readonly postalCode: string; - readonly country: string; - - private constructor( - street: string, - city: string, - postalCode: string, - country: string, - ) { - this.street = street; - this.city = city; - this.postalCode = postalCode; - this.country = country; - } - static create( street: string, city: string, @@ -44,6 +27,23 @@ export class Address { ); } + readonly street: string; + readonly city: string; + readonly postalCode: string; + readonly country: string; + + private constructor( + street: string, + city: string, + postalCode: string, + country: string, + ) { + this.street = street; + this.city = city; + this.postalCode = postalCode; + this.country = country; + } + equals(other: Address): boolean { return ( other instanceof Address && diff --git a/shared/kernel/domain/value-objects/email.ts b/shared/kernel/domain/value-objects/email.ts index 189789ad..ce9fcbb2 100644 --- a/shared/kernel/domain/value-objects/email.ts +++ b/shared/kernel/domain/value-objects/email.ts @@ -1,10 +1,4 @@ export class Email { - readonly value: string; - - private constructor(value: string) { - this.value = value; - } - static create(email: string): Email { const trimmed = email.trim().toLowerCase(); @@ -24,6 +18,12 @@ export class Email { return new Email(trimmed); } + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + equals(other: Email): boolean { return other instanceof Email && this.value === other.value; } diff --git a/shared/kernel/domain/value-objects/localized-date.ts b/shared/kernel/domain/value-objects/localized-date.ts index f6c2a209..89c3946f 100644 --- a/shared/kernel/domain/value-objects/localized-date.ts +++ b/shared/kernel/domain/value-objects/localized-date.ts @@ -9,14 +9,6 @@ * {d.toString()} // "25/6/2026" for es */ export class LocalizedDate { - readonly date: Date; - readonly locale: string; - - private constructor(date: Date, locale: string) { - this.date = date; - this.locale = locale; - } - static create(date: Date | string | number, locale: string): LocalizedDate { if (!locale) { throw new Error('LocalizedDate requires a locale'); @@ -24,12 +16,20 @@ export class LocalizedDate { const d = date instanceof Date ? date : new Date(date); if (Number.isNaN(d.getTime())) { - throw new Error('LocalizedDate received an invalid date'); + throw new TypeError('LocalizedDate received an invalid date'); } return new LocalizedDate(d, locale); } + readonly date: Date; + readonly locale: string; + + private constructor(date: Date, locale: string) { + this.date = date; + this.locale = locale; + } + /** Format using the route locale, e.g. "25/6/2026". */ toString(): string { return this.date.toLocaleDateString(this.locale); diff --git a/shared/kernel/domain/value-objects/money.ts b/shared/kernel/domain/value-objects/money.ts index 77cc5d92..2b254991 100644 --- a/shared/kernel/domain/value-objects/money.ts +++ b/shared/kernel/domain/value-objects/money.ts @@ -1,6 +1,52 @@ import { Currency } from './currency'; export class Money { + static create(amount: number, currency: Currency): Money { + if (!Number.isFinite(amount)) { + throw new TypeError('Money amount must be a finite number'); + } + + if (amount < 0) { + throw new Error('Money amount cannot be negative'); + } + + if (!currency) { + throw new Error('Currency is required'); + } + + return new Money(amount, currency); + } + + static format(amount: number, currency: Currency): string { + if (!Number.isFinite(amount)) { + throw new TypeError('Money.format amount must be a finite number'); + } + if (amount < 0) { + throw new Error('Money amount cannot be negative'); + } + if (!currency) { + throw new Error('Money.format currency is required'); + } + return `${amount.toFixed(2)} ${this.getSymbol(currency)}`; + } + + private static getSymbol(currency: Currency): string { + switch (currency) { + case Currency.EUR: { + return '€'; + } + case Currency.USD: { + return '$'; + } + case Currency.GBP: { + return '£'; + } + default: { + return currency; + } + } + } + readonly amount: number; readonly currency: Currency; @@ -14,27 +60,19 @@ export class Money { this.currency = currency; } - static create(amount: number, currency: Currency): Money { - if (!Number.isFinite(amount)) { - throw new Error('Money amount must be a finite number'); - } - - if (amount < 0) { - throw new Error('Money amount cannot be negative'); - } - - if (!currency) { - throw new Error('Currency is required'); + private assertSameCurrency(other: Money): void { + if (this.currency !== other.currency) { + throw new Error( + `Cannot operate on Money with different currencies: ${this.currency} vs ${other.currency}`, + ); } - - return new Money(amount, currency); } add(other: Money): Money { this.assertSameCurrency(other); const resultAmount = this.amount + other.amount; if (!Number.isFinite(resultAmount)) { - throw new Error('Money addition resulted in a non-finite amount'); + throw new TypeError('Money addition resulted in a non-finite amount'); } return new Money(resultAmount, this.currency); } @@ -43,18 +81,20 @@ export class Money { this.assertSameCurrency(other); const resultAmount = this.amount - other.amount; if (!Number.isFinite(resultAmount)) { - throw new Error('Money subtraction resulted in a non-finite amount'); + throw new TypeError('Money subtraction resulted in a non-finite amount'); } return new Money(resultAmount, this.currency); } multiply(multiplier: number): Money { if (!Number.isFinite(multiplier)) { - throw new Error('Money multiplier must be a finite number'); + throw new TypeError('Money multiplier must be a finite number'); } const resultAmount = this.amount * multiplier; if (!Number.isFinite(resultAmount)) { - throw new Error('Money multiplication resulted in a non-finite amount'); + throw new TypeError( + 'Money multiplication resulted in a non-finite amount', + ); } return new Money(resultAmount, this.currency); } @@ -70,38 +110,4 @@ export class Money { format(): string { return `${this.amount.toFixed(2)} ${Money.getSymbol(this.currency)}`; } - - static format(amount: number, currency: Currency): string { - if (!Number.isFinite(amount)) { - throw new Error('Money.format amount must be a finite number'); - } - if (amount < 0) { - throw new Error('Money amount cannot be negative'); - } - if (!currency) { - throw new Error('Money.format currency is required'); - } - return `${amount.toFixed(2)} ${this.getSymbol(currency)}`; - } - - private static getSymbol(currency: Currency): string { - switch (currency) { - case Currency.EUR: - return '€'; - case Currency.USD: - return '$'; - case Currency.GBP: - return '£'; - default: - return currency; - } - } - - private assertSameCurrency(other: Money): void { - if (this.currency !== other.currency) { - throw new Error( - `Cannot operate on Money with different currencies: ${this.currency} vs ${other.currency}`, - ); - } - } } diff --git a/shared/kernel/domain/value-objects/order-id.ts b/shared/kernel/domain/value-objects/order-id.ts index 94889d24..4b39b9f7 100644 --- a/shared/kernel/domain/value-objects/order-id.ts +++ b/shared/kernel/domain/value-objects/order-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class OrderId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): OrderId { return new OrderId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/password-hash.ts b/shared/kernel/domain/value-objects/password-hash.ts index 299bc2f2..8ee74b89 100644 --- a/shared/kernel/domain/value-objects/password-hash.ts +++ b/shared/kernel/domain/value-objects/password-hash.ts @@ -1,10 +1,4 @@ export class PasswordHash { - readonly value: string; - - private constructor(value: string) { - this.value = value; - } - static create(hash: string): PasswordHash { const trimmed = hash.trim(); @@ -19,6 +13,12 @@ export class PasswordHash { return new PasswordHash(trimmed); } + readonly value: string; + + private constructor(value: string) { + this.value = value; + } + equals(other: PasswordHash): boolean { return other instanceof PasswordHash && this.value === other.value; } diff --git a/shared/kernel/domain/value-objects/payment-id.ts b/shared/kernel/domain/value-objects/payment-id.ts index 448da5e8..6cd39d59 100644 --- a/shared/kernel/domain/value-objects/payment-id.ts +++ b/shared/kernel/domain/value-objects/payment-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class PaymentId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): PaymentId { return new PaymentId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/product-id.ts b/shared/kernel/domain/value-objects/product-id.ts index 48c6d674..d2fe5861 100644 --- a/shared/kernel/domain/value-objects/product-id.ts +++ b/shared/kernel/domain/value-objects/product-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class ProductId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): ProductId { return new ProductId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/seller-id.ts b/shared/kernel/domain/value-objects/seller-id.ts index cb200f67..25797715 100644 --- a/shared/kernel/domain/value-objects/seller-id.ts +++ b/shared/kernel/domain/value-objects/seller-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class SellerId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): SellerId { return new SellerId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/ticket-id.ts b/shared/kernel/domain/value-objects/ticket-id.ts index 81e4aec5..bd6470af 100644 --- a/shared/kernel/domain/value-objects/ticket-id.ts +++ b/shared/kernel/domain/value-objects/ticket-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class TicketId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): TicketId { return new TicketId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/domain/value-objects/user-id.ts b/shared/kernel/domain/value-objects/user-id.ts index 71cdb910..8c59c9a8 100644 --- a/shared/kernel/domain/value-objects/user-id.ts +++ b/shared/kernel/domain/value-objects/user-id.ts @@ -1,11 +1,11 @@ import { EntityId } from './entity-id'; export class UserId extends EntityId { - private constructor(value: string) { - super(value); - } - static create(value: string): UserId { return new UserId(value); } + + private constructor(value: string) { + super(value); + } } diff --git a/shared/kernel/escape-html.ts b/shared/kernel/escape-html.ts index e9cb0485..3f177275 100644 --- a/shared/kernel/escape-html.ts +++ b/shared/kernel/escape-html.ts @@ -8,9 +8,9 @@ export function escapeHtml(str: string | null | undefined): string { if (str == null) return ''; return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } diff --git a/shared/layout/header-nav.tsx b/shared/layout/header-nav.tsx index 977a69b3..359f784c 100644 --- a/shared/layout/header-nav.tsx +++ b/shared/layout/header-nav.tsx @@ -20,7 +20,7 @@ export function HeaderNav({ loginLabel, cartAlt }: HeaderNavProps) { const { data: session, status } = useSession(); const [isLoginOpen, setIsLoginOpen] = useState(false); const isAuthPage = pathname.includes('/auth/'); - const locale = pathname.split('/')[1] ?? 'es'; + const locale = pathname.split('/', 2)[1] ?? 'es'; if (status === 'authenticated' && session?.user) { const isInternal = diff --git a/shared/layout/language-selector.tsx b/shared/layout/language-selector.tsx index 7a7f70d8..b3cde89c 100644 --- a/shared/layout/language-selector.tsx +++ b/shared/layout/language-selector.tsx @@ -19,7 +19,10 @@ export default function LanguageSelector({ const toggle = TOGGLE[currentLocale] ?? TOGGLE.es; const handleToggle = () => { - const newPath = pathname.replace(`/${currentLocale}`, `/${toggle.target}`); + const newPath = pathname.replace( + `/${currentLocale}`, + () => `/${toggle.target}`, + ); router.push(newPath); }; diff --git a/shared/layout/login-modal.tsx b/shared/layout/login-modal.tsx index dd0b21b4..3a71dcd3 100644 --- a/shared/layout/login-modal.tsx +++ b/shared/layout/login-modal.tsx @@ -43,15 +43,15 @@ export function LoginModal({ isOpen, onClose }: LoginModalProps) { const res = await fetch('/api/auth/session'); const session = await res.json(); const role = session?.user?.role; - const locale = window.location.pathname.split('/')[1] ?? 'es'; + const locale = globalThis.location.pathname.split('/', 2)[1] ?? 'es'; if (role === 'ADMIN') { - window.location.assign(`/${locale}/admin/sellers`); + globalThis.location.assign(`/${locale}/admin/sellers`); } else if (role === 'DESIGNER') { - window.location.assign(`/${locale}/seller/products`); + globalThis.location.assign(`/${locale}/seller/products`); } } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : ''; + } catch (error_: unknown) { + const message = error_ instanceof Error ? error_.message : ''; if (message.includes('CredentialsSignin')) { setError(dict.auth.invalidCredentials); } else { diff --git a/shared/lib/normalize-text.ts b/shared/lib/normalize-text.ts index a491bfeb..e106ab9f 100644 --- a/shared/lib/normalize-text.ts +++ b/shared/lib/normalize-text.ts @@ -10,5 +10,5 @@ * "São Paulo" → "Sao Paulo" */ export function normalizeText(text: string): string { - return text.normalize('NFD').replace(/[\u0300-\u036F]/g, ''); + return text.normalize('NFD').replaceAll(/[\u{0300}-\u{036F}]/gu, ''); } diff --git a/shared/presentation/components/file-upload-dropzone.tsx b/shared/presentation/components/file-upload-dropzone.tsx index 22e73ac0..f6629c46 100644 --- a/shared/presentation/components/file-upload-dropzone.tsx +++ b/shared/presentation/components/file-upload-dropzone.tsx @@ -56,7 +56,7 @@ export function FileUploadDropzone({ const handleFiles = async (fileList: FileList | File[]) => { const files = [...fileList]; - if (!files.length || disabled || busy) return; + if (files.length === 0 || disabled || busy) return; await onFilesSelected(multiple ? files : files.slice(0, 1)); }; @@ -114,9 +114,7 @@ export function FileUploadDropzone({ ) : null} {items.length === 0 ? ( - emptyLabel ? ( -

    {emptyLabel}

    - ) : null + emptyLabel &&

    {emptyLabel}

    ) : (
      {items.map((item) => { diff --git a/shared/presentation/lib/to-absolute-url.ts b/shared/presentation/lib/to-absolute-url.ts index 3e164929..c2c9d39d 100644 --- a/shared/presentation/lib/to-absolute-url.ts +++ b/shared/presentation/lib/to-absolute-url.ts @@ -7,5 +7,5 @@ export function toAbsoluteUrl(url: string): string { return url; } - return new URL(url, window.location.origin).href; + return new URL(url, globalThis.location.origin).href; } diff --git a/shared/presentation/status-labels.ts b/shared/presentation/status-labels.ts index 1859b705..70e94a97 100644 --- a/shared/presentation/status-labels.ts +++ b/shared/presentation/status-labels.ts @@ -18,7 +18,15 @@ export function resolveStatusLabel( dict: Record, fallback = status, ): string { - const key = - map[status] ?? map[status.toLowerCase()] ?? map[status.toUpperCase()]; - return key && dict[key] ? dict[key] : fallback; + let key: string | undefined; + if (Object.hasOwn(map, status)) { + key = map[status]; + } else if (Object.hasOwn(map, status.toLowerCase())) { + key = map[status.toLowerCase()]; + } else if (Object.hasOwn(map, status.toUpperCase())) { + key = map[status.toUpperCase()]; + } + + if (key && Object.hasOwn(dict, key)) return dict[key]; + return fallback; } diff --git a/shared/ui/auth-card.tsx b/shared/ui/auth-card.tsx index 51fa773c..c08de40d 100644 --- a/shared/ui/auth-card.tsx +++ b/shared/ui/auth-card.tsx @@ -7,7 +7,7 @@ interface AuthCardProps { export function AuthCard({ children, className }: AuthCardProps) { return ( -
      +
      {children}
      ); diff --git a/shared/ui/eye-toggle-wrapper.tsx b/shared/ui/eye-toggle-wrapper.tsx index e40fe837..fdb48e2e 100644 --- a/shared/ui/eye-toggle-wrapper.tsx +++ b/shared/ui/eye-toggle-wrapper.tsx @@ -26,10 +26,12 @@ export function EyeToggleWrapper({ }; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleVisibility(); + if (!(e.key === 'Enter' || e.key === ' ')) { + return; } + + e.preventDefault(); + toggleVisibility(); }; const inputType = showPassword ? 'text' : 'password'; diff --git a/shared/ui/file-upload-dropzone.tsx b/shared/ui/file-upload-dropzone.tsx index 22e73ac0..f6629c46 100644 --- a/shared/ui/file-upload-dropzone.tsx +++ b/shared/ui/file-upload-dropzone.tsx @@ -56,7 +56,7 @@ export function FileUploadDropzone({ const handleFiles = async (fileList: FileList | File[]) => { const files = [...fileList]; - if (!files.length || disabled || busy) return; + if (files.length === 0 || disabled || busy) return; await onFilesSelected(multiple ? files : files.slice(0, 1)); }; @@ -114,9 +114,7 @@ export function FileUploadDropzone({ ) : null} {items.length === 0 ? ( - emptyLabel ? ( -

      {emptyLabel}

      - ) : null + emptyLabel &&

      {emptyLabel}

      ) : (
        {items.map((item) => { diff --git a/shared/ui/modal.tsx b/shared/ui/modal.tsx index 151f250d..bf66a01c 100644 --- a/shared/ui/modal.tsx +++ b/shared/ui/modal.tsx @@ -21,10 +21,12 @@ export function Modal({ isOpen, onClose, children }: ModalProps) { ); useEffect(() => { - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); + if (!isOpen) { + return; } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleKeyDown]); if (!isOpen) return null; diff --git a/shared/ui/password-strength-indicator.tsx b/shared/ui/password-strength-indicator.tsx index 13313438..3331bfc6 100644 --- a/shared/ui/password-strength-indicator.tsx +++ b/shared/ui/password-strength-indicator.tsx @@ -25,12 +25,14 @@ export function PasswordStrengthIndicator({ const metCount = criteria.filter((c) => c.met).length; const percentage = Math.round((metCount / 3) * 100); - const strengthClass = - percentage === 100 - ? styles.strong - : percentage >= 33 - ? styles.medium - : styles.weak; + let strengthClass: string; + if (percentage === 100) { + strengthClass = styles.strong; + } else if (percentage >= 33) { + strengthClass = styles.medium; + } else { + strengthClass = styles.weak; + } return (
        diff --git a/shared/ui/price-field.tsx b/shared/ui/price-field.tsx index 2fa3bd6b..cec01a4e 100644 --- a/shared/ui/price-field.tsx +++ b/shared/ui/price-field.tsx @@ -18,7 +18,7 @@ export function PriceField({ onChange, error, required, - currencySymbol = '\u20AC', + currencySymbol = '\u{20AC}', }: PriceFieldProps) { const id = useId(); const handleChange = (e: ChangeEvent) => { diff --git a/shared/ui/quantity-controls.tsx b/shared/ui/quantity-controls.tsx index 4cc468aa..0de89240 100644 --- a/shared/ui/quantity-controls.tsx +++ b/shared/ui/quantity-controls.tsx @@ -17,19 +17,23 @@ export function QuantityControls({ onChange, min = 1, max = 99, - decrementLabel = '\u2212', + decrementLabel = '\u{2212}', incrementLabel = '+', variant = 'default', }: QuantityControlsProps) { const clamp = (n: number) => Math.max(min, Math.min(max, n)); // Mostrar siempre el símbolo como contenido visual, // el label se usa solo para accesibilidad. - const decSymbol = '\u2212'; + const decSymbol = '\u{2212}'; const incSymbol = '+'; return (
        ))} diff --git a/shared/ui/wave-transition.tsx b/shared/ui/wave-transition.tsx index 9ad912d5..77ec611d 100644 --- a/shared/ui/wave-transition.tsx +++ b/shared/ui/wave-transition.tsx @@ -7,7 +7,7 @@ interface WaveTransitionProps { export function WaveTransition({ animatedText = 'DETALLES QUE DEJAN HUELLA', }: WaveTransitionProps) { - const nbsp = '\u00A0'; + const nbsp = '\u{A0}'; const gap = nbsp.repeat(60); const repeatedText = `${animatedText}${gap}${animatedText}`; diff --git a/tests/doubles/memory-rate-limiter.ts b/tests/doubles/memory-rate-limiter.ts index abfc12a3..36494161 100644 --- a/tests/doubles/memory-rate-limiter.ts +++ b/tests/doubles/memory-rate-limiter.ts @@ -8,7 +8,7 @@ import type { * * Stores login attempts in plain arrays. Test cases can: * - call checkRateLimit / recordLoginAttempt as the production code would - * - inspect `attempts` to assert what was persisted + * - inspect ttempts to assert what was persisted * * Enforces the SAME thresholds as the production Prisma adapter so unit * tests exercise the same boundary conditions: @@ -19,13 +19,6 @@ import type { * these — production wires the Prisma adapter. */ export class MemoryRateLimiter implements RateLimiter { - /** Thresholds — keep in sync with the Prisma adapter and docs/security-gaps.md. */ - public readonly EMAIL_FAIL_THRESHOLD = 5; - public readonly IP_FAIL_THRESHOLD = 20; - public readonly WINDOW_MS = 15 * 60 * 1000; - public readonly EMAIL_BLOCK_SECONDS = 15 * 60; - public readonly IP_BLOCK_SECONDS = 60 * 60; - private store: Array<{ email: string; ip: string; @@ -33,6 +26,13 @@ export class MemoryRateLimiter implements RateLimiter { createdAt: Date; }> = []; + /** Thresholds — keep in sync with the Prisma adapter and docs/security-gaps.md. */ + public readonly EMAIL_FAIL_THRESHOLD = 5; + public readonly IP_FAIL_THRESHOLD = 20; + public readonly WINDOW_MS = 15 * 60 * 1000; + public readonly EMAIL_BLOCK_SECONDS = 15 * 60; + public readonly IP_BLOCK_SECONDS = 60 * 60; + async checkRateLimit(email: string, ip: string): Promise { const since = new Date(Date.now() - this.WINDOW_MS); @@ -70,9 +70,9 @@ export class MemoryRateLimiter implements RateLimiter { async recordLoginAttempt( email: string, ip: string, - success: boolean, + isSuccess: boolean, ): Promise { - this.store.push({ email, ip, success, createdAt: new Date() }); + this.store.push({ email, ip, success: isSuccess, createdAt: new Date() }); } /** Test helper — return the full list of stored attempts. */ diff --git a/tests/unit/modules/uploads/application/create-upload-use-case.test.ts b/tests/unit/modules/uploads/application/create-upload-use-case.test.ts index 204b69d6..cfc0bdbc 100644 --- a/tests/unit/modules/uploads/application/create-upload-use-case.test.ts +++ b/tests/unit/modules/uploads/application/create-upload-use-case.test.ts @@ -244,7 +244,7 @@ describe('CreateUploadUseCase', () => { ).rejects.toThrow('File too large'); }); - it('should throw ValidationError for zero-size files', async () => { + it('should throw InvalidUploadError for zero-size files', async () => { await expect( useCase.execute({ userId: 'user-1', @@ -253,10 +253,10 @@ describe('CreateUploadUseCase', () => { mimeType: 'image/webp', size: 0, }), - ).rejects.toThrow('Invalid file size'); + ).rejects.toThrow('File size must be greater than zero'); }); - it('should throw ValidationError for negative size', async () => { + it('should throw InvalidUploadError for negative size', async () => { await expect( useCase.execute({ userId: 'user-1', @@ -265,7 +265,7 @@ describe('CreateUploadUseCase', () => { mimeType: 'image/webp', size: -1, }), - ).rejects.toThrow('Invalid file size'); + ).rejects.toThrow('File size must be greater than zero'); }); // ── StorageKey Format ───────────────────────────────────────